From 80cadf72421f9c19cab3ee49933e60c6e555b686 Mon Sep 17 00:00:00 2001 From: yuta Date: Thu, 24 Sep 2026 18:50:44 +0900 Subject: [PATCH] feat: persist research conversations and refine deck headers --- README.md | 33 +- docs/plans/2026-09-24-home-agent-research.md | 49 +- docs/research-decks.md | 6 + drizzle/0003_romantic_scrambler.sql | 14 + drizzle/meta/0003_snapshot.json | 562 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + e2e/integrations/research.test.ts | 4 +- src/components/app-shell.tsx | 5 - src/features/decks/deck-column.tsx | 8 +- src/features/decks/deck-page.tsx | 5 - src/features/decks/decks.css | 12 +- src/features/platforms/platform-icon.tsx | 37 ++ src/features/research/model.ts | 24 +- src/features/research/repository.server.ts | 87 +++ src/features/research/repository.test.ts | 162 +++++ src/features/research/research-panel.css | 32 +- src/features/research/research-panel.test.tsx | 177 +++++- src/features/research/research-panel.tsx | 96 ++- src/features/research/runner.server.ts | 143 ++++- src/features/research/runner.test.ts | 323 +++++++++- src/features/research/server-functions.ts | 9 + src/features/storage/migrations.generated.ts | 9 + src/features/storage/schema.ts | 21 + src/styles.css | 21 - 24 files changed, 1719 insertions(+), 127 deletions(-) create mode 100644 drizzle/0003_romantic_scrambler.sql create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 src/features/platforms/platform-icon.tsx create mode 100644 src/features/research/repository.server.ts create mode 100644 src/features/research/repository.test.ts diff --git a/README.md b/README.md index f43881e..f33b772 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ bound to a connection account, so platforms and multiple accounts work side by s - Native X search syntax, Top/Latest ranking, and optional `filter:follows` - List discovery for the profile selected in the column editor - Column editing, ordering, deletion/undo, manual refresh, and cursor pagination +- Platform logos in column headers distinguish Twitter and Mastodon at a glance - SQLite-backed shared decks, revision conflicts, and device-local active selection - Temporary views for AI exploration, with explicit save and temporary copies - Server-only encrypted Mastodon credentials and Tailscale owner access @@ -19,6 +20,8 @@ bound to a connection account, so platforms and multiple accounts work side by s - Experimental WebMCP tools to manage decks and read or paginate their columns - Prototype: chat with a resident home Codex beside a live deck, reuse existing decks and write cited Markdown research reports on the host +- Saved chat history with deck, account and citation restoration across devices + and backend restarts Both `/` and `/deck` open the deck workspace. The separate reader, search, list, user-profile, and conversation routes have been removed. Original-post @@ -104,10 +107,12 @@ accounts' lists before planning new searches, then read relevant list columns for evidence. Follow-ups can reuse catalogs already fetched in the conversation. Manual deck creation and column editing remain available. The backend handles tools even when the browser is closed. -Use **新しいチャット** to clear the current conversation and start a fresh Codex +Use **新しいチャット** to leave the current conversation and start a fresh Codex thread with your next message. The open deck and host reports remain available. If a turn is running, stop it first. The conversation reset is shared across -open devices; previous conversations are not listed in this prototype. +open devices. **チャット履歴** reopens saved conversations with their account +selection, deck and citations; the next message resumes the same Codex thread. +SQLite stores conversation snapshots and the active conversation selection. Each turn is limited to 6 columns, 12 upstream requests shared between list discovery and post retrieval (at most 20 posts per fetch), and 15 minutes. The prototype accepts up to 100 messages per backend process. @@ -120,8 +125,8 @@ with matching source and account settings, creating a temporary citation deck when needed. These captured posts are labeled and do not change feed pagination or reveal sensitive content automatically. Other links still open externally; Ctrl/Cmd-click on a citation also opens the original site. -Citation metadata stays with the in-memory conversation across reconnects and -follow-up messages. It is not saved in SQLite. Markdown HTML is not executed and +Citation metadata stays with the saved conversation across reconnects, +follow-up messages and server restarts. Markdown HTML is not executed and Markdown images are rendered as links. Reopening the page receives the latest state and restores the temporary deck. Later updates modify the same temporary view; they do not overwrite saved decks @@ -131,15 +136,16 @@ to persist a deck. The agent and visible columns currently fetch posts separatel Codex writes `//report.md` using its workspace file tools. The UI displays its host path after validating the file. A conversation or deck edit can finish without producing a report. Citation quality is model output, -not independently verified. No research-history or snapshot tables are added; -report contents are not stored in the app DB. Only the latest conversation is shown. +not independently verified. Report contents remain on the host filesystem; +the app DB stores the conversation, thread ID, deck context and citation metadata. Browser disconnection does not cancel research; reconnecting restores the latest state through `/api/research/events`, under the same owner access checks as the -rest of the app. The app keeps run state in -memory; backend/process restart recovery is not implemented. A backend failure -can leave Codex work unfinished and dynamic tools unavailable; files already -written remain. Cancellation is explicit. The launcher and report directory must +rest of the app. After a backend restart, unfinished turns appear as interrupted; +sending another message resumes the same thread. Before starting that message, +the backend checks the resumed thread and interrupts any orphaned active turn. +It does not automatically rerun interrupted requests. Files already written remain. +Cancellation is explicit. The launcher and report directory must run under a user able to write those files. NixOS DynamicUser service integration and dedicated service credentials are outside this prototype. @@ -173,9 +179,10 @@ visible. Conflicting revisions are rejected. Only the active deck is rendered and fetched; posts and cursors are not archived. The active selection stays local to the device. -AI-created decks start as temporary views in the current tab. Save a view to -share it across devices, or make a temporary copy of a saved deck to experiment. -Temporary views disappear on reload or navigation, including OAuth redirects. +AI-created decks open as temporary views reconstructed from their saved chat +on another device or after reload. Save a view to add it to the independent deck +library, or make a temporary copy of a saved deck to experiment. +Other unsaved temporary edits disappear on reload or navigation, including OAuth redirects. Existing version-2 browser decks have an explicit import action; invalid older data is left untouched. See [the deck model and persistence contract](docs/research-decks.md). diff --git a/docs/plans/2026-09-24-home-agent-research.md b/docs/plans/2026-09-24-home-agent-research.md index 708c07a..bc78dc2 100644 --- a/docs/plans/2026-09-24-home-agent-research.md +++ b/docs/plans/2026-09-24-home-agent-research.md @@ -7,9 +7,10 @@ sources, opens a temporary deck, reads posts and writes a cited Markdown report on the host. Twitter Lite provides the connected-account and deck tools; the agent owns research and report writing. -Do not add research-history, snapshot or report tables. Existing SQLite storage -continues to own saved deck definitions and account credentials. Saving a -temporary deck remains an explicit user action. +SQLite stores conversation snapshots and the selected conversation alongside +saved deck definitions and account credentials. Reports remain host Markdown; +their contents are not copied into the DB. Adding a temporary deck to the saved +deck library remains an explicit user action. ## Proposed first implementation @@ -17,7 +18,7 @@ temporary deck remains an explicit user action. 2. Start one home Codex task with a dedicated output directory. 3. Supply tools for connection discovery, temporary deck creation and bounded post retrieval using the existing validators and platform services. -4. Keep execution status and the latest generated deck in process memory. SSE +4. Save conversation state and the latest generated deck in SQLite. SSE sends the current snapshot on connection and pushes subsequent changes. Reopened pages restore the temporary view; later definitions update the same view. Closing that view does not delete the report or stop the agent. @@ -42,9 +43,10 @@ is necessary. - Configure a dedicated report root and Codex authentication store. Preserve refreshed authentication for the service; do not put credentials in prompts or copy application secrets into the child environment. -- Keep manual execution bounded and cancellable. Process restarts end in-memory - tasks; completed Markdown files remain on disk. No durable queue or automatic - restart is required. +- Keep manual execution bounded and cancellable. After restart, unfinished + conversations restore as interrupted. The next user message resumes the same + Codex thread after stopping any surviving orphan turn. No automatic rerun or + durable job queue is added. ## Conversation workspace @@ -85,8 +87,18 @@ The chat header can reset an idle conversation. The next message starts a new Codex thread while retaining the browser's current deck as context. Reset pushes an empty conversation to every subscriber, preserves host reports and does not delete deck definitions. Active turns must be stopped first; a stale reset must -not clear a newer conversation started on another device. This does not add a -conversation-history UI. +not clear a newer conversation started on another device. The history selector +can reopen previous conversations while idle, restoring the generated deck (or +last supplied deck context), selected accounts and citations. Selection is shared +through SSE. Stale selections are rejected just like stale resets. + +`research_sessions` stores the full public conversation snapshot and summary +metadata; `research_state` stores the selected conversation, including an explicit +blank selection for new chat. State is written before starting Codex and before +publishing updates. DB write failures stop research and surface a save error. +On restore, formerly active sessions become interrupted. `thread/resume` restores +Codex's persisted dynamic tools. Recovery suppresses old turn notifications and +tool calls, interrupts a still-running old turn, then starts the new message. ## Markdown and source navigation @@ -96,9 +108,9 @@ regular click on a known post navigates within the deck, while modified clicks and unrelated URLs retain external navigation. Successful agent post fetches capture the exact column definition and a public -post projection in the in-memory conversation. The projection excludes raw +post projection in the saved conversation. The projection excludes raw provider responses, HTML, media and account credentials. References are retained -across turns, updated per source/account/post identity and cleared by new chat. +across turns, updated per source/account/post identity and retained in chat history. The agent is instructed to cite exact returned post URLs in Markdown. Navigation prefers a loaded card, then a workspace column with the same source @@ -259,3 +271,18 @@ be handled by the backend so research can continue without the initiating tab. Closing and reopening the page preserved working citation navigation. - react-markdown 10.1.0 and remark-gfm 4.0.1 were added with the user's approval. The pnpm dependency hash was updated and the x86_64-linux Nix package built. + +## Conversation persistence verification + +- SQLite repository tests reopen file-backed databases and verify conversation, + deck, account, citation and active-selection restoration, plus transaction rollback. +- Runner tests recreate the service against the same database, resume its thread, + reject stale history switches, interrupt orphan turns and reject late orphan + events. Failed writes prevent new execution or stop further tool calls. +- 52 desktop/mobile E2E cases passed. The final focused research suite passed + 120 tests; typecheck, lint, knip and Nix packaging passed. +- An actual Codex run captured 20 Twitter posts. A fresh Node process restored + that conversation and used the same Codex thread and dynamic tools to add a + second column, retaining all citations. Separate desktop/mobile browser + contexts restored the saved chat, navigated citations, shared history selection + through SSE and retained blank-new-chat selection across reload. diff --git a/docs/research-decks.md b/docs/research-decks.md index 25948ea..47230f0 100644 --- a/docs/research-decks.md +++ b/docs/research-decks.md @@ -84,6 +84,12 @@ content. Temporary views disappear on reload or navigation, including OAuth redirects. Only definitions are saved: posts, cursors, and scroll positions are not archived. +Research chats separately retain the agent-generated deck and last supplied +deck context. Reopening a saved chat reconstructs a temporary view, including +on another device or after a server restart. Chat citations retain a limited +post projection for source navigation; this does not persist the deck's feed +cache, pagination cursors, scroll positions or unsent manual edits. + The UI offers an explicit, one-time import of valid version-2 data from `twitter-lite-research-deck` in localStorage. It resolves old relay profile names to connection IDs and creates new saved deck IDs transactionally. A diff --git a/drizzle/0003_romantic_scrambler.sql b/drizzle/0003_romantic_scrambler.sql new file mode 100644 index 0000000..5ff5044 --- /dev/null +++ b/drizzle/0003_romantic_scrambler.sql @@ -0,0 +1,14 @@ +CREATE TABLE `research_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `title` text NOT NULL, + `status` text NOT NULL, + `updated_at` integer NOT NULL, + `snapshot` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `research_state` ( + `id` integer PRIMARY KEY NOT NULL, + `active_session_id` text, + FOREIGN KEY (`active_session_id`) REFERENCES `research_sessions`(`id`) ON UPDATE no action ON DELETE set null, + CONSTRAINT "research_state_singleton" CHECK("research_state"."id" = 1) +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..ba446a0 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,562 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d021bc5b-2422-467a-ab92-2493da6e642a", + "prevId": "ebfb67e2-a014-4d40-89e3-30c9b55cf047", + "tables": { + "connection_credentials": { + "name": "connection_credentials", + "columns": { + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "encrypted_token": { + "name": "encrypted_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "connection_credentials_connection_id_connections_id_fk": { + "name": "connection_credentials_connection_id_connections_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "connection_credentials_app_id_oauth_apps_id_fk": { + "name": "connection_credentials_app_id_oauth_apps_id_fk", + "tableFrom": "connection_credentials", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connections": { + "name": "connections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "relay_profile": { + "name": "relay_profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connections_account": { + "name": "connections_account", + "columns": ["platform", "origin", "account_id"], + "isUnique": true + }, + "connections_relay_profile": { + "name": "connections_relay_profile", + "columns": ["origin", "relay_profile"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deck_columns": { + "name": "deck_columns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_id": { + "name": "deck_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "deck_columns_position": { + "name": "deck_columns_position", + "columns": ["deck_id", "position"], + "isUnique": true + } + }, + "foreignKeys": { + "deck_columns_deck_id_decks_id_fk": { + "name": "deck_columns_deck_id_decks_id_fk", + "tableFrom": "deck_columns", + "tableTo": "decks", + "columnsFrom": ["deck_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deck_columns_connection_id_connections_id_fk": { + "name": "deck_columns_connection_id_connections_id_fk", + "tableFrom": "deck_columns", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deck_columns_deck_id_id_pk": { + "columns": ["deck_id", "id"], + "name": "deck_columns_deck_id_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": { + "deck_columns_valid_position": { + "name": "deck_columns_valid_position", + "value": "\"deck_columns\".\"position\" >= 0" + } + } + }, + "decks": { + "name": "decks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "decks_positive_revision": { + "name": "decks_positive_revision", + "value": "\"decks\".\"revision\" >= 1" + } + } + }, + "legacy_imports": { + "name": "legacy_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload_hash": { + "name": "payload_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deck_ids": { + "name": "deck_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_apps": { + "name": "oauth_apps", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_client_secret": { + "name": "encrypted_client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_apps_configuration": { + "name": "oauth_apps_configuration", + "columns": ["origin", "redirect_uri", "scopes"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_attempts": { + "name": "oauth_attempts", + "columns": { + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "browser_hash": { + "name": "browser_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_verifier": { + "name": "encrypted_verifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_attempts_app_id_oauth_apps_id_fk": { + "name": "oauth_attempts_app_id_oauth_apps_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "oauth_apps", + "columnsFrom": ["app_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_attempts_connection_id_connections_id_fk": { + "name": "oauth_attempts_connection_id_connections_id_fk", + "tableFrom": "oauth_attempts", + "tableTo": "connections", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_sessions": { + "name": "research_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "research_state": { + "name": "research_state", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "active_session_id": { + "name": "active_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "research_state_active_session_id_research_sessions_id_fk": { + "name": "research_state_active_session_id_research_sessions_id_fk", + "tableFrom": "research_state", + "tableTo": "research_sessions", + "columnsFrom": ["active_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "research_state_singleton": { + "name": "research_state_singleton", + "value": "\"research_state\".\"id\" = 1" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 61cb3c3..818e70b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1790234736939, "tag": "0002_fine_master_chief", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1790242505709, + "tag": "0003_romantic_scrambler", + "breakpoints": true } ] } diff --git a/e2e/integrations/research.test.ts b/e2e/integrations/research.test.ts index 984d9b3..9142c8c 100644 --- a/e2e/integrations/research.test.ts +++ b/e2e/integrations/research.test.ts @@ -28,8 +28,8 @@ test('pushes the current research snapshot on each SSE connection', async ({ return [await connect(), await connect()] }) expect(snapshots).toEqual([ - { configured: false, run: null }, - { configured: false, run: null }, + { configured: false, run: null, history: [] }, + { configured: false, run: null, history: [] }, ]) }) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 7e11488..e0eb297 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,5 +1,3 @@ -import { Icon } from './icon' - export function AppShell({ sidebar, researchChat, @@ -14,9 +12,6 @@ export function AppShell({ {researchChat}
-

- -

{sidebar}
{children} diff --git a/src/features/decks/deck-column.tsx b/src/features/decks/deck-column.tsx index 61cccdf..17f717f 100644 --- a/src/features/decks/deck-column.tsx +++ b/src/features/decks/deck-column.tsx @@ -6,6 +6,7 @@ import { import { PostLoadError } from '#/features/posts/use-post-feed' import { citationUrlKey } from '#/features/research/citation-url' import type { CitationTarget } from '#/features/research/use-citation-navigation' +import { PlatformIcon } from '../platforms/platform-icon' import { type ColumnRegistry, useRegisterColumn } from './column-tools' import type { DeckColumn } from './model' import { ResearchPostCard } from './research-post-card' @@ -50,7 +51,7 @@ export function ResearchColumn({
- +

{column.title}

{sourceDescription.query}

diff --git a/src/features/decks/deck-page.tsx b/src/features/decks/deck-page.tsx index c6ef1b8..f35141a 100644 --- a/src/features/decks/deck-page.tsx +++ b/src/features/decks/deck-page.tsx @@ -214,11 +214,6 @@ export function DeckPage() { - {!deck.persisted && ready ? ( -

- 一時ビューです。再読み込みやタブを閉じると失われます。 -

- ) : null} {saving ? (

保存しています… diff --git a/src/features/decks/decks.css b/src/features/decks/decks.css index 7158ff2..b7cc157 100644 --- a/src/features/decks/decks.css +++ b/src/features/decks/decks.css @@ -115,9 +115,6 @@ align-items: center; gap: var(--space-xs); } -.deck-column-title > svg { - color: var(--accent); -} .deck-column h2 { flex: 1; min-width: 0; @@ -140,6 +137,15 @@ min-width: 0; overflow-wrap: anywhere; } +.deck-source-label { + display: inline-flex; + align-items: center; + gap: var(--space-2xs); +} +.deck-source-label svg { + width: 14px; + height: 14px; +} .deck-query { margin: var(--space-2xs) 0; font-size: var(--text-xs); diff --git a/src/features/platforms/platform-icon.tsx b/src/features/platforms/platform-icon.tsx new file mode 100644 index 0000000..587257e --- /dev/null +++ b/src/features/platforms/platform-icon.tsx @@ -0,0 +1,37 @@ +import type { DeckColumn } from '../decks/model' + +// Brand paths from Simple Icons (CC0): twitter v9.0.0 and mastodon. +// https://github.com/simple-icons/simple-icons +const icons = { + twitter: { + label: 'Twitter', + color: '#1d9bf0', + path: 'M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z', + }, + mastodon: { + label: 'Mastodon', + color: '#858afa', + path: 'M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z', + }, +} + +export function PlatformIcon({ + platform, +}: { + platform: DeckColumn['source']['platform'] +}) { + const icon = icons[platform] + return ( + + {icon.label} + + + ) +} diff --git a/src/features/research/model.ts b/src/features/research/model.ts index 8f47bc2..ba4ca57 100644 --- a/src/features/research/model.ts +++ b/src/features/research/model.ts @@ -19,9 +19,18 @@ export const startResearchInput = z export type ResearchRun = { id: string topic: string - status: 'starting' | 'running' | 'complete' | 'failed' | 'cancelled' + status: + | 'starting' + | 'running' + | 'complete' + | 'failed' + | 'cancelled' + | 'interrupted' startedAt: number threadId?: string + turnId?: string + connectionIds?: string[] + contextDeck?: Deck deck?: Deck deckVersion: number message: string @@ -31,6 +40,19 @@ export type ResearchRun = { error?: string } +export type ResearchHistoryEntry = { + id: string + title: string + status: ResearchRun['status'] + updatedAt: number +} + +export type ResearchSnapshot = { + configured: boolean + run: ResearchRun | null + history: ResearchHistoryEntry[] +} + export type ResearchCitation = { column: DeckColumn; post: ResearchPost } export type ResearchMessage = { diff --git a/src/features/research/repository.server.ts b/src/features/research/repository.server.ts new file mode 100644 index 0000000..0d5b610 --- /dev/null +++ b/src/features/research/repository.server.ts @@ -0,0 +1,87 @@ +import { desc, eq } from 'drizzle-orm' +import { getDatabase } from '../storage/database.server' +import { researchSessions, researchState } from '../storage/schema' +import type { ResearchHistoryEntry, ResearchRun } from './model' + +export type ResearchStore = { + loadActive(): ResearchRun | null + load(id: string): ResearchRun | null + list(): ResearchHistoryEntry[] + save(run: ResearchRun): void + activate(id: string | null): void +} + +export function createResearchStore(database = getDatabase()): ResearchStore { + const load = (id: string) => + database + .select({ snapshot: researchSessions.snapshot }) + .from(researchSessions) + .where(eq(researchSessions.id, id)) + .get()?.snapshot ?? null + + const setActive = (id: string | null) => { + database + .insert(researchState) + .values({ id: 1, activeSessionId: id }) + .onConflictDoUpdate({ + target: researchState.id, + set: { activeSessionId: id }, + }) + .run() + } + + return { + load, + loadActive() { + return ( + database + .select({ snapshot: researchSessions.snapshot }) + .from(researchState) + .innerJoin( + researchSessions, + eq(researchState.activeSessionId, researchSessions.id), + ) + .where(eq(researchState.id, 1)) + .get()?.snapshot ?? null + ) + }, + list() { + return database + .select({ + id: researchSessions.id, + title: researchSessions.title, + status: researchSessions.status, + updatedAt: researchSessions.updatedAt, + }) + .from(researchSessions) + .orderBy(desc(researchSessions.updatedAt), desc(researchSessions.id)) + .all() + }, + save(run) { + database.transaction(() => { + const row = { + id: run.id, + title: + run.messages.find((message) => message.role === 'user')?.text ?? + run.topic, + status: run.status, + updatedAt: Date.now(), + snapshot: run, + } + database + .insert(researchSessions) + .values(row) + .onConflictDoUpdate({ target: researchSessions.id, set: row }) + .run() + setActive(run.id) + }) + }, + activate(id) { + database.transaction(() => { + if (id !== null && !load(id)) + throw new Error('この会話は見つかりません。') + setActive(id) + }) + }, + } +} diff --git a/src/features/research/repository.test.ts b/src/features/research/repository.test.ts new file mode 100644 index 0000000..a51cfd5 --- /dev/null +++ b/src/features/research/repository.test.ts @@ -0,0 +1,162 @@ +// @vitest-environment node +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Deck } from '../decks/model' +import { type AppDatabase, openDatabase } from '../storage/database.server' +import type { ResearchRun } from './model' +import { createResearchStore } from './repository.server' + +let directory: string +let databases: AppDatabase[] +function open() { + const database = openDatabase(join(directory, 'research.db')) + databases.push(database) + return database +} + +const deck: Deck = { + id: 'research-deck', + title: 'WebMCP', + columns: [ + { + id: 'search', + title: 'WebMCP posts', + connectionId: 'twitter-account2', + source: { + platform: 'twitter', + kind: 'search', + query: 'WebMCP', + product: 'Latest', + following: false, + }, + }, + ], +} +const run: ResearchRun = { + id: 'conversation-1', + topic: 'WebMCPを調べて', + status: 'complete', + startedAt: 100, + threadId: 'codex-thread-1', + turnId: 'codex-turn-1', + connectionIds: ['twitter-account2'], + contextDeck: { ...deck, id: 'original-deck' }, + deck, + deckVersion: 2, + message: '調査が完了しました。', + messages: [ + { id: 'request', role: 'user', text: 'WebMCPを調べて' }, + { + id: 'answer', + role: 'assistant', + text: '[元の投稿](https://social.invalid/status/1)', + }, + ], + citations: [ + { + column: deck.columns[0] as Deck['columns'][number], + post: { + key: 'twitter:1', + nativeId: '1', + platform: 'twitter', + url: 'https://social.invalid/status/1', + text: 'WebMCP news', + author: { name: 'Researcher', handle: 'researcher' }, + }, + }, + ], + reportPath: 'conversation-1/report.md', +} + +beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'twitter-lite-research-store-')) + databases = [] +}) +afterEach(() => { + for (const database of databases) database.$client.close() + rmSync(directory, { recursive: true }) + vi.restoreAllMocks() +}) + +describe('research history persistence', () => { + it('starts with a blank conversation and no history', () => { + const store = createResearchStore(open()) + expect(store.loadActive()).toBeNull() + expect(store.load('missing')).toBeNull() + expect(store.list()).toEqual([]) + }) + + it('restores the conversation, Codex thread, decks, citations and accounts after reopening', () => { + const database = open() + createResearchStore(database).save(run) + database.$client.close() + const restored = createResearchStore(open()) + expect(restored.loadActive()).toEqual(run) + expect(restored.load(run.id)).toEqual(run) + }) + + it('preserves a blank active chat across restarts without removing history', () => { + const store = createResearchStore(open()) + store.save(run) + store.activate(null) + const restored = createResearchStore(open()) + expect(restored.loadActive()).toBeNull() + expect(restored.load(run.id)).toEqual(run) + restored.activate(run.id) + expect(restored.loadActive()).toEqual(run) + }) + + it('lists updated conversations first and replaces snapshots without duplicating history', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1000) + const store = createResearchStore(open()) + store.save(run) + now.mockReturnValue(2000) + const second = { + ...run, + id: 'conversation-2', + topic: 'Mastodon', + messages: [ + { id: 'second-user', role: 'user' as const, text: 'Mastodon' }, + ], + } + store.save(second) + expect(store.list().map((entry) => entry.id)).toEqual([second.id, run.id]) + now.mockReturnValue(3000) + const updated = { ...run, topic: '続けて', status: 'interrupted' as const } + store.save(updated) + expect(store.list()).toEqual([ + { id: run.id, title: run.topic, status: 'interrupted', updatedAt: 3000 }, + { + id: second.id, + title: second.topic, + status: 'complete', + updatedAt: 2000, + }, + ]) + expect(store.loadActive()).toEqual(updated) + }) + + it('rejects a missing conversation without changing the active conversation', () => { + const store = createResearchStore(open()) + store.save(run) + expect(() => store.activate('missing')).toThrow( + 'この会話は見つかりません。', + ) + expect(store.loadActive()).toEqual(run) + }) + + it('rolls back a snapshot when its active-conversation update fails', () => { + const database = open() + const store = createResearchStore(database) + store.save(run) + database.$client.exec(` + CREATE TRIGGER reject_active_change BEFORE UPDATE ON research_state + BEGIN SELECT RAISE(ABORT, 'disk write failed'); END; + `) + expect(() => store.save({ ...run, id: 'conversation-2' })).toThrow() + expect(store.load('conversation-2')).toBeNull() + expect(store.loadActive()).toEqual(run) + }) +}) diff --git a/src/features/research/research-panel.css b/src/features/research/research-panel.css index 3dbc34e..7548a1b 100644 --- a/src/features/research/research-panel.css +++ b/src/features/research/research-panel.css @@ -23,18 +23,36 @@ margin: 0; font-size: 1rem; } -.research-heading p { - width: 100%; -} .research-heading button { font-size: 0.75rem; } -.research-heading p, .research-context { margin: 0.5rem 0 0; color: var(--secondary); font-size: 0.75rem; } +.research-history { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + font-size: 0.75rem; + color: var(--secondary); +} +.research-history span { + flex-shrink: 0; +} +.research-history select { + flex: 1; + min-width: 0; + font-size: 0.75rem; + color: var(--ink); + background: var(--canvas); + border: 1px solid var(--control-border); + border-radius: 0.25rem; + padding: 0.4rem; +} .research-panel > p { padding: 0.5rem 1rem; margin: 0; @@ -194,9 +212,6 @@ .research-heading { padding: 0.5rem 0.75rem; } - .research-heading p { - display: none; - } .research-conversation { flex: 1 0 80px; min-height: 80px; @@ -207,9 +222,6 @@ .research-run { flex-shrink: 0; } - .deck-toolbar .brand { - display: none; - } .deck-toolbar > button:last-child { width: 100%; } diff --git a/src/features/research/research-panel.test.tsx b/src/features/research/research-panel.test.tsx index 3136067..e94a0fc 100644 --- a/src/features/research/research-panel.test.tsx +++ b/src/features/research/research-panel.test.tsx @@ -2,7 +2,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { Connection } from '../connections/model' -import type { ResearchRun } from './model' +import type { ResearchHistoryEntry, ResearchRun } from './model' import { ResearchPanel } from './research-panel' const actions = vi.hoisted(() => ({ @@ -10,6 +10,7 @@ const actions = vi.hoisted(() => ({ start: vi.fn(), cancel: vi.fn(), reset: vi.fn(), + select: vi.fn(), })) vi.mock('@tanstack/react-start', () => ({ useServerFn: (fn: unknown) => fn })) vi.mock('./server-functions', () => ({ @@ -17,6 +18,7 @@ vi.mock('./server-functions', () => ({ startResearch: actions.start, cancelResearch: actions.cancel, resetResearch: actions.reset, + selectResearch: actions.select, })) const connection: Connection = { @@ -38,7 +40,11 @@ const running: ResearchRun = { messages: [{ id: 'u1', role: 'user', text: 'WebMCPの反応' }], deck: { id: 'plan', title: 'WebMCP調査', columns: [] }, } -let current: { configured: boolean; run: ResearchRun | null } +let current: { + configured: boolean + run: ResearchRun | null + history?: ResearchHistoryEntry[] +} const clients: QueryClient[] = [] class MockEventSource { static instances: MockEventSource[] = [] @@ -146,7 +152,7 @@ it('restores the latest plan automatically on reload and permits manual activati current = { configured: true, run: running } const view = show() await waitFor(() => - expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, false), + expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true), ) fireEvent.click( await screen.findByRole('button', { name: '一時デッキで開く' }), @@ -215,7 +221,7 @@ it('waits for the workspace before restoring a completed plan from the stream', expect(view.onOpenDeck).not.toHaveBeenCalled() view.rerender(view.ui(true)) await waitFor(() => - expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, false), + expect(view.onOpenDeck).toHaveBeenCalledWith(running.deck, true), ) }) @@ -515,3 +521,166 @@ it('renders assistant Markdown and forwards its citation with the current eviden citations, ) }) + +it('opens a saved chat with its context deck and selected accounts, preserving local choices on updates', async () => { + const contextDeck = { id: 'past-deck', title: '以前のデッキ', columns: [] } + const saved: ResearchRun = { + ...running, + id: 'saved', + status: 'interrupted', + threadId: 'saved-thread', + connectionIds: ['second'], + contextDeck, + deck: undefined, + deckVersion: 0, + messages: [ + { + id: 'past', + role: 'assistant', + text: '保存した会話\n\n[前回の引用](https://x.com/author/status/123)', + }, + ], + citations: [ + { + column: { + id: 'source', + title: '投稿元', + connectionId: 'second', + source: { platform: 'twitter', kind: 'user', target: 'author' }, + }, + post: { + key: 'twitter:123', + platform: 'twitter', + nativeId: '123', + url: 'https://x.com/author/status/123', + text: '前回の投稿', + author: { name: 'Author', handle: 'author' }, + }, + }, + ], + } + const history: ResearchHistoryEntry[] = [ + { + id: saved.id, + title: '以前の調査', + status: saved.status, + updatedAt: 1000, + }, + ] + current = { configured: true, run: null, history } + actions.select.mockResolvedValue({ ...current, run: saved }) + const view = show([ + connection, + { ...connection, id: 'second', displayName: 'account2' }, + ]) + await screen.findByLabelText('チャット履歴') + fireEvent.change(screen.getByLabelText('メッセージ'), { + target: { value: '別の下書き' }, + }) + fireEvent.change(screen.getByLabelText('チャット履歴'), { + target: { value: saved.id }, + }) + expect(await screen.findByText('保存した会話')).toBeVisible() + fireEvent.click(screen.getByRole('link', { name: '前回の引用' })) + expect(view.onNavigateCitation).toHaveBeenCalledWith( + 'https://x.com/author/status/123', + saved.citations, + ) + expect(actions.select).toHaveBeenCalledWith({ + data: { id: saved.id, currentId: null }, + }) + await waitFor(() => + expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true), + ) + expect(screen.getByLabelText('メッセージ')).toHaveValue('') + expect(screen.getByText('中断しました・続きから再開できます')).toBeVisible() + fireEvent.click(screen.getByText('接続アカウント · 1件')) + expect(screen.getByLabelText('調査用アカウント · Twitter')).not.toBeChecked() + expect(screen.getByLabelText('account2 · Twitter')).toBeChecked() + fireEvent.click(screen.getByLabelText('調査用アカウント · Twitter')) + act(() => + MockEventSource.instances[0]?.onmessage?.({ + data: JSON.stringify({ + configured: true, + run: { ...saved, message: '更新' }, + history, + }), + }), + ) + await waitFor(() => + expect(view.client.getQueryData(['research-status'])).toMatchObject({ + run: { message: '更新' }, + }), + ) + expect(screen.getByLabelText('調査用アカウント · Twitter')).toBeChecked() + expect(view.onOpenDeck).toHaveBeenCalledOnce() + fireEvent.change(screen.getByLabelText('メッセージ'), { + target: { value: '続きを調べて' }, + }) + fireEvent.click(screen.getByRole('button', { name: '送信' })) + await waitFor(() => + expect(actions.start).toHaveBeenCalledWith({ + data: expect.objectContaining({ + runId: saved.id, + connectionIds: ['second', 'first'], + }), + }), + ) + expect(view.client.getQueryData(['research-status'])).toMatchObject({ + history, + }) +}) + +it('restores a context-only chat on reload and prevents switching during a running turn', async () => { + const contextDeck = { id: 'context', title: '前回の手動デッキ', columns: [] } + current = { + configured: true, + run: { ...running, deck: undefined, contextDeck }, + history: [ + { + id: running.id, + title: running.topic, + status: running.status, + updatedAt: 1000, + }, + ], + } + const view = show() + await waitFor(() => + expect(view.onOpenDeck).toHaveBeenCalledWith(contextDeck, true), + ) + expect(screen.getByLabelText('チャット履歴')).toBeDisabled() +}) + +it('keeps newer streamed history when a chat selection response arrives late', async () => { + const saved = { ...running, id: 'saved', status: 'complete' as const } + const history: ResearchHistoryEntry[] = [ + { + id: saved.id, + title: '保存した調査', + status: saved.status, + updatedAt: 1000, + }, + ] + current = { configured: true, run: null, history } + let finish!: (snapshot: typeof current) => void + actions.select.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const view = show() + fireEvent.change(await screen.findByLabelText('チャット履歴'), { + target: { value: saved.id }, + }) + const newer = { configured: true, run: { ...running, id: 'remote' }, history } + act(() => + MockEventSource.instances[0]?.onmessage?.({ data: JSON.stringify(newer) }), + ) + await waitFor(() => + expect(view.client.getQueryData(['research-status'])).toEqual(newer), + ) + await act(async () => finish({ configured: true, run: saved, history })) + expect(view.client.getQueryData(['research-status'])).toEqual(newer) +}) diff --git a/src/features/research/research-panel.tsx b/src/features/research/research-panel.tsx index 5bb05ef..f7a9fcc 100644 --- a/src/features/research/research-panel.tsx +++ b/src/features/research/research-panel.tsx @@ -7,12 +7,14 @@ import { isResearchActive, type ResearchCitation, type ResearchRun, + type ResearchSnapshot, } from './model' import { ResearchMarkdown } from './research-markdown' import { cancelResearch, loadResearchStatus, resetResearch, + selectResearch, startResearch, } from './server-functions' import './research-panel.css' @@ -23,6 +25,7 @@ const statusNames: Record = { complete: '完了', failed: '失敗', cancelled: '中止しました', + interrupted: '中断しました・続きから再開できます', } type ResearchInput = { topic: string; connectionIds: string[] } @@ -43,6 +46,7 @@ export function ResearchPanel({ const start = useServerFn(startResearch) const cancel = useServerFn(cancelResearch) const reset = useServerFn(resetResearch) + const select = useServerFn(selectResearch) const client = useQueryClient() const status = useQuery({ queryKey: ['research-status'], @@ -55,6 +59,7 @@ export function ResearchPanel({ const [reconnecting, setReconnecting] = useState(false) const [draftResetVersion, setDraftResetVersion] = useState(0) const previousRunId = useRef(null) + const syncedRunId = useRef(null) const attemptedVersion = useRef(null) const pendingTurn = useRef<{ runId?: string @@ -73,7 +78,7 @@ export function ResearchPanel({ const request = useRef<{ signature: string; id: string } | null>(null) useEffect(() => { - if (!run && previousRunId.current) { + if (previousRunId.current && previousRunId.current !== run?.id) { setDraftResetVersion((value) => value + 1) request.current = null pendingTurn.current = null @@ -87,10 +92,7 @@ export function ResearchPanel({ events.onerror = () => setReconnecting(true) events.onmessage = (event) => { try { - const next = JSON.parse(event.data) as { - configured: boolean - run: ResearchRun | null - } + const next = JSON.parse(event.data) as ResearchSnapshot void client .cancelQueries({ queryKey: ['research-status'] }) .then(() => { @@ -105,17 +107,27 @@ export function ResearchPanel({ }, [client]) useEffect(() => { - if (!ready || !run?.deck) return + if (!run) { + syncedRunId.current = null + attemptedVersion.current = null + return + } + if (!ready) return + const switched = syncedRunId.current !== run.id + const deck = run.deck ?? (switched ? run.contextDeck : undefined) + if (!deck) return + syncedRunId.current = run.id const version = `${run.id}:${run.deckVersion}` if (attemptedVersion.current === version) return attemptedVersion.current = version - const deck = run.deck const turn = pendingTurn.current const activate = - !!turn && - run.deckVersion > turn.version && - (turn.runId ? run.id === turn.runId : run.id !== turn.previousRunId) - if (activate) pendingTurn.current = null + switched || + (!!turn && + run.deckVersion > turn.version && + (turn.runId ? run.id === turn.runId : run.id !== turn.previousRunId)) + if (activate && run.deck && turn && run.deckVersion > turn.version) + pendingTurn.current = null void syncDeck(deck, activate).catch(() => { setError( '調査デッキを同期できませんでした。「一時デッキで開く」から再試行できます。', @@ -145,10 +157,10 @@ export function ResearchPanel({ data: { ...payload, requestId: request.current.id }, }) request.current = null - client.setQueryData<{ configured: boolean; run: ResearchRun | null }>( - ['research-status'], - (existing) => - existing !== beforeStart ? existing : { configured: true, run: next }, + client.setQueryData(['research-status'], (existing) => + existing !== beforeStart + ? existing + : { configured: true, run: next, history: existing?.history ?? [] }, ) return true } catch { @@ -174,6 +186,7 @@ export function ResearchPanel({ ? { configured: status.data?.configured ?? true, run: next, + history: status.data?.history ?? [], } : existing, ) @@ -206,6 +219,25 @@ export function ResearchPanel({ } } + async function selectChat(id: string) { + if (!id || id === run?.id || isResearchActive(run)) return + const beforeSelect = client.getQueryData(['research-status']) + setBusy(true) + setError(undefined) + try { + const next = await select({ data: { id, currentId: run?.id ?? null } }) + client.setQueryData(['research-status'], (existing: unknown) => + existing === beforeSelect ? next : existing, + ) + } catch { + setError( + 'チャットを開けませんでした。入力内容は残っています。もう一度お試しください。', + ) + } finally { + setBusy(false) + } + } + async function openDeck(deck: Deck) { setBusy(true) setError(undefined) @@ -236,7 +268,25 @@ export function ResearchPanel({ > 新しいチャット -

会話に合わせて右のデッキが変わります。

+ {(status.data?.history?.length ?? 0) > 0 && ( + + )}
{reconnecting && (

@@ -262,6 +312,7 @@ export function ResearchPanel({

自宅のCodexへの接続が設定されていません。

) : null} )} { if (resetVersion > 0) setTopic('') }, [resetVersion]) - const [selected, setSelected] = useState(null) + const [selected, setSelected] = useState( + initialConnectionIds ?? null, + ) const selectedIds = selected ?? available.slice(0, 6).map((connection) => connection.id) const id = useId() @@ -458,6 +515,7 @@ function ResearchRunStatus({ onCancel: () => void onOpenDeck: (deck: Deck) => void }) { + const deck = run.deck ?? run.contextDeck return (

{statusNames[run.status]}

@@ -479,11 +537,11 @@ function ResearchRunStatus({ 調査を中止 )} - {run.deck && ( + {deck && (