From d3317f68ffaec8f850c788105faf7968589672bd Mon Sep 17 00:00:00 2001 From: Louis Knight-Webb Date: Tue, 2 Dec 2025 14:52:27 +0000 Subject: [PATCH] WYSIWYG editor (#1397) * Replace follow up section with WYSIWYG (vibe-kanban 55b58b24) frontend/src/components/tasks/TaskFollowUpSection.tsx frontend/src/components/ui/wysiwyg.tsx * Delete all usage of image chip component (vibe-kanban 5c90eac1) frontend/src/components/ui/wysiwyg/image-chip-markdown.ts frontend/src/components/ui/wysiwyg/image-chip-node.tsx * Trigger file / tag picker from WYSIWYG (vibe-kanban 3e73cf53) LexicalTypeaheadMenuPlugin frontend/src/components/ui/wysiwyg.tsx frontend/src/components/ui/file-search-textarea.tsx (old) * Editor state should be saved as JSON (vibe-kanban 4f9eec74) Instead of saving markdown, we should save JSON eg `editorState.toJSON();`. This will enable us to properly serialize custom Elements in the future. frontend/src/components/ui/wysiwyg.tsx frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx * In WYSIWYG, the search dialog can exceed screen (vibe-kanban 25337029) When searching for tags/files. Sometimes the dialog is cut off the bottom of the screen. frontend/src/components/ui/wysiwyg.tsx * Use WYSIWYG for tasks (vibe-kanban 5485d481) Currently used for follow ups, we should also use for task frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx frontend/src/components/dialogs/tasks/TaskFormDialog.tsx frontend/src/components/ui/wysiwyg.tsx * Keyboard shortcuts when typing in WYSIWYG (vibe-kanban 04bd70bc) We used to have a callback for: - CMD+Enter - Shift+CMD+Enter In create task dialog: - CMD+Enter = create and start - Shift+CMD+Enter = create without start In follow up: - CMD+Enter = Follow up - Shift+CMD+Enter = nothing frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx frontend/src/components/ui/wysiwyg.tsx frontend/src/components/dialogs/tasks/TaskFormDialog.tsx Ideally we can use the relevant Lexical plugin and callbacks, cleaning up the old `@/keyboard` hooks which no longer work. * Trigger file / tag picker from WYSIWYG (vibe-kanban 3e73cf53) LexicalTypeaheadMenuPlugin frontend/src/components/ui/wysiwyg.tsx frontend/src/components/ui/file-search-textarea.tsx (old) * Use WYSIWYG for tasks (vibe-kanban 5485d481) Currently used for follow ups, we should also use for task frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx frontend/src/components/dialogs/tasks/TaskFormDialog.tsx frontend/src/components/ui/wysiwyg.tsx * Introduce new user-message table and struct (vibe-kanban 09116513) { ID, message_json: Value, message_md: String } We'll also need some endpoints to CRUD them. crates/db crates/server * Stream individual scratch (vibe-kanban 321b50a1) crates/server/src/routes/scratch.rs It should be possible to listen for updates made to a single scratch * Refactor useScratch (vibe-kanban 51ea2317) To consolidate the API stuff into frontend/src/lib/api.ts * Update scratch API (vibe-kanban 878f40c5) Primary key should come from: ID and scratch type combination The frontend will provide both. Scratch IDs should not be generated on the backend. * Remove all usage of hook from follow up (vibe-kanban 2d691095) Use of hooks that reside in frontend/src/hooks/follow-up/* should be removed, except for frontend/src/hooks/follow-up/useFollowUpSend.ts From: frontend/src/components/tasks/TaskFollowUpSection.tsx * Task follow up should use scratch (vibe-kanban d37d3b18) The current task attempt ID should be used to save the content of the follow up box as scratch. frontend/src/components/tasks/TaskFollowUpSection.tsx * Use just markdown serialization for scratch (vibe-kanban 42f5507f) frontend/src/hooks/useScratch.ts crates/server/src/routes/scratch.rs crates/db/src/models/scratch.rs We are currently storing JSON + MD, however we should now store just MD and import/export the markdown into lexical. * Consolidate MarkdownRenderer and WYSIWYG (vibe-kanban f61a7d40) Currently we have an old implementation of markdown rendering in frontend/src/components/ui/markdown-renderer.tsx But we have recently introduced the new WYSIWYG editor frontend/src/components/ui/wysiwyg.tsx wysiwyg takes JSON as input, not raw markdown. Ideally we could just use a single component and have a read only mode, removing Markdown Renderer and its dependencies and custom styling. * WYSIWYG images (vibe-kanban 8cc3c0e7) Create a Lexical plugin for images, with markdown import/export support. Visually, images should be displayed as a small thumbnail with the path truncated. Export/import should support standard markdown image format. * Get image metadata endpoint (vibe-kanban 2c0dfbff) Task attempt endpoint to get info, given the relative URL of an image. We will also need an image that acts as a proxy to the file. Info to return: - Whether file exists - Size of image - Format - File name - Path - URL to get image (the proxy URL) The images are stored in the `.vibe-images` folder, relative to the task attempt container. crates/server/src/routes/task_attempts.rs * Inject relative path not absolute to image (vibe-kanban 007d589b) Currently when we upload an image, it adds markdown with the full relative path of the image, eg: /var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/2702-testing-images/.vibe-images/b01e6b02-dbd0-464c-aa9f-a42a89f6d67b.png However, we should change this to be the path relative to the worktree eg .vibe-images/b01e6b02-dbd0-464c-aa9f-a42a89f6d67b.png * Improve image in WYSIWYG (vibe-kanban 53de9071) frontend/src/components/ui/wysiwyg/nodes/image-node.tsx Check if the image comes from `./vibe-images/...`, if so: Use the API endpoints to get and display metadata. Use the image proxy to display the thumbnail image. Do not render non `.vibe-images` images, instead just show the path and show a question icon as a thumbnail. * rebase fixes * Add Lexical toolbar (vibe-kanban b8904ad9) frontend/src/components/ui/wysiwyg.tsx * Clicking image once should open dialog (vibe-kanban aab2e6f4) frontend/src/components/ui/wysiwyg/nodes/image-node.tsx * Style quotes better (vibe-kanban 54718e76) frontend/src/components/ui/wysiwyg.tsx * Auto detect multi-line code blocks (vibe-kanban ce33792d) Currently when I type triple backticks it doesn't create a multi-line code block frontend/src/components/ui/wysiwyg.tsx * Update how image upload works on the backend (vibe-kanban 62d97322) I am only referring to the image upload for sending a follow up message. Currently we: - upload an image - when a follow up is made, send file IDs - copy the image into container based on those file IDs We should tweak this so that: - upload an image - immediately the image is copied into container - the image file location is added to the markdown of the follow up message (on the frontend) - when user makes follow up, the image is already in the container crates/server/src/routes/images.rs crates/server/src/routes/task_attempts/images.rs * Use @lexical/code to render code (vibe-kanban 60605a2c) frontend/src/components/ui/wysiwyg.tsx * Save variant in scratch (vibe-kanban 06e1e255) frontend/src/components/tasks/TaskFollowUpSection.tsx * prepare db * Solve follow up loading when empty (vibe-kanban 1991bf3d) frontend/src/components/tasks/TaskFollowUpSection.tsx Currently the loader shows when the scratch data is loading, but also when there is no scratch data - which means the user can never see the follow up inputs * descriptive scratch error * Triple backtick WYSIWYG not working properly (vibe-kanban 30b0114e) When I paste in a multi-line code block, eg ```js var x = 100; ``` It doesn't add a multi-line code block properly, instead it created two multi-line code blocks above and below the code. frontend/src/components/ui/wysiwyg.tsx * Safe scratch fail (vibe-kanban c3f99b37) It's possible to get an error like: scratch WS closed: Failed to get scratch item: invalid type: string "\\`\\`\\`js\n\nvar x = 100;\n\n\\`\\`\\` \n\n\n", expected struct DraftFollowUpData at line 1 column 49 In this situation the websocket should act in the same way when no scratch exists yet. * Remove drafts (vibe-kanban 0af2e9aa) crates/services/src/services/drafts.rs crates/db/src/models/draft.rs * Cleanup scratch (vibe-kanban 0baf9b69) Remove: - frontend/src/pages/TestScratch.tsx - frontend/src/components/ScratchEditor.tsx * Improve styling of WYSIWYG + attachment (vibe-kanban 042a18da) frontend/src/components/ui/wysiwyg.tsx The placeholder can overlap the attachment icon * Introduce queued message service (vibe-kanban 442164ae) - New service (crates/services/src/services/...) that holds an in memory store - When the final executor_action finishes, if another follow up prompt (scratch ID) is queued then we can automatically begin executing it (crates/local-deployment/src/container.rs after finalize) - New endpoint required to modify the queue for a task attempt. - Scratch should be wiped after the execution process is created - Scratch can't be edited while queued - Add button to TaskFollowUpSection to make current scratch queued, or cancel queued item * prepare db * Follow up box does not reset after sending message (vibe-kanban c032bc21) - Type follow up - Press send - Expect follow up to be reset, but it is not frontend/src/components/tasks/TaskFollowUpSection.tsx * bg * Fix i18n (vibe-kanban a7ee5604) i18next::translator: missingKey en-GB tasks followUp.queue Queue * Reduce re-renders (vibe-kanban 86ec1b47) frontend/src/components/ui/wysiwyg.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx * Speed up button transitions (vibe-kanban be499249) It takes 0.5-1s for the send button to go from no opacity to full opacity after I start typing frontend/src/components/tasks/TaskFollowUpSection.tsx * add icon to variant selection (vibe-kanban 92fca0e6) frontend/src/components/tasks/TaskFollowUpSection.tsx Dropdown should have settings-2 * Queued message functionality (vibe-kanban 21c7a725) Say I have two messages to send: - I send first - I queue the second - I now see "message queued" and the follow up editable text contains the second - First finishes, second starts, no tasks are queued - I still see "message queued" box but the follow up editable text gets wiped frontend/src/components/tasks/TaskFollowUpSection.tsx * variant width adjust * Move the attach button (vibe-kanban b7f89e6e) Attach button should be to the left of of the send button frontend/src/components/ui/wysiwyg.tsx frontend/src/components/tasks/TaskFollowUpSection.tsx * Cleanup WYSIWYG (vibe-kanban 62997d6c) Props, and upstream logic: - make placeholder optional: - remove defaultValue: this seems redundant as value is always controlled, there may also be related cleanups for uncontrolled mode - remove onFocusChange: toggling states is unnecessary here - remove enableCopyButton: this is always enabled when the editor is disabled frontend/src/components/ui/wysiwyg.tsx * cleanup scratch types * further scratch cleanup * Tweak queue (vibe-kanban 642aa7be) If a task is stopped or fails, the next queued task runs, however this is not the desired behaviour. Instead the queued task should be removed from the queue * Can't see attach button and queue at the same time (vibe-kanban 75ca5428) frontend/src/components/tasks/TaskFollowUpSection.tsx * move follow up hooks * WYSIWYG code blocks should scroll horizontally (vibe-kanban 6c5dbc99) frontend/src/components/ui/wysiwyg.tsx * Refactor useDefaultVariant (vibe-kanban 10ec12ec) I think we could change this so that it accepts a default variant and then returns what variant is currently selected, based on the user's preferences and if they select one from the dropdown * Can't retry a task (vibe-kanban dfde6ad8) It seems to retry functionality was removed fromfrontend/src/components/NormalizedConversation/UserMessage.tsx * If execution startup is slow, scratch is not reset (vibe-kanban 6e721b8e) frontend/src/components/tasks/TaskFollowUpSection.tsx If you write out a follow up and then hit send, if you then navigate away from the page quickly the scratch will still be present when you visit the page, when the expected behaviour is that the previous text would be cleared * Code highlighting for inline code block (vibe-kanban 956f1d5c) Currently works for multi-line, can we get it working for multi-line frontend/src/components/ui/wysiwyg.tsx * Delete FileSearchTextArea (vibe-kanban 01107879) Replace with frontend/src/components/ui/wysiwyg.tsx not frontend/src/components/ui/file-search-textarea.tsx * Tweak styles in task dialog (vibe-kanban 8dfe95a9) frontend/src/components/dialogs/tasks/TaskFormDialog.tsx - Placeholder for WYSIWYG too small, just use default - Make title same size as WYSIWYG H1 * Refactor retry to use variant hook (vibe-kanban 69c969c9) frontend/src/hooks/useVariant.ts frontend/src/components/NormalizedConversation/RetryEditorInline.tsx frontend/src/contexts/RetryUiContext.tsx Removing all existing logic related to variant picking * Refactor approval message styles (vibe-kanban b9a905e1) Refactor the WYSIWYG implementation in thefrontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx so the styles align with usage infrontend/src/components/tasks/TaskFollowUpSection.tsx * Fix follow up box font (vibe-kanban 4fa9cd39) When I start typing, it's a really small font for some reason frontend/src/components/tasks/TaskFollowUpSection.tsx * Remove double border for plan approval (vibe-kanban 3f12c591) frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx - Also multi-line code block colour is broken when looking at plans (but not single line strangely...) * Retry Editor shouldn't call API directly (vibe-kanban 3df9cde5) Should use hooks frontend/src/components/NormalizedConversation/RetryEditorInline.tsx * Image metadata for task creation (vibe-kanban 8dd18a28) We have an endpoint for image metadata in task attempt, but not for task crates/server/src/routes/images.rs This means we can't currently render the image (and metadata) in the WYSIWYG editorfrontend/src/components/dialogs/tasks/TaskFormDialog.tsx * Add file upload to retry (vibe-kanban 8dffeed2) frontend/src/components/NormalizedConversation/RetryEditorInline.tsx Similar to: frontend/src/components/tasks/TaskFollowUpSection.tsx Infact we should reuse the same component as much as possible * Remove the client side scratch deletion (vibe-kanban c6b0a613) frontend/src/components/tasks/TaskFollowUpSection.tsx This happens now on backend. Also on backend when queued task is triggered we should also wipe the scratch. * Queued task style (vibe-kanban 0c9bc110) frontend/src/components/tasks/TaskFollowUpSection.tsx When a message is queued it repeats the message under "will execute when current run finishes", however the message is visible anyway in the message box so we can remove that * WYSIWYG base font size decrease * Queueing a message change (vibe-kanban 30ee2d4d) Currently when we queue a message I can see in the logs: Failed to save follow-up draft ApiError: Cannot edit scratch while a message is queued I think this is because the following is happening: - User types - Clicks queue - Debounce tries to save message - Can't save message because of queue --- ...65b85ec3d0eb90aa38f1f891e7ec9308278e9.json | 86 -- ...e9722d8b7ce62f76a1eb084af880c2a4dfad2.json | 44 + ...a3d3c4289754381b47af7cd97acce767163e8.json | 12 + ...09407e24b1d507800cec45828c6b9eef75b86.json | 86 -- ...5b1885402b5f936df49016e572104a5547355.json | 20 + ...c040a86eab49095c89fcef9d1585890056856.json | 44 + ...1b2135383396f82623813d734e321114d4623.json | 44 + ...97ba98c01970a68866eb7028b791f04da2b39.json | 44 + ...688efdae03ddb4249d0636b69934f5cd4d8fd.json | 44 + ...b88b05fcffa19785a364727dc54fff8741bf4.json | 86 -- ...3a9b832e5ba96da7f68374b05d09ec93087e1.json | 62 ++ .../20251120000001_refactor_to_scratch.sql | 10 + .../20251129155145_drop_drafts_table.sql | 2 + crates/db/src/models/draft.rs | 368 ------- crates/db/src/models/image.rs | 38 + crates/db/src/models/mod.rs | 2 +- crates/db/src/models/scratch.rs | 275 +++++ crates/deployment/src/lib.rs | 4 +- crates/local-deployment/src/container.rs | 263 ++--- crates/local-deployment/src/lib.rs | 13 +- crates/server/src/bin/generate_types.rs | 15 +- crates/server/src/error.rs | 31 +- crates/server/src/routes/drafts.rs | 67 -- crates/server/src/routes/images.rs | 86 +- crates/server/src/routes/mod.rs | 4 +- crates/server/src/routes/scratch.rs | 161 +++ crates/server/src/routes/task_attempts.rs | 82 +- .../server/src/routes/task_attempts/drafts.rs | 147 --- .../server/src/routes/task_attempts/images.rs | 240 +++++ .../server/src/routes/task_attempts/queue.rs | 95 ++ .../server/src/routes/task_attempts/util.rs | 31 +- crates/services/src/services/container.rs | 12 +- crates/services/src/services/drafts.rs | 482 --------- crates/services/src/services/events.rs | 96 +- .../services/src/services/events/patches.rs | 117 +-- .../services/src/services/events/streams.rs | 131 +-- crates/services/src/services/events/types.rs | 22 +- crates/services/src/services/image.rs | 18 - crates/services/src/services/mod.rs | 2 +- .../services/src/services/queued_message.rs | 92 ++ frontend/package.json | 1 - .../DisplayConversationEntry.tsx | 66 +- .../PendingApprovalEntry.tsx | 26 +- .../RetryEditorInline.tsx | 423 +++----- .../NormalizedConversation/UserMessage.tsx | 91 +- .../dialogs/tasks/RestoreLogsDialog.tsx | 645 ++++++------ .../dialogs/tasks/TaskFormDialog.tsx | 101 +- .../dialogs/wysiwyg/ImagePreviewDialog.tsx | 79 ++ .../src/components/diff/CommentWidgetLine.tsx | 15 +- .../components/diff/ReviewCommentRenderer.tsx | 50 +- .../src/components/panels/SharedTaskPanel.tsx | 4 +- .../components/panels/TaskAttemptPanel.tsx | 6 +- frontend/src/components/panels/TaskPanel.tsx | 6 +- .../components/tasks/TaskFollowUpSection.tsx | 947 ++++++++++-------- .../src/components/tasks/VariantSelector.tsx | 5 +- .../tasks/follow-up/FollowUpEditorCard.tsx | 64 -- .../components/ui/file-search-textarea.tsx | 544 ---------- .../components/ui/image-upload-section.tsx | 10 +- .../src/components/ui/markdown-renderer.tsx | 281 ------ .../ui/title-description-editor.tsx | 3 + frontend/src/components/ui/wysiwyg.tsx | 420 +++++--- .../wysiwyg/context/task-attempt-context.tsx | 28 + .../ui/wysiwyg/image-chip-markdown.ts | 35 - .../components/ui/wysiwyg/image-chip-node.tsx | 159 --- .../ui/wysiwyg/lib/code-highlight-theme.ts | 36 + .../ui/wysiwyg/nodes/image-node.tsx | 292 ++++++ .../ui/wysiwyg/nodes/inline-code-node.tsx | 154 +++ .../plugins/code-block-shortcut-plugin.tsx | 91 ++ .../wysiwyg/plugins/code-highlight-plugin.tsx | 13 + .../plugins/file-tag-typeahead-plugin.tsx | 235 +++++ .../wysiwyg/plugins/image-keyboard-plugin.tsx | 51 + .../plugins/keyboard-commands-plugin.tsx | 67 ++ .../wysiwyg/plugins/markdown-sync-plugin.tsx | 73 ++ .../wysiwyg/plugins/read-only-link-plugin.tsx | 125 +++ .../ui/wysiwyg/plugins/toolbar-plugin.tsx | 250 +++++ .../transformers/code-block-transformer.ts | 61 ++ .../wysiwyg/transformers/image-transformer.ts | 21 + .../transformers/inline-code-transformer.ts | 27 + .../src/contexts/ClickedElementsProvider.tsx | 5 +- frontend/src/contexts/RetryUiContext.tsx | 39 +- frontend/src/contexts/ReviewProvider.tsx | 5 +- .../src/hooks/follow-up/useDefaultVariant.ts | 62 -- .../src/hooks/follow-up/useDraftAutosave.ts | 263 ----- .../src/hooks/follow-up/useDraftEditor.ts | 91 -- frontend/src/hooks/follow-up/useDraftQueue.ts | 69 -- .../src/hooks/follow-up/useDraftStream.ts | 109 -- frontend/src/hooks/index.ts | 2 + frontend/src/hooks/useDebouncedCallback.ts | 48 + .../hooks/{follow-up => }/useFollowUpSend.ts | 24 +- frontend/src/hooks/useImageMetadata.ts | 69 ++ frontend/src/hooks/useProcessRetry.ts | 106 -- frontend/src/hooks/useQueueStatus.ts | 86 ++ frontend/src/hooks/useRetryProcess.ts | 76 ++ frontend/src/hooks/useScratch.ts | 62 ++ frontend/src/hooks/useVariant.ts | 42 + frontend/src/i18n/locales/en/tasks.json | 5 +- frontend/src/i18n/locales/es/tasks.json | 5 +- frontend/src/i18n/locales/ja/tasks.json | 5 +- frontend/src/i18n/locales/ko/tasks.json | 5 +- frontend/src/keyboard/hooks.ts | 8 - frontend/src/keyboard/registry.ts | 8 - frontend/src/lib/api.ts | 195 ++-- frontend/src/lib/searchTagsAndFiles.ts | 40 + frontend/src/lib/utils.ts | 8 + frontend/src/pages/ProjectTasks.tsx | 2 +- frontend/src/styles/diff-style-overrides.css | 52 +- frontend/src/styles/index.css | 59 +- pnpm-lock.yaml | 13 - shared/types.ts | 46 +- 109 files changed, 5363 insertions(+), 5229 deletions(-) delete mode 100644 crates/db/.sqlx/query-457ee97807c0e4cc329e1c3b8b765b85ec3d0eb90aa38f1f891e7ec9308278e9.json create mode 100644 crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json create mode 100644 crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json delete mode 100644 crates/db/.sqlx/query-971d979ba0156b060d173c37db009407e24b1d507800cec45828c6b9eef75b86.json create mode 100644 crates/db/.sqlx/query-99f759d5f5dfdf7770de7fb31915b1885402b5f936df49016e572104a5547355.json create mode 100644 crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json create mode 100644 crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json create mode 100644 crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json create mode 100644 crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json delete mode 100644 crates/db/.sqlx/query-eb8c35173d48f942dd8c93ce0d5b88b05fcffa19785a364727dc54fff8741bf4.json create mode 100644 crates/db/.sqlx/query-f4dfe229b47380175daba08daa93a9b832e5ba96da7f68374b05d09ec93087e1.json create mode 100644 crates/db/migrations/20251120000001_refactor_to_scratch.sql create mode 100644 crates/db/migrations/20251129155145_drop_drafts_table.sql delete mode 100644 crates/db/src/models/draft.rs create mode 100644 crates/db/src/models/scratch.rs delete mode 100644 crates/server/src/routes/drafts.rs create mode 100644 crates/server/src/routes/scratch.rs delete mode 100644 crates/server/src/routes/task_attempts/drafts.rs create mode 100644 crates/server/src/routes/task_attempts/images.rs create mode 100644 crates/server/src/routes/task_attempts/queue.rs delete mode 100644 crates/services/src/services/drafts.rs create mode 100644 crates/services/src/services/queued_message.rs create mode 100644 frontend/src/components/dialogs/wysiwyg/ImagePreviewDialog.tsx delete mode 100644 frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx delete mode 100644 frontend/src/components/ui/file-search-textarea.tsx delete mode 100644 frontend/src/components/ui/markdown-renderer.tsx create mode 100644 frontend/src/components/ui/wysiwyg/context/task-attempt-context.tsx delete mode 100644 frontend/src/components/ui/wysiwyg/image-chip-markdown.ts delete mode 100644 frontend/src/components/ui/wysiwyg/image-chip-node.tsx create mode 100644 frontend/src/components/ui/wysiwyg/lib/code-highlight-theme.ts create mode 100644 frontend/src/components/ui/wysiwyg/nodes/image-node.tsx create mode 100644 frontend/src/components/ui/wysiwyg/nodes/inline-code-node.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/code-highlight-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/image-keyboard-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/keyboard-commands-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/markdown-sync-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/read-only-link-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/plugins/toolbar-plugin.tsx create mode 100644 frontend/src/components/ui/wysiwyg/transformers/code-block-transformer.ts create mode 100644 frontend/src/components/ui/wysiwyg/transformers/image-transformer.ts create mode 100644 frontend/src/components/ui/wysiwyg/transformers/inline-code-transformer.ts delete mode 100644 frontend/src/hooks/follow-up/useDefaultVariant.ts delete mode 100644 frontend/src/hooks/follow-up/useDraftAutosave.ts delete mode 100644 frontend/src/hooks/follow-up/useDraftEditor.ts delete mode 100644 frontend/src/hooks/follow-up/useDraftQueue.ts delete mode 100644 frontend/src/hooks/follow-up/useDraftStream.ts create mode 100644 frontend/src/hooks/useDebouncedCallback.ts rename frontend/src/hooks/{follow-up => }/useFollowUpSend.ts (77%) create mode 100644 frontend/src/hooks/useImageMetadata.ts delete mode 100644 frontend/src/hooks/useProcessRetry.ts create mode 100644 frontend/src/hooks/useQueueStatus.ts create mode 100644 frontend/src/hooks/useRetryProcess.ts create mode 100644 frontend/src/hooks/useScratch.ts create mode 100644 frontend/src/hooks/useVariant.ts create mode 100644 frontend/src/lib/searchTagsAndFiles.ts diff --git a/crates/db/.sqlx/query-457ee97807c0e4cc329e1c3b8b765b85ec3d0eb90aa38f1f891e7ec9308278e9.json b/crates/db/.sqlx/query-457ee97807c0e4cc329e1c3b8b765b85ec3d0eb90aa38f1f891e7ec9308278e9.json deleted file mode 100644 index 3c9656cd..00000000 --- a/crates/db/.sqlx/query-457ee97807c0e4cc329e1c3b8b765b85ec3d0eb90aa38f1f891e7ec9308278e9.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO drafts (id, task_attempt_id, draft_type, retry_process_id, prompt, queued, variant, image_ids)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT(task_attempt_id, draft_type) DO UPDATE SET\n retry_process_id = excluded.retry_process_id,\n prompt = excluded.prompt,\n queued = excluded.queued,\n variant = excluded.variant,\n image_ids = excluded.image_ids,\n version = drafts.version + 1\n RETURNING\n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n draft_type,\n retry_process_id as \"retry_process_id?: Uuid\",\n prompt,\n queued as \"queued!: bool\",\n sending as \"sending!: bool\",\n variant,\n image_ids,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\",\n version as \"version!: i64\"", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "task_attempt_id!: Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "draft_type", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "retry_process_id?: Uuid", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "prompt", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "queued!: bool", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "sending!: bool", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "variant", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "image_ids", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 9, - "type_info": "Datetime" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 10, - "type_info": "Datetime" - }, - { - "name": "version!: i64", - "ordinal": 11, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 8 - }, - "nullable": [ - true, - false, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false - ] - }, - "hash": "457ee97807c0e4cc329e1c3b8b765b85ec3d0eb90aa38f1f891e7ec9308278e9" -} diff --git a/crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json b/crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json new file mode 100644 index 00000000..38ff1df8 --- /dev/null +++ b/crates/db/.sqlx/query-5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO scratch (id, scratch_type, payload)\n VALUES ($1, $2, $3)\n RETURNING\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "scratch_type", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "5a5eb8f05ddf515b4e568d8e209e9722d8b7ce62f76a1eb084af880c2a4dfad2" +} diff --git a/crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json b/crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json new file mode 100644 index 00000000..908545c2 --- /dev/null +++ b/crates/db/.sqlx/query-8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM scratch WHERE id = $1 AND scratch_type = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8f3ab3ad20de3261703b0bdaf01a3d3c4289754381b47af7cd97acce767163e8" +} diff --git a/crates/db/.sqlx/query-971d979ba0156b060d173c37db009407e24b1d507800cec45828c6b9eef75b86.json b/crates/db/.sqlx/query-971d979ba0156b060d173c37db009407e24b1d507800cec45828c6b9eef75b86.json deleted file mode 100644 index f0d86347..00000000 --- a/crates/db/.sqlx/query-971d979ba0156b060d173c37db009407e24b1d507800cec45828c6b9eef75b86.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n draft_type,\n retry_process_id as \"retry_process_id?: Uuid\",\n prompt,\n queued as \"queued!: bool\",\n sending as \"sending!: bool\",\n variant,\n image_ids,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\",\n version as \"version!: i64\"\n FROM drafts\n WHERE task_attempt_id = $1 AND draft_type = $2", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "task_attempt_id!: Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "draft_type", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "retry_process_id?: Uuid", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "prompt", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "queued!: bool", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "sending!: bool", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "variant", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "image_ids", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 9, - "type_info": "Datetime" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 10, - "type_info": "Datetime" - }, - { - "name": "version!: i64", - "ordinal": 11, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - true, - false, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false - ] - }, - "hash": "971d979ba0156b060d173c37db009407e24b1d507800cec45828c6b9eef75b86" -} diff --git a/crates/db/.sqlx/query-99f759d5f5dfdf7770de7fb31915b1885402b5f936df49016e572104a5547355.json b/crates/db/.sqlx/query-99f759d5f5dfdf7770de7fb31915b1885402b5f936df49016e572104a5547355.json new file mode 100644 index 00000000..a9183cd1 --- /dev/null +++ b/crates/db/.sqlx/query-99f759d5f5dfdf7770de7fb31915b1885402b5f936df49016e572104a5547355.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT 1 as \"exists\" FROM task_images WHERE task_id = $1 AND image_id = $2", + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "99f759d5f5dfdf7770de7fb31915b1885402b5f936df49016e572104a5547355" +} \ No newline at end of file diff --git a/crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json b/crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json new file mode 100644 index 00000000..e18f64d7 --- /dev/null +++ b/crates/db/.sqlx/query-a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n INSERT INTO scratch (id, scratch_type, payload)\n VALUES ($1, $2, $3)\n ON CONFLICT(id, scratch_type) DO UPDATE SET\n payload = excluded.payload,\n updated_at = datetime('now', 'subsec')\n RETURNING\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "scratch_type", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "a3d14f90b59d6cb15c42d1e6400c040a86eab49095c89fcef9d1585890056856" +} diff --git a/crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json b/crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json new file mode 100644 index 00000000..dfab52e4 --- /dev/null +++ b/crates/db/.sqlx/query-a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "scratch_type", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 0 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "a9446d873a2f199e7120e9faeda1b2135383396f82623813d734e321114d4623" +} diff --git a/crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json b/crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json new file mode 100644 index 00000000..6b26ab8b --- /dev/null +++ b/crates/db/.sqlx/query-c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n WHERE id = $1 AND scratch_type = $2\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "scratch_type", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c422aa419f267df88b65557ccb897ba98c01970a68866eb7028b791f04da2b39" +} diff --git a/crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json b/crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json new file mode 100644 index 00000000..cc708abc --- /dev/null +++ b/crates/db/.sqlx/query-e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n SELECT\n id as \"id!: Uuid\",\n scratch_type,\n payload,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM scratch\n WHERE rowid = $1\n ", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "scratch_type", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "payload", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "e41bedcff88553343a55112c9c0688efdae03ddb4249d0636b69934f5cd4d8fd" +} diff --git a/crates/db/.sqlx/query-eb8c35173d48f942dd8c93ce0d5b88b05fcffa19785a364727dc54fff8741bf4.json b/crates/db/.sqlx/query-eb8c35173d48f942dd8c93ce0d5b88b05fcffa19785a364727dc54fff8741bf4.json deleted file mode 100644 index eeb07f32..00000000 --- a/crates/db/.sqlx/query-eb8c35173d48f942dd8c93ce0d5b88b05fcffa19785a364727dc54fff8741bf4.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT\n id as \"id!: Uuid\",\n task_attempt_id as \"task_attempt_id!: Uuid\",\n draft_type,\n retry_process_id as \"retry_process_id?: Uuid\",\n prompt,\n queued as \"queued!: bool\",\n sending as \"sending!: bool\",\n variant,\n image_ids,\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\",\n version as \"version!: i64\"\n FROM drafts\n WHERE rowid = $1", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "task_attempt_id!: Uuid", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "draft_type", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "retry_process_id?: Uuid", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "prompt", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "queued!: bool", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "sending!: bool", - "ordinal": 6, - "type_info": "Integer" - }, - { - "name": "variant", - "ordinal": 7, - "type_info": "Text" - }, - { - "name": "image_ids", - "ordinal": 8, - "type_info": "Text" - }, - { - "name": "created_at!: DateTime", - "ordinal": 9, - "type_info": "Datetime" - }, - { - "name": "updated_at!: DateTime", - "ordinal": 10, - "type_info": "Datetime" - }, - { - "name": "version!: i64", - "ordinal": 11, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - true, - false, - false, - true, - false, - false, - false, - true, - true, - false, - false, - false - ] - }, - "hash": "eb8c35173d48f942dd8c93ce0d5b88b05fcffa19785a364727dc54fff8741bf4" -} diff --git a/crates/db/.sqlx/query-f4dfe229b47380175daba08daa93a9b832e5ba96da7f68374b05d09ec93087e1.json b/crates/db/.sqlx/query-f4dfe229b47380175daba08daa93a9b832e5ba96da7f68374b05d09ec93087e1.json new file mode 100644 index 00000000..d9bd8627 --- /dev/null +++ b/crates/db/.sqlx/query-f4dfe229b47380175daba08daa93a9b832e5ba96da7f68374b05d09ec93087e1.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "SELECT id as \"id!: Uuid\",\n file_path as \"file_path!\",\n original_name as \"original_name!\",\n mime_type,\n size_bytes as \"size_bytes!\",\n hash as \"hash!\",\n created_at as \"created_at!: DateTime\",\n updated_at as \"updated_at!: DateTime\"\n FROM images\n WHERE file_path = $1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "file_path!", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "original_name!", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "mime_type", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "size_bytes!", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "hash!", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + true, + true, + false, + false, + false + ] + }, + "hash": "f4dfe229b47380175daba08daa93a9b832e5ba96da7f68374b05d09ec93087e1" +} \ No newline at end of file diff --git a/crates/db/migrations/20251120000001_refactor_to_scratch.sql b/crates/db/migrations/20251120000001_refactor_to_scratch.sql new file mode 100644 index 00000000..e57c3001 --- /dev/null +++ b/crates/db/migrations/20251120000001_refactor_to_scratch.sql @@ -0,0 +1,10 @@ +CREATE TABLE scratch ( + id BLOB NOT NULL, + scratch_type TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + PRIMARY KEY (id, scratch_type) +); + +CREATE INDEX idx_scratch_created_at ON scratch(created_at); diff --git a/crates/db/migrations/20251129155145_drop_drafts_table.sql b/crates/db/migrations/20251129155145_drop_drafts_table.sql new file mode 100644 index 00000000..d8628904 --- /dev/null +++ b/crates/db/migrations/20251129155145_drop_drafts_table.sql @@ -0,0 +1,2 @@ +-- Drop the drafts table (follow-up and retry drafts are no longer used) +DROP TABLE IF EXISTS drafts; diff --git a/crates/db/src/models/draft.rs b/crates/db/src/models/draft.rs deleted file mode 100644 index 9259304f..00000000 --- a/crates/db/src/models/draft.rs +++ /dev/null @@ -1,368 +0,0 @@ -use std::str::FromStr; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool}; -use ts_rs::TS; -use uuid::Uuid; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, TS, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -pub enum DraftType { - FollowUp, - Retry, -} - -impl DraftType { - pub fn as_str(&self) -> &'static str { - match self { - DraftType::FollowUp => "follow_up", - DraftType::Retry => "retry", - } - } -} - -impl FromStr for DraftType { - type Err = (); - - fn from_str(s: &str) -> Result { - match s { - "follow_up" => Ok(DraftType::FollowUp), - "retry" => Ok(DraftType::Retry), - _ => Err(()), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct Draft { - pub id: Uuid, - pub task_attempt_id: Uuid, - pub draft_type: DraftType, - #[serde(skip_serializing_if = "Option::is_none")] - pub retry_process_id: Option, - pub prompt: String, - pub queued: bool, - pub sending: bool, - pub variant: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub image_ids: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, - pub version: i64, -} - -#[derive(Debug, Clone, FromRow)] -struct DraftRow { - pub id: Uuid, - pub task_attempt_id: Uuid, - pub draft_type: String, - pub retry_process_id: Option, - pub prompt: String, - pub queued: bool, - pub sending: bool, - pub variant: Option, - pub image_ids: Option, - pub created_at: DateTime, - pub updated_at: DateTime, - pub version: i64, -} - -impl From for Draft { - fn from(r: DraftRow) -> Self { - let image_ids = r - .image_ids - .as_deref() - .and_then(|s| serde_json::from_str::>(s).ok()); - Draft { - id: r.id, - task_attempt_id: r.task_attempt_id, - draft_type: DraftType::from_str(&r.draft_type).unwrap_or(DraftType::FollowUp), - retry_process_id: r.retry_process_id, - prompt: r.prompt, - queued: r.queued, - sending: r.sending, - variant: r.variant, - image_ids, - created_at: r.created_at, - updated_at: r.updated_at, - version: r.version, - } - } -} - -#[derive(Debug, Deserialize, TS)] -pub struct UpsertDraft { - pub task_attempt_id: Uuid, - pub draft_type: DraftType, - pub retry_process_id: Option, - pub prompt: String, - pub queued: bool, - pub variant: Option, - pub image_ids: Option>, -} - -impl Draft { - pub async fn find_by_rowid(pool: &SqlitePool, rowid: i64) -> Result, sqlx::Error> { - sqlx::query_as!( - DraftRow, - r#"SELECT - id as "id!: Uuid", - task_attempt_id as "task_attempt_id!: Uuid", - draft_type, - retry_process_id as "retry_process_id?: Uuid", - prompt, - queued as "queued!: bool", - sending as "sending!: bool", - variant, - image_ids, - created_at as "created_at!: DateTime", - updated_at as "updated_at!: DateTime", - version as "version!: i64" - FROM drafts - WHERE rowid = $1"#, - rowid - ) - .fetch_optional(pool) - .await - .map(|opt| opt.map(Draft::from)) - } - - pub async fn find_by_task_attempt_and_type( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result, sqlx::Error> { - let draft_type_str = draft_type.as_str(); - sqlx::query_as!( - DraftRow, - r#"SELECT - id as "id!: Uuid", - task_attempt_id as "task_attempt_id!: Uuid", - draft_type, - retry_process_id as "retry_process_id?: Uuid", - prompt, - queued as "queued!: bool", - sending as "sending!: bool", - variant, - image_ids, - created_at as "created_at!: DateTime", - updated_at as "updated_at!: DateTime", - version as "version!: i64" - FROM drafts - WHERE task_attempt_id = $1 AND draft_type = $2"#, - task_attempt_id, - draft_type_str - ) - .fetch_optional(pool) - .await - .map(|opt| opt.map(Draft::from)) - } - - pub async fn upsert(pool: &SqlitePool, data: &UpsertDraft) -> Result { - // Validate retry_process_id requirement - if data.draft_type == DraftType::Retry && data.retry_process_id.is_none() { - return Err(sqlx::Error::Protocol( - "retry_process_id is required for retry drafts".into(), - )); - } - - let id = Uuid::new_v4(); - let image_ids_json = data - .image_ids - .as_ref() - .map(|ids| serde_json::to_string(ids).unwrap_or_else(|_| "[]".to_string())); - let draft_type_str = data.draft_type.as_str(); - let prompt = data.prompt.clone(); - let variant = data.variant.clone(); - sqlx::query_as!( - DraftRow, - r#"INSERT INTO drafts (id, task_attempt_id, draft_type, retry_process_id, prompt, queued, variant, image_ids) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT(task_attempt_id, draft_type) DO UPDATE SET - retry_process_id = excluded.retry_process_id, - prompt = excluded.prompt, - queued = excluded.queued, - variant = excluded.variant, - image_ids = excluded.image_ids, - version = drafts.version + 1 - RETURNING - id as "id!: Uuid", - task_attempt_id as "task_attempt_id!: Uuid", - draft_type, - retry_process_id as "retry_process_id?: Uuid", - prompt, - queued as "queued!: bool", - sending as "sending!: bool", - variant, - image_ids, - created_at as "created_at!: DateTime", - updated_at as "updated_at!: DateTime", - version as "version!: i64""#, - id, - data.task_attempt_id, - draft_type_str, - data.retry_process_id, - prompt, - data.queued, - variant, - image_ids_json - ) - .fetch_one(pool) - .await - .map(Draft::from) - } - - pub async fn clear_after_send( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result<(), sqlx::Error> { - let draft_type_str = draft_type.as_str(); - - match draft_type { - DraftType::FollowUp => { - // Follow-up drafts: update to empty - sqlx::query( - r#"UPDATE drafts - SET prompt = '', queued = 0, sending = 0, image_ids = NULL, updated_at = CURRENT_TIMESTAMP, version = version + 1 - WHERE task_attempt_id = ? AND draft_type = ?"#, - ) - .bind(task_attempt_id) - .bind(draft_type_str) - .execute(pool) - .await?; - } - DraftType::Retry => { - // Retry drafts: delete the record - Self::delete_by_task_attempt_and_type(pool, task_attempt_id, draft_type).await?; - } - } - Ok(()) - } - - pub async fn delete_by_task_attempt_and_type( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result<(), sqlx::Error> { - sqlx::query(r#"DELETE FROM drafts WHERE task_attempt_id = ? AND draft_type = ?"#) - .bind(task_attempt_id) - .bind(draft_type.as_str()) - .execute(pool) - .await?; - - Ok(()) - } - - /// Attempt to atomically mark this draft as "sending" if it's currently queued and non-empty. - /// Returns true if the row was updated (we acquired the send lock), false otherwise. - pub async fn try_mark_sending( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result { - let draft_type_str = draft_type.as_str(); - let result = sqlx::query( - r#"UPDATE drafts - SET sending = 1, updated_at = CURRENT_TIMESTAMP, version = version + 1 - WHERE task_attempt_id = ? - AND draft_type = ? - AND queued = 1 - AND sending = 0 - AND TRIM(prompt) != ''"#, - ) - .bind(task_attempt_id) - .bind(draft_type_str) - .execute(pool) - .await?; - - Ok(result.rows_affected() > 0) - } - - /// Partial update on a draft by attempt and type. Updates only provided fields - /// and bumps `updated_at` and `version` when any change occurs. - pub async fn update_partial( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - prompt: Option, - variant: Option>, - image_ids: Option>, - retry_process_id: Option, - ) -> Result<(), sqlx::Error> { - if retry_process_id.is_none() - && prompt.is_none() - && variant.is_none() - && image_ids.is_none() - { - return Ok(()); - } - let mut query = QueryBuilder::::new("UPDATE drafts SET "); - - let mut separated = query.separated(", "); - if let Some(rpid) = retry_process_id { - separated.push("retry_process_id = "); - separated.push_bind_unseparated(rpid); - } - if let Some(p) = prompt { - separated.push("prompt = "); - separated.push_bind_unseparated(p); - } - if let Some(v_opt) = variant { - separated.push("variant = "); - match v_opt { - Some(v) => separated.push_bind_unseparated(v), - None => separated.push_bind_unseparated(Option::::None), - }; - } - if let Some(ids) = image_ids { - let image_ids_json = serde_json::to_string(&ids).unwrap_or_else(|_| "[]".to_string()); - separated.push("image_ids = "); - separated.push_bind_unseparated(image_ids_json); - } - separated.push("updated_at = CURRENT_TIMESTAMP"); - separated.push("version = version + 1"); - - query.push(" WHERE task_attempt_id = "); - query.push_bind(task_attempt_id); - query.push(" AND draft_type = "); - query.push_bind(draft_type.as_str()); - query.build().execute(pool).await?; - Ok(()) - } - - /// Set queued flag (and bump metadata) for a draft by attempt and type. - pub async fn set_queued( - pool: &SqlitePool, - task_attempt_id: Uuid, - draft_type: DraftType, - queued: bool, - expected_queued: Option, - expected_version: Option, - ) -> Result { - let result = sqlx::query( - r#"UPDATE drafts - SET queued = CASE - WHEN ?1 THEN (TRIM(prompt) <> '') - ELSE 0 - END, - updated_at = CURRENT_TIMESTAMP, - version = version + 1 - WHERE task_attempt_id = ?2 - AND draft_type = ?3 - AND (?4 IS NULL OR queued = ?4) - AND (?5 IS NULL OR version = ?5)"#, - ) - .bind(queued as i64) - .bind(task_attempt_id) - .bind(draft_type.as_str()) - .bind(expected_queued.map(|value| value as i64)) - .bind(expected_version) - .execute(pool) - .await?; - - Ok(result.rows_affected()) - } -} diff --git a/crates/db/src/models/image.rs b/crates/db/src/models/image.rs index a4c31cb7..3d9d3ece 100644 --- a/crates/db/src/models/image.rs +++ b/crates/db/src/models/image.rs @@ -103,6 +103,28 @@ impl Image { .await } + pub async fn find_by_file_path( + pool: &SqlitePool, + file_path: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + Image, + r#"SELECT id as "id!: Uuid", + file_path as "file_path!", + original_name as "original_name!", + mime_type, + size_bytes as "size_bytes!", + hash as "hash!", + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM images + WHERE file_path = $1"#, + file_path + ) + .fetch_optional(pool) + .await + } + pub async fn find_by_task_id( pool: &SqlitePool, task_id: Uuid, @@ -215,4 +237,20 @@ impl TaskImage { .await?; Ok(()) } + + /// Check if an image is associated with a specific task. + pub async fn is_associated( + pool: &SqlitePool, + task_id: Uuid, + image_id: Uuid, + ) -> Result { + let result = sqlx::query_scalar!( + r#"SELECT 1 as "exists" FROM task_images WHERE task_id = $1 AND image_id = $2"#, + task_id, + image_id + ) + .fetch_optional(pool) + .await?; + Ok(result.is_some()) + } } diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 1d5eda49..812cd252 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -1,10 +1,10 @@ -pub mod draft; pub mod execution_process; pub mod execution_process_logs; pub mod executor_session; pub mod image; pub mod merge; pub mod project; +pub mod scratch; pub mod shared_task; pub mod tag; pub mod task; diff --git a/crates/db/src/models/scratch.rs b/crates/db/src/models/scratch.rs new file mode 100644 index 00000000..023c9d21 --- /dev/null +++ b/crates/db/src/models/scratch.rs @@ -0,0 +1,275 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool}; +use strum_macros::{Display, EnumDiscriminants, EnumString}; +use thiserror::Error; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum ScratchError { + #[error(transparent)] + Serde(#[from] serde_json::Error), + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error("Scratch type mismatch: expected '{expected}' but got '{actual}'")] + TypeMismatch { expected: String, actual: String }, +} + +/// Data for a draft follow-up scratch +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct DraftFollowUpData { + pub message: String, + #[serde(default)] + pub variant: Option, +} + +/// The payload of a scratch, tagged by type. The type is part of the composite primary key. +/// Data is stored as markdown string. +#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumDiscriminants)] +#[serde(tag = "type", content = "data", rename_all = "SCREAMING_SNAKE_CASE")] +#[strum_discriminants(name(ScratchType))] +#[strum_discriminants(derive(Display, EnumString, Serialize, Deserialize, TS))] +#[strum_discriminants(ts(use_ts_enum))] +#[strum_discriminants(serde(rename_all = "SCREAMING_SNAKE_CASE"))] +#[strum_discriminants(strum(serialize_all = "SCREAMING_SNAKE_CASE"))] +pub enum ScratchPayload { + DraftTask(String), + DraftFollowUp(DraftFollowUpData), +} + +impl ScratchPayload { + /// Returns the scratch type for this payload + pub fn scratch_type(&self) -> ScratchType { + ScratchType::from(self) + } + + /// Validates that the payload type matches the expected type + pub fn validate_type(&self, expected: ScratchType) -> Result<(), ScratchError> { + let actual = self.scratch_type(); + if actual != expected { + return Err(ScratchError::TypeMismatch { + expected: expected.to_string(), + actual: actual.to_string(), + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, FromRow)] +struct ScratchRow { + pub id: Uuid, + pub scratch_type: String, + pub payload: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct Scratch { + pub id: Uuid, + pub payload: ScratchPayload, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Scratch { + /// Returns the scratch type derived from the payload + pub fn scratch_type(&self) -> ScratchType { + self.payload.scratch_type() + } +} + +impl TryFrom for Scratch { + type Error = ScratchError; + fn try_from(r: ScratchRow) -> Result { + let payload: ScratchPayload = serde_json::from_str(&r.payload)?; + payload.validate_type(r.scratch_type.parse().map_err(|_| { + ScratchError::TypeMismatch { + expected: r.scratch_type.clone(), + actual: payload.scratch_type().to_string(), + } + })?)?; + Ok(Scratch { + id: r.id, + payload, + created_at: r.created_at, + updated_at: r.updated_at, + }) + } +} + +/// Request body for creating a scratch (id comes from URL path, type from payload) +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct CreateScratch { + pub payload: ScratchPayload, +} + +/// Request body for updating a scratch +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct UpdateScratch { + pub payload: ScratchPayload, +} + +impl Scratch { + pub async fn create( + pool: &SqlitePool, + id: Uuid, + data: &CreateScratch, + ) -> Result { + let scratch_type_str = data.payload.scratch_type().to_string(); + let payload_str = serde_json::to_string(&data.payload)?; + + let row = sqlx::query_as!( + ScratchRow, + r#" + INSERT INTO scratch (id, scratch_type, payload) + VALUES ($1, $2, $3) + RETURNING + id as "id!: Uuid", + scratch_type, + payload, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + "#, + id, + scratch_type_str, + payload_str, + ) + .fetch_one(pool) + .await?; + + Scratch::try_from(row) + } + + pub async fn find_by_id( + pool: &SqlitePool, + id: Uuid, + scratch_type: &ScratchType, + ) -> Result, ScratchError> { + let scratch_type_str = scratch_type.to_string(); + let row = sqlx::query_as!( + ScratchRow, + r#" + SELECT + id as "id!: Uuid", + scratch_type, + payload, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM scratch + WHERE id = $1 AND scratch_type = $2 + "#, + id, + scratch_type_str, + ) + .fetch_optional(pool) + .await?; + + let scratch = row.map(Scratch::try_from).transpose()?; + Ok(scratch) + } + + pub async fn find_all(pool: &SqlitePool) -> Result, ScratchError> { + let rows = sqlx::query_as!( + ScratchRow, + r#" + SELECT + id as "id!: Uuid", + scratch_type, + payload, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM scratch + ORDER BY created_at DESC + "# + ) + .fetch_all(pool) + .await?; + + let scratches = rows + .into_iter() + .filter_map(|row| Scratch::try_from(row).ok()) + .collect(); + + Ok(scratches) + } + + /// Upsert a scratch record - creates if not exists, updates if exists. + pub async fn update( + pool: &SqlitePool, + id: Uuid, + scratch_type: &ScratchType, + data: &UpdateScratch, + ) -> Result { + let payload_str = serde_json::to_string(&data.payload)?; + let scratch_type_str = scratch_type.to_string(); + + // Upsert: insert if not exists, update if exists + let row = sqlx::query_as!( + ScratchRow, + r#" + INSERT INTO scratch (id, scratch_type, payload) + VALUES ($1, $2, $3) + ON CONFLICT(id, scratch_type) DO UPDATE SET + payload = excluded.payload, + updated_at = datetime('now', 'subsec') + RETURNING + id as "id!: Uuid", + scratch_type, + payload, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + "#, + id, + scratch_type_str, + payload_str, + ) + .fetch_one(pool) + .await?; + + Scratch::try_from(row) + } + + pub async fn delete( + pool: &SqlitePool, + id: Uuid, + scratch_type: &ScratchType, + ) -> Result { + let scratch_type_str = scratch_type.to_string(); + let result = sqlx::query!( + "DELETE FROM scratch WHERE id = $1 AND scratch_type = $2", + id, + scratch_type_str + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) + } + + pub async fn find_by_rowid( + pool: &SqlitePool, + rowid: i64, + ) -> Result, ScratchError> { + let row = sqlx::query_as!( + ScratchRow, + r#" + SELECT + id as "id!: Uuid", + scratch_type, + payload, + created_at as "created_at!: DateTime", + updated_at as "updated_at!: DateTime" + FROM scratch + WHERE rowid = $1 + "#, + rowid + ) + .fetch_optional(pool) + .await?; + + let scratch = row.map(Scratch::try_from).transpose()?; + Ok(scratch) + } +} diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index bf305862..47b993d3 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -20,7 +20,6 @@ use services::services::{ auth::AuthContext, config::{Config, ConfigError}, container::{ContainerError, ContainerService}, - drafts::DraftsService, events::{EventError, EventService}, file_search_cache::FileSearchCache, filesystem::{FilesystemError, FilesystemService}, @@ -28,6 +27,7 @@ use services::services::{ git::{GitService, GitServiceError}, image::{ImageError, ImageService}, pr_monitor::PrMonitorService, + queued_message::QueuedMessageService, share::{RemoteSync, RemoteSyncHandle, ShareConfig, SharePublisher}, worktree_manager::WorktreeError, }; @@ -100,7 +100,7 @@ pub trait Deployment: Clone + Send + Sync + 'static { fn approvals(&self) -> &Approvals; - fn drafts(&self) -> &DraftsService; + fn queued_message_service(&self) -> &QueuedMessageService; fn auth_context(&self) -> &AuthContext; diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 4a191e87..8f3f9884 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -12,21 +12,24 @@ use command_group::AsyncGroupChild; use db::{ DBService, models::{ - draft::{Draft, DraftType}, execution_process::{ ExecutionContext, ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus, }, executor_session::ExecutorSession, - image::TaskImage, merge::Merge, project::Project, + scratch::{DraftFollowUpData, Scratch, ScratchType}, task::{Task, TaskStatus}, task_attempt::TaskAttempt, }, }; use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::{ - actions::{Executable, ExecutorAction}, + actions::{ + Executable, ExecutorAction, ExecutorActionType, + coding_agent_follow_up::CodingAgentFollowUpRequest, + coding_agent_initial::CodingAgentInitialRequest, + }, approvals::{ExecutorApprovalService, NoopExecutorApprovalService}, executors::{BaseCodingAgent, ExecutorExitResult, ExecutorExitSignal}, logs::{ @@ -36,6 +39,7 @@ use executors::{ patch::{escape_json_pointer_segment, extract_normalized_entry_from_patch}, }, }, + profile::ExecutorProfileId, }; use futures::{FutureExt, StreamExt, TryStreamExt, stream::select}; use serde_json::json; @@ -47,6 +51,7 @@ use services::services::{ diff_stream::{self, DiffStreamHandle}, git::{Commit, DiffTarget, GitService}, image::ImageService, + queued_message::QueuedMessageService, share::SharePublisher, worktree_manager::{WorktreeCleanup, WorktreeManager}, }; @@ -71,6 +76,7 @@ pub struct LocalContainerService { image_service: ImageService, analytics: Option, approvals: Approvals, + queued_message_service: QueuedMessageService, publisher: Result, } @@ -84,6 +90,7 @@ impl LocalContainerService { image_service: ImageService, analytics: Option, approvals: Approvals, + queued_message_service: QueuedMessageService, publisher: Result, ) -> Self { let child_store = Arc::new(RwLock::new(HashMap::new())); @@ -97,6 +104,7 @@ impl LocalContainerService { image_service, analytics, approvals, + queued_message_service, publisher, }; @@ -409,16 +417,63 @@ impl LocalContainerService { } if container.should_finalize(&ctx) { - container - .finalize_task(&config, publisher.as_ref().ok(), &ctx) - .await; - // After finalization, check if a queued follow-up exists and start it - if let Err(e) = container.try_consume_queued_followup(&ctx).await { - tracing::error!( - "Failed to start queued follow-up for attempt {}: {}", - ctx.task_attempt.id, - e - ); + // Only execute queued messages if the execution succeeded + // If it failed or was killed, just clear the queue and finalize + let should_execute_queued = !matches!( + ctx.execution_process.status, + ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed + ); + + if let Some(queued_msg) = container + .queued_message_service + .take_queued(ctx.task_attempt.id) + { + if should_execute_queued { + tracing::info!( + "Found queued message for attempt {}, starting follow-up execution", + ctx.task_attempt.id + ); + + // Delete the scratch since we're consuming the queued message + if let Err(e) = Scratch::delete( + &db.pool, + ctx.task_attempt.id, + &ScratchType::DraftFollowUp, + ) + .await + { + tracing::warn!( + "Failed to delete scratch after consuming queued message: {}", + e + ); + } + + // Execute the queued follow-up + if let Err(e) = container + .start_queued_follow_up(&ctx, &queued_msg.data) + .await + { + tracing::error!("Failed to start queued follow-up: {}", e); + // Fall back to finalization if follow-up fails + container + .finalize_task(&config, publisher.as_ref().ok(), &ctx) + .await; + } + } else { + // Execution failed or was killed - discard the queued message and finalize + tracing::info!( + "Discarding queued message for attempt {} due to execution status {:?}", + ctx.task_attempt.id, + ctx.execution_process.status + ); + container + .finalize_task(&config, publisher.as_ref().ok(), &ctx) + .await; + } + } else { + container + .finalize_task(&config, publisher.as_ref().ok(), &ctx) + .await; } } @@ -662,156 +717,60 @@ impl LocalContainerService { Ok(()) } - /// If a queued follow-up draft exists for this attempt and nothing is running, - /// start it immediately and clear the draft. - async fn try_consume_queued_followup( + /// Start a follow-up execution from a queued message + async fn start_queued_follow_up( &self, ctx: &ExecutionContext, - ) -> Result<(), ContainerError> { - // Only consider CodingAgent/cleanup chains; skip DevServer completions - if matches!( - ctx.execution_process.run_reason, - ExecutionProcessRunReason::DevServer - ) { - return Ok(()); - } - - // If anything is running for this attempt, bail - let procs = - ExecutionProcess::find_by_task_attempt_id(&self.db.pool, ctx.task_attempt.id, false) - .await?; - if procs - .iter() - .any(|p| matches!(p.status, ExecutionProcessStatus::Running)) - { - return Ok(()); - } - - // Load draft and ensure it's eligible - let Some(draft) = Draft::find_by_task_attempt_and_type( - &self.db.pool, - ctx.task_attempt.id, - DraftType::FollowUp, - ) - .await? - else { - return Ok(()); - }; - - if !draft.queued || draft.prompt.trim().is_empty() { - return Ok(()); - } - - // Atomically acquire sending lock; if not acquired, someone else is sending. - if !Draft::try_mark_sending(&self.db.pool, ctx.task_attempt.id, DraftType::FollowUp) - .await - .unwrap_or(false) - { - return Ok(()); - } - - // Ensure worktree exists - let container_ref = self.ensure_container_exists(&ctx.task_attempt).await?; - - // Get session id - let Some(session_id) = ExecutionProcess::find_latest_session_id_by_task_attempt( + queued_data: &DraftFollowUpData, + ) -> Result { + // Get executor profile from the latest CodingAgent process + let initial_executor_profile_id = ExecutionProcess::latest_executor_profile_for_attempt( &self.db.pool, ctx.task_attempt.id, ) - .await? - else { - tracing::warn!( - "No session id found for attempt {}. Cannot start queued follow-up.", - ctx.task_attempt.id - ); - return Ok(()); + .await + .map_err(|e| ContainerError::Other(anyhow!("Failed to get executor profile: {e}")))?; + + let executor_profile_id = ExecutorProfileId { + executor: initial_executor_profile_id.executor, + variant: queued_data.variant.clone(), }; - // Get last coding agent process to inherit executor profile - let Some(latest) = ExecutionProcess::find_latest_by_task_attempt_and_run_reason( + // Get latest session ID for session continuity + let latest_session_id = ExecutionProcess::find_latest_session_id_by_task_attempt( &self.db.pool, ctx.task_attempt.id, + ) + .await?; + + // Get project for cleanup script + let project = Project::find_by_id(&self.db.pool, ctx.task.project_id) + .await? + .ok_or_else(|| ContainerError::Other(anyhow!("Project not found")))?; + + let cleanup_action = self.cleanup_action(project.cleanup_script); + + let action_type = if let Some(session_id) = latest_session_id { + ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { + prompt: queued_data.message.clone(), + session_id, + executor_profile_id: executor_profile_id.clone(), + }) + } else { + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt: queued_data.message.clone(), + executor_profile_id: executor_profile_id.clone(), + }) + }; + + let action = ExecutorAction::new(action_type, cleanup_action); + + self.start_execution( + &ctx.task_attempt, + &action, &ExecutionProcessRunReason::CodingAgent, ) - .await? - else { - tracing::warn!( - "No prior CodingAgent process for attempt {}. Cannot start queued follow-up.", - ctx.task_attempt.id - ); - return Ok(()); - }; - - use executors::actions::ExecutorActionType; - let initial_executor_profile_id = match &latest.executor_action()?.typ { - ExecutorActionType::CodingAgentInitialRequest(req) => req.executor_profile_id.clone(), - ExecutorActionType::CodingAgentFollowUpRequest(req) => req.executor_profile_id.clone(), - _ => { - tracing::warn!( - "Latest process for attempt {} is not a coding agent; skipping queued follow-up", - ctx.task_attempt.id - ); - return Ok(()); - } - }; - - let executor_profile_id = executors::profile::ExecutorProfileId { - executor: initial_executor_profile_id.executor, - variant: draft.variant.clone(), - }; - - // Prepare cleanup action - let cleanup_action = ctx - .task - .parent_project(&self.db.pool) - .await? - .and_then(|project| self.cleanup_action(project.cleanup_script)); - - // Handle images: associate, copy to worktree, canonicalize prompt - let mut prompt = draft.prompt.clone(); - if let Some(image_ids) = &draft.image_ids { - // Associate to task - let _ = TaskImage::associate_many_dedup(&self.db.pool, ctx.task.id, image_ids).await; - - // Copy to worktree and canonicalize - let worktree_path = std::path::PathBuf::from(&container_ref); - if let Err(e) = self - .image_service - .copy_images_by_ids_to_worktree(&worktree_path, image_ids) - .await - { - tracing::warn!("Failed to copy images to worktree: {}", e); - } else { - prompt = ImageService::canonicalise_image_paths(&prompt, &worktree_path); - } - } - - let follow_up_request = - executors::actions::coding_agent_follow_up::CodingAgentFollowUpRequest { - prompt, - session_id, - executor_profile_id, - }; - - let follow_up_action = executors::actions::ExecutorAction::new( - executors::actions::ExecutorActionType::CodingAgentFollowUpRequest(follow_up_request), - cleanup_action, - ); - - // Start the execution - let _ = self - .start_execution( - &ctx.task_attempt, - &follow_up_action, - &ExecutionProcessRunReason::CodingAgent, - ) - .await?; - - // Clear the draft to reflect that it has been consumed - let _ = - Draft::clear_after_send(&self.db.pool, ctx.task_attempt.id, DraftType::FollowUp).await; - - Ok(()) + .await } } diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index 8c3bc44f..201b68bf 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -10,13 +10,13 @@ use services::services::{ auth::AuthContext, config::{Config, load_config_from_file, save_config_to_file}, container::ContainerService, - drafts::DraftsService, events::EventService, file_search_cache::FileSearchCache, filesystem::FilesystemService, git::GitService, image::ImageService, oauth_credentials::OAuthCredentials, + queued_message::QueuedMessageService, remote_client::{RemoteClient, RemoteClientError}, share::{RemoteSyncHandle, ShareConfig, SharePublisher}, }; @@ -45,7 +45,7 @@ pub struct LocalDeployment { events: EventService, file_search_cache: Arc, approvals: Approvals, - drafts: DraftsService, + queued_message_service: QueuedMessageService, share_publisher: Result, share_sync_handle: Arc>>, share_config: Option, @@ -120,6 +120,7 @@ impl Deployment for LocalDeployment { } let approvals = Approvals::new(msg_stores.clone()); + let queued_message_service = QueuedMessageService::new(); let share_config = ShareConfig::from_env(); @@ -181,13 +182,13 @@ impl Deployment for LocalDeployment { image.clone(), analytics_ctx, approvals.clone(), + queued_message_service.clone(), share_publisher.clone(), ) .await; let events = EventService::new(db.clone(), events_msg_store, events_entry_count); - let drafts = DraftsService::new(db.clone(), image.clone()); let file_search_cache = Arc::new(FileSearchCache::new()); let deployment = Self { @@ -202,7 +203,7 @@ impl Deployment for LocalDeployment { events, file_search_cache, approvals, - drafts, + queued_message_service, share_publisher, share_sync_handle: share_sync_handle.clone(), share_config: share_config.clone(), @@ -262,8 +263,8 @@ impl Deployment for LocalDeployment { &self.approvals } - fn drafts(&self) -> &DraftsService { - &self.drafts + fn queued_message_service(&self) -> &QueuedMessageService { + &self.queued_message_service } fn share_publisher(&self) -> Result { diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 5fd20e75..dc63e5cf 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -38,6 +38,14 @@ fn generate_types_content() -> String { db::models::task::CreateTask::decl(), db::models::task::UpdateTask::decl(), db::models::shared_task::SharedTask::decl(), + db::models::scratch::DraftFollowUpData::decl(), + db::models::scratch::ScratchPayload::decl(), + db::models::scratch::ScratchType::decl(), + db::models::scratch::Scratch::decl(), + db::models::scratch::CreateScratch::decl(), + db::models::scratch::UpdateScratch::decl(), + services::services::queued_message::QueuedMessage::decl(), + services::services::queued_message::QueueStatus::decl(), db::models::image::Image::decl(), db::models::image::CreateImage::decl(), utils::response::ApiResponse::<()>::decl(), @@ -79,9 +87,6 @@ fn generate_types_content() -> String { server::routes::config::CheckAgentAvailabilityQuery::decl(), executors::executors::AvailabilityInfo::decl(), server::routes::task_attempts::CreateFollowUpAttempt::decl(), - services::services::drafts::DraftResponse::decl(), - services::services::drafts::UpdateFollowUpDraftRequest::decl(), - services::services::drafts::UpdateRetryFollowUpDraftRequest::decl(), server::routes::task_attempts::ChangeTargetBranchRequest::decl(), server::routes::task_attempts::ChangeTargetBranchResponse::decl(), server::routes::task_attempts::RenameBranchRequest::decl(), @@ -95,6 +100,7 @@ fn generate_types_content() -> String { server::routes::tasks::CreateAndStartTaskRequest::decl(), server::routes::task_attempts::CreateGitHubPrRequest::decl(), server::routes::images::ImageResponse::decl(), + server::routes::images::ImageMetadata::decl(), services::services::config::Config::decl(), services::services::config::NotificationConfig::decl(), services::services::config::ThemeMode::decl(), @@ -140,7 +146,6 @@ fn generate_types_content() -> String { server::routes::task_attempts::GitOperationError::decl(), server::routes::task_attempts::PushError::decl(), server::routes::task_attempts::CreatePrError::decl(), - server::routes::task_attempts::CommitInfo::decl(), server::routes::task_attempts::BranchStatus::decl(), services::services::git::ConflictOp::decl(), db::models::task_attempt::TaskAttempt::decl(), @@ -152,8 +157,6 @@ fn generate_types_content() -> String { db::models::merge::PrMerge::decl(), db::models::merge::MergeStatus::decl(), db::models::merge::PullRequestInfo::decl(), - db::models::draft::Draft::decl(), - db::models::draft::DraftType::decl(), executors::logs::CommandExitStatus::decl(), executors::logs::CommandRunResult::decl(), executors::logs::NormalizedEntry::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 917168b4..a5a234c4 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -5,7 +5,8 @@ use axum::{ response::{IntoResponse, Response}, }; use db::models::{ - execution_process::ExecutionProcessError, project::ProjectError, task_attempt::TaskAttemptError, + execution_process::ExecutionProcessError, project::ProjectError, scratch::ScratchError, + task_attempt::TaskAttemptError, }; use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::executors::ExecutorError; @@ -13,7 +14,6 @@ use git2::Error as Git2Error; use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, - drafts::DraftsServiceError, git::GitServiceError, github::GitHubServiceError, image::ImageError, @@ -32,6 +32,8 @@ pub enum ApiError { #[error(transparent)] TaskAttempt(#[from] TaskAttemptError), #[error(transparent)] + ScratchError(#[from] ScratchError), + #[error(transparent)] ExecutionProcess(#[from] ExecutionProcessError), #[error(transparent)] GitService(#[from] GitServiceError), @@ -51,8 +53,6 @@ pub enum ApiError { Config(#[from] ConfigError), #[error(transparent)] Image(#[from] ImageError), - #[error(transparent)] - Drafts(#[from] DraftsServiceError), #[error("Multipart error: {0}")] Multipart(#[from] MultipartError), #[error("IO error: {0}")] @@ -94,6 +94,7 @@ impl IntoResponse for ApiError { let (status_code, error_type) = match &self { ApiError::Project(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ProjectError"), ApiError::TaskAttempt(_) => (StatusCode::INTERNAL_SERVER_ERROR, "TaskAttemptError"), + ApiError::ScratchError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ScratchError"), ApiError::ExecutionProcess(err) => match err { ExecutionProcessError::ExecutionProcessNotFound => { (StatusCode::NOT_FOUND, "ExecutionProcessError") @@ -123,19 +124,6 @@ impl IntoResponse for ApiError { ImageError::NotFound => (StatusCode::NOT_FOUND, "ImageNotFound"), _ => (StatusCode::INTERNAL_SERVER_ERROR, "ImageError"), }, - ApiError::Drafts(drafts_err) => match drafts_err { - DraftsServiceError::Conflict(_) => (StatusCode::CONFLICT, "ConflictError"), - DraftsServiceError::Database(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError") - } - DraftsServiceError::Container(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "ContainerError") - } - DraftsServiceError::Image(_) => (StatusCode::INTERNAL_SERVER_ERROR, "ImageError"), - DraftsServiceError::ExecutionProcess(_) => { - (StatusCode::INTERNAL_SERVER_ERROR, "ExecutionProcessError") - } - }, ApiError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"), ApiError::EditorOpen(err) => match err { EditorOpenError::LaunchFailed { .. } => { @@ -256,15 +244,6 @@ impl IntoResponse for ApiError { ApiError::BadRequest(msg) => msg.clone(), ApiError::Conflict(msg) => msg.clone(), ApiError::Forbidden(msg) => msg.clone(), - ApiError::Drafts(drafts_err) => match drafts_err { - DraftsServiceError::Conflict(msg) => msg.clone(), - DraftsServiceError::Database(_) => format!("{}: {}", error_type, drafts_err), - DraftsServiceError::Container(_) => format!("{}: {}", error_type, drafts_err), - DraftsServiceError::Image(_) => format!("{}: {}", error_type, drafts_err), - DraftsServiceError::ExecutionProcess(_) => { - format!("{}: {}", error_type, drafts_err) - } - }, _ => format!("{}: {}", error_type, self), }; let response = ApiResponse::<()>::error(&error_message); diff --git a/crates/server/src/routes/drafts.rs b/crates/server/src/routes/drafts.rs deleted file mode 100644 index 959bd9fc..00000000 --- a/crates/server/src/routes/drafts.rs +++ /dev/null @@ -1,67 +0,0 @@ -use axum::{ - Router, - extract::{ - Query, State, - ws::{WebSocket, WebSocketUpgrade}, - }, - response::IntoResponse, - routing::get, -}; -use deployment::Deployment; -use futures_util::{SinkExt, StreamExt, TryStreamExt}; -use serde::Deserialize; -use uuid::Uuid; - -use crate::DeploymentImpl; - -#[derive(Debug, Deserialize)] -pub struct DraftsQuery { - pub project_id: Uuid, -} - -pub async fn stream_project_drafts_ws( - ws: WebSocketUpgrade, - State(deployment): State, - Query(query): Query, -) -> impl IntoResponse { - ws.on_upgrade(move |socket| async move { - if let Err(e) = handle_project_drafts_ws(socket, deployment, query.project_id).await { - tracing::warn!("drafts WS closed: {}", e); - } - }) -} - -async fn handle_project_drafts_ws( - socket: WebSocket, - deployment: DeploymentImpl, - project_id: Uuid, -) -> anyhow::Result<()> { - let mut stream = deployment - .events() - .stream_drafts_for_project_raw(project_id) - .await? - .map_ok(|msg| msg.to_ws_message_unchecked()); - - let (mut sender, mut receiver) = socket.split(); - tokio::spawn(async move { while let Some(Ok(_)) = receiver.next().await {} }); - - while let Some(item) = stream.next().await { - match item { - Ok(msg) => { - if sender.send(msg).await.is_err() { - break; - } - } - Err(e) => { - tracing::error!("stream error: {}", e); - break; - } - } - } - Ok(()) -} - -pub fn router(_deployment: &DeploymentImpl) -> Router { - let inner = Router::new().route("/stream/ws", get(stream_project_drafts_ws)); - Router::new().nest("/drafts", inner) -} diff --git a/crates/server/src/routes/images.rs b/crates/server/src/routes/images.rs index d202af00..96330f38 100644 --- a/crates/server/src/routes/images.rs +++ b/crates/server/src/routes/images.rs @@ -1,7 +1,9 @@ +use std::path::Path as StdPath; + use axum::{ Router, body::Body, - extract::{DefaultBodyLimit, Multipart, Path, State}, + extract::{DefaultBodyLimit, Multipart, Path, Query, State}, http::{StatusCode, header}, response::{Json as ResponseJson, Response}, routing::{delete, get, post}, @@ -52,6 +54,24 @@ impl ImageResponse { } } +#[derive(Debug, Deserialize)] +pub struct ImageMetadataQuery { + /// Path relative to worktree root, e.g., ".vibe-images/screenshot.png" + pub path: String, +} + +/// Metadata response for image files, used for rendering in WYSIWYG editor +#[derive(Debug, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct ImageMetadata { + pub exists: bool, + pub file_name: Option, + pub path: Option, + pub size_bytes: Option, + pub format: Option, + pub proxy_url: Option, +} + pub async fn upload_image( State(deployment): State, multipart: Multipart, @@ -170,6 +190,69 @@ pub async fn get_task_images( Ok(ResponseJson(ApiResponse::success(image_responses))) } +/// Get metadata for an image associated with a task. +/// The path should be in the format `.vibe-images/{uuid}.{ext}`. +pub async fn get_task_image_metadata( + Path(task_id): Path, + State(deployment): State, + Query(query): Query, +) -> Result>, ApiError> { + let not_found_response = || ImageMetadata { + exists: false, + file_name: None, + path: Some(query.path.clone()), + size_bytes: None, + format: None, + proxy_url: None, + }; + + // Validate path starts with .vibe-images/ + let vibe_images_prefix = format!("{}/", utils::path::VIBE_IMAGES_DIR); + if !query.path.starts_with(&vibe_images_prefix) { + return Ok(ResponseJson(ApiResponse::success(not_found_response()))); + } + + // Reject paths with .. to prevent traversal + if query.path.contains("..") { + return Ok(ResponseJson(ApiResponse::success(not_found_response()))); + } + + // Extract the filename from the path (e.g., "uuid.png" from ".vibe-images/uuid.png") + let file_name = match query.path.strip_prefix(&vibe_images_prefix) { + Some(name) if !name.is_empty() => name, + _ => return Ok(ResponseJson(ApiResponse::success(not_found_response()))), + }; + + // Look up the image by file_path (which is just the filename in the images table) + let image = match Image::find_by_file_path(&deployment.db().pool, file_name).await? { + Some(img) => img, + None => return Ok(ResponseJson(ApiResponse::success(not_found_response()))), + }; + + // Verify the image is associated with this task + let is_associated = TaskImage::is_associated(&deployment.db().pool, task_id, image.id).await?; + if !is_associated { + return Ok(ResponseJson(ApiResponse::success(not_found_response()))); + } + + // Get format from extension + let format = StdPath::new(file_name) + .extension() + .map(|ext| ext.to_string_lossy().to_lowercase()); + + // Build the proxy URL + let proxy_url = format!("/api/images/{}/file", image.id); + + Ok(ResponseJson(ApiResponse::success(ImageMetadata { + exists: true, + file_name: Some(image.original_name), + path: Some(query.path), + size_bytes: Some(image.size_bytes), + format, + proxy_url: Some(proxy_url), + }))) +} + pub fn routes() -> Router { Router::new() .route( @@ -179,6 +262,7 @@ pub fn routes() -> Router { .route("/{id}/file", get(serve_image)) .route("/{id}", delete(delete_image)) .route("/task/{task_id}", get(get_task_images)) + .route("/task/{task_id}/metadata", get(get_task_image_metadata)) .route( "/task/{task_id}/upload", post(upload_task_image).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 71139432..2966cb96 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -10,7 +10,6 @@ pub mod config; pub mod containers; pub mod filesystem; // pub mod github; -pub mod drafts; pub mod events; pub mod execution_processes; pub mod frontend; @@ -19,6 +18,7 @@ pub mod images; pub mod oauth; pub mod organizations; pub mod projects; +pub mod scratch; pub mod shared_tasks; pub mod tags; pub mod task_attempts; @@ -31,7 +31,6 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(config::router()) .merge(containers::router(&deployment)) .merge(projects::router(&deployment)) - .merge(drafts::router(&deployment)) .merge(tasks::router(&deployment)) .merge(shared_tasks::router()) .merge(task_attempts::router(&deployment)) @@ -42,6 +41,7 @@ pub fn router(deployment: DeploymentImpl) -> IntoMakeService { .merge(filesystem::router()) .merge(events::router(&deployment)) .merge(approvals::router()) + .merge(scratch::router(&deployment)) .nest("/images", images::routes()) .with_state(deployment); diff --git a/crates/server/src/routes/scratch.rs b/crates/server/src/routes/scratch.rs new file mode 100644 index 00000000..6401bcb1 --- /dev/null +++ b/crates/server/src/routes/scratch.rs @@ -0,0 +1,161 @@ +use axum::{ + Json, Router, + extract::{ + Path, State, + ws::{WebSocket, WebSocketUpgrade}, + }, + response::{IntoResponse, Json as ResponseJson}, + routing::get, +}; +use db::models::scratch::{CreateScratch, Scratch, ScratchType, UpdateScratch}; +use deployment::Deployment; +use futures_util::{SinkExt, StreamExt, TryStreamExt}; +use serde::Deserialize; +use utils::response::ApiResponse; +use uuid::Uuid; + +use crate::{DeploymentImpl, error::ApiError}; + +/// Path parameters for scratch routes with composite key +#[derive(Deserialize)] +pub struct ScratchPath { + scratch_type: ScratchType, + id: Uuid, +} + +pub async fn list_scratch( + State(deployment): State, +) -> Result>>, ApiError> { + let scratch_items = Scratch::find_all(&deployment.db().pool).await?; + Ok(ResponseJson(ApiResponse::success(scratch_items))) +} + +pub async fn get_scratch( + State(deployment): State, + Path(ScratchPath { scratch_type, id }): Path, +) -> Result>, ApiError> { + let scratch = Scratch::find_by_id(&deployment.db().pool, id, &scratch_type) + .await? + .ok_or_else(|| ApiError::BadRequest("Scratch not found".to_string()))?; + Ok(ResponseJson(ApiResponse::success(scratch))) +} + +pub async fn create_scratch( + State(deployment): State, + Path(ScratchPath { scratch_type, id }): Path, + Json(payload): Json, +) -> Result>, ApiError> { + // Reject edits to draft_follow_up if a message is queued for this task attempt + if matches!(scratch_type, ScratchType::DraftFollowUp) + && deployment.queued_message_service().has_queued(id) + { + return Err(ApiError::BadRequest( + "Cannot edit scratch while a message is queued".to_string(), + )); + } + + // Validate that payload type matches URL type + payload + .payload + .validate_type(scratch_type) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + + let scratch = Scratch::create(&deployment.db().pool, id, &payload).await?; + Ok(ResponseJson(ApiResponse::success(scratch))) +} + +pub async fn update_scratch( + State(deployment): State, + Path(ScratchPath { scratch_type, id }): Path, + Json(payload): Json, +) -> Result>, ApiError> { + // Reject edits to draft_follow_up if a message is queued for this task attempt + if matches!(scratch_type, ScratchType::DraftFollowUp) + && deployment.queued_message_service().has_queued(id) + { + return Err(ApiError::BadRequest( + "Cannot edit scratch while a message is queued".to_string(), + )); + } + + // Validate that payload type matches URL type + payload + .payload + .validate_type(scratch_type) + .map_err(|e| ApiError::BadRequest(e.to_string()))?; + + // Upsert: creates if not exists, updates if exists + let scratch = Scratch::update(&deployment.db().pool, id, &scratch_type, &payload).await?; + Ok(ResponseJson(ApiResponse::success(scratch))) +} + +pub async fn delete_scratch( + State(deployment): State, + Path(ScratchPath { scratch_type, id }): Path, +) -> Result>, ApiError> { + let rows = Scratch::delete(&deployment.db().pool, id, &scratch_type).await?; + if rows == 0 { + return Err(ApiError::BadRequest("Scratch not found".to_string())); + } + Ok(ResponseJson(ApiResponse::success(()))) +} + +pub async fn stream_scratch_ws( + ws: WebSocketUpgrade, + State(deployment): State, + Path(ScratchPath { scratch_type, id }): Path, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| async move { + if let Err(e) = handle_scratch_ws(socket, deployment, id, scratch_type).await { + tracing::warn!("scratch WS closed: {}", e); + } + }) +} + +async fn handle_scratch_ws( + socket: WebSocket, + deployment: DeploymentImpl, + id: Uuid, + scratch_type: ScratchType, +) -> anyhow::Result<()> { + let mut stream = deployment + .events() + .stream_scratch_raw(id, &scratch_type) + .await? + .map_ok(|msg| msg.to_ws_message_unchecked()); + + let (mut sender, mut receiver) = socket.split(); + + tokio::spawn(async move { while let Some(Ok(_)) = receiver.next().await {} }); + + while let Some(item) = stream.next().await { + match item { + Ok(msg) => { + if sender.send(msg).await.is_err() { + break; + } + } + Err(e) => { + tracing::error!("scratch stream error: {}", e); + break; + } + } + } + Ok(()) +} + +pub fn router(_deployment: &DeploymentImpl) -> Router { + Router::new() + .route("/scratch", get(list_scratch)) + .route( + "/scratch/{scratch_type}/{id}", + get(get_scratch) + .post(create_scratch) + .put(update_scratch) + .delete(delete_scratch), + ) + .route( + "/scratch/{scratch_type}/{id}/stream/ws", + get(stream_scratch_ws), + ) +} diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 9b4695ea..04904651 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -1,7 +1,8 @@ pub mod codex_setup; pub mod cursor_setup; -pub mod drafts; pub mod gh_cli_setup; +pub mod images; +pub mod queue; pub mod util; use axum::{ @@ -16,10 +17,10 @@ use axum::{ routing::{get, post}, }; use db::models::{ - draft::{Draft, DraftType}, execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, merge::{Merge, MergeStatus, PrMerge, PullRequestInfo}, project::{Project, ProjectError}, + scratch::{Scratch, ScratchType}, task::{Task, TaskRelationships, TaskStatus}, task_attempt::{CreateTaskAttempt, TaskAttempt, TaskAttemptError}, }; @@ -49,10 +50,7 @@ use crate::{ DeploymentImpl, error::ApiError, middleware::load_task_attempt_middleware, - routes::task_attempts::{ - gh_cli_setup::GhCliSetupError, - util::{ensure_worktree_path, handle_images_for_prompt}, - }, + routes::task_attempts::{gh_cli_setup::GhCliSetupError, util::ensure_worktree_path}, }; #[derive(Debug, Deserialize, Serialize, TS)] @@ -215,7 +213,6 @@ pub async fn run_agent_setup( pub struct CreateFollowUpAttempt { pub prompt: String, pub variant: Option, - pub image_ids: Option>, pub retry_process_id: Option, pub force_when_dirty: Option, pub perform_git_reset: Option, @@ -309,9 +306,6 @@ pub async fn follow_up( // Soft-drop the target process and all later processes let _ = ExecutionProcess::drop_at_and_after(pool, task_attempt.id, proc_id).await?; - - // Best-effort: clear any retry draft for this attempt - let _ = Draft::clear_after_send(pool, task_attempt.id, DraftType::Retry).await; } let latest_session_id = ExecutionProcess::find_latest_session_id_by_task_attempt( @@ -320,11 +314,7 @@ pub async fn follow_up( ) .await?; - let mut prompt = payload.prompt; - if let Some(image_ids) = &payload.image_ids { - prompt = handle_images_for_prompt(&deployment, &task_attempt, task.id, image_ids, &prompt) - .await?; - } + let prompt = payload.prompt; let cleanup_action = deployment .container() @@ -356,13 +346,21 @@ pub async fn follow_up( ) .await?; - // Clear drafts post-send: - // - If this was a retry send, the retry draft has already been cleared above. - // - Otherwise, clear the follow-up draft to avoid. - if payload.retry_process_id.is_none() { - let _ = - Draft::clear_after_send(&deployment.db().pool, task_attempt.id, DraftType::FollowUp) - .await; + // Clear the draft follow-up scratch on successful spawn + // This ensures the scratch is wiped even if the user navigates away quickly + if let Err(e) = Scratch::delete( + &deployment.db().pool, + task_attempt.id, + &ScratchType::DraftFollowUp, + ) + .await + { + // Log but don't fail the request - scratch deletion is best-effort + tracing::debug!( + "Failed to delete draft follow-up scratch for attempt {}: {}", + task_attempt.id, + e + ); } Ok(ResponseJson(ApiResponse::success(execution_process))) @@ -431,33 +429,9 @@ async fn handle_task_attempt_diff_ws( Ok(()) } -#[derive(Debug, Serialize, TS)] -pub struct CommitInfo { - pub sha: String, - pub subject: String, -} - -pub async fn get_commit_info( - Extension(task_attempt): Extension, - State(deployment): State, - Query(params): Query>, -) -> Result>, ApiError> { - let Some(sha) = params.get("sha").cloned() else { - return Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "Missing sha param".to_string(), - ))); - }; - let wt_buf = ensure_worktree_path(&deployment, &task_attempt).await?; - let wt = wt_buf.as_path(); - let subject = deployment.git().get_commit_subject(wt, &sha)?; - Ok(ResponseJson(ApiResponse::success(CommitInfo { - sha, - subject, - }))) -} - #[derive(Debug, Serialize, TS)] pub struct CommitCompareResult { + pub subject: String, pub head_oid: String, pub target_oid: String, pub ahead_from_head: usize, @@ -477,6 +451,7 @@ pub async fn compare_commit_to_head( }; let wt_buf = ensure_worktree_path(&deployment, &task_attempt).await?; let wt = wt_buf.as_path(); + let subject = deployment.git().get_commit_subject(wt, &target_oid)?; let head_info = deployment.git().get_head_info(wt)?; let (ahead_from_head, behind_from_head) = deployment @@ -484,6 +459,7 @@ pub async fn compare_commit_to_head( .ahead_behind_commits_by_oid(wt, &head_info.oid, &target_oid)?; let is_linear = behind_from_head == 0; Ok(ResponseJson(ApiResponse::success(CommitCompareResult { + subject, head_oid: head_info.oid, target_oid, ahead_from_head, @@ -1574,14 +1550,6 @@ pub fn router(deployment: &DeploymentImpl) -> Router { .route("/follow-up", post(follow_up)) .route("/run-agent-setup", post(run_agent_setup)) .route("/gh-cli-setup", post(gh_cli_setup_handler)) - .route( - "/draft", - get(drafts::get_draft) - .put(drafts::save_draft) - .delete(drafts::delete_draft), - ) - .route("/draft/queue", post(drafts::set_draft_queue)) - .route("/commit-info", get(get_commit_info)) .route("/commit-compare", get(compare_commit_to_head)) .route("/start-dev-server", post(start_dev_server)) .route("/branch-status", get(get_task_attempt_branch_status)) @@ -1605,7 +1573,9 @@ pub fn router(deployment: &DeploymentImpl) -> Router { let task_attempts_router = Router::new() .route("/", get(get_task_attempts).post(create_task_attempt)) - .nest("/{id}", task_attempt_id_router); + .nest("/{id}", task_attempt_id_router) + .nest("/{id}/images", images::router(deployment)) + .nest("/{id}/queue", queue::router(deployment)); Router::new().nest("/task-attempts", task_attempts_router) } diff --git a/crates/server/src/routes/task_attempts/drafts.rs b/crates/server/src/routes/task_attempts/drafts.rs deleted file mode 100644 index d355d3eb..00000000 --- a/crates/server/src/routes/task_attempts/drafts.rs +++ /dev/null @@ -1,147 +0,0 @@ -use axum::{Extension, Json, extract::State, response::Json as ResponseJson}; -use db::models::{ - draft::DraftType, - task_attempt::{TaskAttempt, TaskAttemptError}, -}; -use deployment::Deployment; -use serde::Deserialize; -use services::services::drafts::{ - DraftResponse, SetQueueRequest, UpdateFollowUpDraftRequest, UpdateRetryFollowUpDraftRequest, -}; -use utils::response::ApiResponse; - -use crate::{DeploymentImpl, error::ApiError}; - -#[derive(Debug, Deserialize)] -pub struct DraftTypeQuery { - #[serde(rename = "type")] - pub draft_type: DraftType, -} - -#[axum::debug_handler] -pub async fn save_follow_up_draft( - Extension(task_attempt): Extension, - State(deployment): State, - Json(payload): Json, -) -> Result>, ApiError> { - let service = deployment.drafts(); - let resp = service - .save_follow_up_draft(&task_attempt, &payload) - .await?; - Ok(ResponseJson(ApiResponse::success(resp))) -} - -#[axum::debug_handler] -pub async fn save_retry_follow_up_draft( - Extension(task_attempt): Extension, - State(deployment): State, - Json(payload): Json, -) -> Result>, ApiError> { - let service = deployment.drafts(); - let resp = service - .save_retry_follow_up_draft(&task_attempt, &payload) - .await?; - Ok(ResponseJson(ApiResponse::success(resp))) -} - -#[axum::debug_handler] -pub async fn delete_retry_follow_up_draft( - Extension(task_attempt): Extension, - State(deployment): State, -) -> Result>, ApiError> { - let service = deployment.drafts(); - service.delete_retry_follow_up_draft(&task_attempt).await?; - Ok(ResponseJson(ApiResponse::success(()))) -} - -#[axum::debug_handler] -pub async fn set_follow_up_queue( - Extension(task_attempt): Extension, - State(deployment): State, - Json(payload): Json, -) -> Result>, ApiError> { - let service = deployment.drafts(); - let resp = service - .set_follow_up_queue(deployment.container(), &task_attempt, &payload) - .await?; - Ok(ResponseJson(ApiResponse::success(resp))) -} - -#[axum::debug_handler] -pub async fn get_draft( - Extension(task_attempt): Extension, - State(deployment): State, - axum::extract::Query(q): axum::extract::Query, -) -> Result>, ApiError> { - let service = deployment.drafts(); - let resp = service.get_draft(task_attempt.id, q.draft_type).await?; - Ok(ResponseJson(ApiResponse::success(resp))) -} - -#[axum::debug_handler] -pub async fn save_draft( - Extension(task_attempt): Extension, - State(deployment): State, - axum::extract::Query(q): axum::extract::Query, - Json(payload): Json, -) -> Result>, ApiError> { - let service = deployment.drafts(); - match q.draft_type { - DraftType::FollowUp => { - let body: UpdateFollowUpDraftRequest = - serde_json::from_value(payload).map_err(|e| { - ApiError::TaskAttempt(TaskAttemptError::ValidationError(e.to_string())) - })?; - let resp = service.save_follow_up_draft(&task_attempt, &body).await?; - Ok(ResponseJson(ApiResponse::success(resp))) - } - DraftType::Retry => { - let body: UpdateRetryFollowUpDraftRequest = - serde_json::from_value(payload).map_err(|e| { - ApiError::TaskAttempt(TaskAttemptError::ValidationError(e.to_string())) - })?; - let resp = service - .save_retry_follow_up_draft(&task_attempt, &body) - .await?; - Ok(ResponseJson(ApiResponse::success(resp))) - } - } -} - -#[axum::debug_handler] -pub async fn delete_draft( - Extension(task_attempt): Extension, - State(deployment): State, - axum::extract::Query(q): axum::extract::Query, -) -> Result>, ApiError> { - let service = deployment.drafts(); - match q.draft_type { - DraftType::FollowUp => Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "Cannot delete follow-up draft; unqueue or edit instead".to_string(), - ))), - DraftType::Retry => { - service.delete_retry_follow_up_draft(&task_attempt).await?; - Ok(ResponseJson(ApiResponse::success(()))) - } - } -} - -#[axum::debug_handler] -pub async fn set_draft_queue( - Extension(task_attempt): Extension, - State(deployment): State, - axum::extract::Query(q): axum::extract::Query, - Json(payload): Json, -) -> Result>, ApiError> { - if q.draft_type != DraftType::FollowUp { - return Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError( - "Queue is only supported for follow-up drafts".to_string(), - ))); - } - - let service = deployment.drafts(); - let resp = service - .set_follow_up_queue(deployment.container(), &task_attempt, &payload) - .await?; - Ok(ResponseJson(ApiResponse::success(resp))) -} diff --git a/crates/server/src/routes/task_attempts/images.rs b/crates/server/src/routes/task_attempts/images.rs new file mode 100644 index 00000000..86a7004d --- /dev/null +++ b/crates/server/src/routes/task_attempts/images.rs @@ -0,0 +1,240 @@ +use std::path::Path; + +use axum::{ + Extension, Router, + body::Body, + extract::{DefaultBodyLimit, Multipart, Query, Request, State}, + http::{StatusCode, header}, + middleware::{Next, from_fn_with_state}, + response::{Json as ResponseJson, Response}, + routing::{get, post}, +}; +use db::models::{task::Task, task_attempt::TaskAttempt}; +use deployment::Deployment; +use serde::Deserialize; +use services::services::image::ImageError; +use tokio::fs::File; +use tokio_util::io::ReaderStream; +use utils::response::ApiResponse; +use uuid::Uuid; + +use super::util::ensure_worktree_path; +use crate::{ + DeploymentImpl, + error::ApiError, + middleware::load_task_attempt_middleware, + routes::images::{ImageMetadata, ImageResponse, process_image_upload}, +}; + +#[derive(Debug, Deserialize)] +pub struct ImageMetadataQuery { + /// Path relative to worktree root, e.g., ".vibe-images/screenshot.png" + pub path: String, +} + +/// Upload an image and immediately copy it to the task attempt's worktree. +/// This allows images to be available in the container before follow-up is sent. +pub async fn upload_image( + Extension(task_attempt): Extension, + State(deployment): State, + multipart: Multipart, +) -> Result>, ApiError> { + // Get the task for this attempt + let task = Task::find_by_id(&deployment.db().pool, task_attempt.task_id) + .await? + .ok_or_else(|| ApiError::Image(ImageError::NotFound))?; + + // Process upload (store in cache, associate with task) + let image_response = process_image_upload(&deployment, multipart, Some(task.id)).await?; + + // Copy image to worktree immediately + let worktree_path = ensure_worktree_path(&deployment, &task_attempt).await?; + deployment + .image() + .copy_images_by_ids_to_worktree(&worktree_path, &[image_response.id]) + .await?; + + Ok(ResponseJson(ApiResponse::success(image_response))) +} + +/// Get metadata about an image in the task attempt's worktree. +pub async fn get_image_metadata( + Extension(task_attempt): Extension, + State(deployment): State, + Query(query): Query, +) -> Result>, ApiError> { + // Validate path starts with .vibe-images/ + let vibe_images_prefix = format!("{}/", utils::path::VIBE_IMAGES_DIR); + if !query.path.starts_with(&vibe_images_prefix) { + return Ok(ResponseJson(ApiResponse::success(ImageMetadata { + exists: false, + file_name: None, + path: Some(query.path), + size_bytes: None, + format: None, + proxy_url: None, + }))); + } + + // Reject paths with .. to prevent traversal + if query.path.contains("..") { + return Ok(ResponseJson(ApiResponse::success(ImageMetadata { + exists: false, + file_name: None, + path: Some(query.path), + size_bytes: None, + format: None, + proxy_url: None, + }))); + } + + let worktree_path = ensure_worktree_path(&deployment, &task_attempt).await?; + let full_path = worktree_path.join(&query.path); + + // Check if file exists + let metadata = match tokio::fs::metadata(&full_path).await { + Ok(m) if m.is_file() => m, + _ => { + return Ok(ResponseJson(ApiResponse::success(ImageMetadata { + exists: false, + file_name: None, + path: Some(query.path), + size_bytes: None, + format: None, + proxy_url: None, + }))); + } + }; + + // Extract filename + let file_name = Path::new(&query.path) + .file_name() + .map(|s| s.to_string_lossy().to_string()); + + // Detect format from extension + let format = Path::new(&query.path) + .extension() + .map(|ext| ext.to_string_lossy().to_lowercase()); + + // Build proxy URL - the path after .vibe-images/ + let image_path = query.path.strip_prefix(&vibe_images_prefix).unwrap_or(""); + let proxy_url = format!( + "/api/task-attempts/{}/images/file/{}", + task_attempt.id, image_path + ); + + Ok(ResponseJson(ApiResponse::success(ImageMetadata { + exists: true, + file_name, + path: Some(query.path), + size_bytes: Some(metadata.len() as i64), + format, + proxy_url: Some(proxy_url), + }))) +} + +/// Serve an image file from the task attempt's .vibe-images folder. +pub async fn serve_image( + axum::extract::Path((_id, path)): axum::extract::Path<(Uuid, String)>, + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result { + // Reject paths with .. to prevent traversal + if path.contains("..") { + return Err(ApiError::Image(ImageError::NotFound)); + } + + let worktree_path = ensure_worktree_path(&deployment, &task_attempt).await?; + let vibe_images_dir = worktree_path.join(utils::path::VIBE_IMAGES_DIR); + let full_path = vibe_images_dir.join(&path); + + // Security: Canonicalize and verify path is within .vibe-images + let canonical_path = tokio::fs::canonicalize(&full_path) + .await + .map_err(|_| ApiError::Image(ImageError::NotFound))?; + + let canonical_vibe_images = tokio::fs::canonicalize(&vibe_images_dir) + .await + .map_err(|_| ApiError::Image(ImageError::NotFound))?; + + if !canonical_path.starts_with(&canonical_vibe_images) { + return Err(ApiError::Image(ImageError::NotFound)); + } + + // Open and stream the file + let file = File::open(&canonical_path) + .await + .map_err(|_| ApiError::Image(ImageError::NotFound))?; + + let metadata = file + .metadata() + .await + .map_err(|_| ApiError::Image(ImageError::NotFound))?; + + let stream = ReaderStream::new(file); + let body = Body::from_stream(stream); + + // Determine content type from extension + let content_type = Path::new(&path) + .extension() + .and_then(|ext| match ext.to_string_lossy().to_lowercase().as_str() { + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + "svg" => Some("image/svg+xml"), + "ico" => Some("image/x-icon"), + "bmp" => Some("image/bmp"), + "tiff" | "tif" => Some("image/tiff"), + _ => None, + }) + .unwrap_or("application/octet-stream"); + + let response = Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, metadata.len()) + .header(header::CACHE_CONTROL, "public, max-age=31536000") + .body(body) + .map_err(|e| ApiError::Image(ImageError::ResponseBuildError(e.to_string())))?; + + Ok(response) +} + +/// Middleware to load TaskAttempt for routes with wildcard path params. +async fn load_task_attempt_with_wildcard( + State(deployment): State, + axum::extract::Path((id, _path)): axum::extract::Path<(Uuid, String)>, + mut request: Request, + next: Next, +) -> Result { + let attempt = match TaskAttempt::find_by_id(&deployment.db().pool, id).await { + Ok(Some(a)) => a, + Ok(None) => return Err(StatusCode::NOT_FOUND), + Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), + }; + request.extensions_mut().insert(attempt); + Ok(next.run(request).await) +} + +pub fn router(deployment: &DeploymentImpl) -> Router { + let metadata_router = Router::new() + .route("/metadata", get(get_image_metadata)) + .route( + "/upload", + post(upload_image).layer(DefaultBodyLimit::max(20 * 1024 * 1024)), // 20MB limit + ) + .layer(from_fn_with_state( + deployment.clone(), + load_task_attempt_middleware, + )); + + let file_router = Router::new() + .route("/file/{*path}", get(serve_image)) + .layer(from_fn_with_state( + deployment.clone(), + load_task_attempt_with_wildcard, + )); + + metadata_router.merge(file_router) +} diff --git a/crates/server/src/routes/task_attempts/queue.rs b/crates/server/src/routes/task_attempts/queue.rs new file mode 100644 index 00000000..63497972 --- /dev/null +++ b/crates/server/src/routes/task_attempts/queue.rs @@ -0,0 +1,95 @@ +use axum::{ + Extension, Json, Router, extract::State, middleware::from_fn_with_state, + response::Json as ResponseJson, routing::get, +}; +use db::models::{scratch::DraftFollowUpData, task_attempt::TaskAttempt}; +use deployment::Deployment; +use serde::Deserialize; +use services::services::queued_message::QueueStatus; +use ts_rs::TS; +use utils::response::ApiResponse; + +use crate::{DeploymentImpl, error::ApiError, middleware::load_task_attempt_middleware}; + +/// Request body for queueing a follow-up message +#[derive(Debug, Deserialize, TS)] +pub struct QueueMessageRequest { + pub message: String, + pub variant: Option, +} + +/// Queue a follow-up message to be executed when the current execution finishes +pub async fn queue_message( + Extension(task_attempt): Extension, + State(deployment): State, + Json(payload): Json, +) -> Result>, ApiError> { + let data = DraftFollowUpData { + message: payload.message, + variant: payload.variant, + }; + + let queued = deployment + .queued_message_service() + .queue_message(task_attempt.id, data); + + deployment + .track_if_analytics_allowed( + "follow_up_queued", + serde_json::json!({ + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(QueueStatus::Queued { + message: queued, + }))) +} + +/// Cancel a queued follow-up message +pub async fn cancel_queued_message( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + deployment + .queued_message_service() + .cancel_queued(task_attempt.id); + + deployment + .track_if_analytics_allowed( + "follow_up_queue_cancelled", + serde_json::json!({ + "attempt_id": task_attempt.id.to_string(), + }), + ) + .await; + + Ok(ResponseJson(ApiResponse::success(QueueStatus::Empty))) +} + +/// Get the current queue status for a task attempt +pub async fn get_queue_status( + Extension(task_attempt): Extension, + State(deployment): State, +) -> Result>, ApiError> { + let status = deployment + .queued_message_service() + .get_status(task_attempt.id); + + Ok(ResponseJson(ApiResponse::success(status))) +} + +pub fn router(deployment: &DeploymentImpl) -> Router { + Router::new() + .route( + "/", + get(get_queue_status) + .post(queue_message) + .delete(cancel_queued_message), + ) + .layer(from_fn_with_state( + deployment.clone(), + load_task_attempt_middleware, + )) +} diff --git a/crates/server/src/routes/task_attempts/util.rs b/crates/server/src/routes/task_attempts/util.rs index 4c0d3aa3..3d1e8880 100644 --- a/crates/server/src/routes/task_attempts/util.rs +++ b/crates/server/src/routes/task_attempts/util.rs @@ -1,7 +1,5 @@ -use db::models::image::TaskImage; use deployment::Deployment; -use services::services::{container::ContainerService, image::ImageService}; -use uuid::Uuid; +use services::services::container::ContainerService; use crate::error::ApiError; @@ -16,30 +14,3 @@ pub async fn ensure_worktree_path( .await?; Ok(std::path::PathBuf::from(container_ref)) } - -/// Associate images to the task, copy into worktree, and canonicalize paths in the prompt. -/// Returns the transformed prompt. -pub async fn handle_images_for_prompt( - deployment: &crate::DeploymentImpl, - attempt: &db::models::task_attempt::TaskAttempt, - task_id: Uuid, - image_ids: &[Uuid], - prompt: &str, -) -> Result { - if image_ids.is_empty() { - return Ok(prompt.to_string()); - } - - TaskImage::associate_many_dedup(&deployment.db().pool, task_id, image_ids).await?; - - // Copy to worktree and canonicalize - let worktree_path = ensure_worktree_path(deployment, attempt).await?; - deployment - .image() - .copy_images_by_ids_to_worktree(&worktree_path, image_ids) - .await?; - Ok(ImageService::canonicalise_image_paths( - prompt, - &worktree_path, - )) -} diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index 490980b4..9451664b 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -4,7 +4,7 @@ use std::{ sync::Arc, }; -use anyhow::{Error as AnyhowError, anyhow}; +use anyhow::Error as AnyhowError; use async_trait::async_trait; use db::{ DBService, @@ -43,7 +43,6 @@ use uuid::Uuid; use crate::services::{ config::Config, git::{GitService, GitServiceError}, - image::ImageService, notification::NotificationService, share::SharePublisher, worktree_manager::WorktreeError, @@ -669,14 +668,7 @@ pub trait ContainerService { .await? .ok_or(SqlxError::RowNotFound)?; - // TODO: this implementation will not work in cloud - let worktree_path = PathBuf::from( - task_attempt - .container_ref - .as_ref() - .ok_or_else(|| ContainerError::Other(anyhow!("Container ref not found")))?, - ); - let prompt = ImageService::canonicalise_image_paths(&task.to_prompt(), &worktree_path); + let prompt = task.to_prompt(); let cleanup_action = self.cleanup_action(project.cleanup_script); diff --git a/crates/services/src/services/drafts.rs b/crates/services/src/services/drafts.rs deleted file mode 100644 index 07bda6d0..00000000 --- a/crates/services/src/services/drafts.rs +++ /dev/null @@ -1,482 +0,0 @@ -use std::path::{Path, PathBuf}; - -use db::{ - DBService, - models::{ - draft::{Draft, DraftType, UpsertDraft}, - execution_process::{ - ExecutionProcess, ExecutionProcessError, ExecutionProcessRunReason, - ExecutionProcessStatus, - }, - image::TaskImage, - task_attempt::TaskAttempt, - }, -}; -use executors::{ - actions::{ - ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, - }, - profile::ExecutorProfileId, -}; -use serde::{Deserialize, Serialize}; -use sqlx::Error as SqlxError; -use thiserror::Error; -use ts_rs::TS; -use uuid::Uuid; - -use super::{ - container::{ContainerError, ContainerService}, - image::{ImageError, ImageService}, -}; - -#[derive(Debug, Error)] -pub enum DraftsServiceError { - #[error(transparent)] - Database(#[from] sqlx::Error), - #[error(transparent)] - Container(#[from] ContainerError), - #[error(transparent)] - Image(#[from] ImageError), - #[error(transparent)] - ExecutionProcess(#[from] ExecutionProcessError), - #[error("Conflict: {0}")] - Conflict(String), -} - -#[derive(Debug, Serialize, TS)] -pub struct DraftResponse { - pub task_attempt_id: Uuid, - pub draft_type: DraftType, - pub retry_process_id: Option, - pub prompt: String, - pub queued: bool, - pub variant: Option, - pub image_ids: Option>, - pub version: i64, -} - -#[derive(Debug, Deserialize, TS)] -pub struct UpdateFollowUpDraftRequest { - pub prompt: Option, - pub variant: Option>, - pub image_ids: Option>, - pub version: Option, -} - -#[derive(Debug, Deserialize, TS)] -pub struct UpdateRetryFollowUpDraftRequest { - pub retry_process_id: Uuid, - pub prompt: Option, - pub variant: Option>, - pub image_ids: Option>, - pub version: Option, -} - -#[derive(Debug, Deserialize, TS)] -pub struct SetQueueRequest { - pub queued: bool, - pub expected_queued: Option, - pub expected_version: Option, -} - -#[derive(Clone)] -pub struct DraftsService { - db: DBService, - image: ImageService, -} - -impl DraftsService { - pub fn new(db: DBService, image: ImageService) -> Self { - Self { db, image } - } - - fn pool(&self) -> &sqlx::SqlitePool { - &self.db.pool - } - - fn draft_to_response(d: Draft) -> DraftResponse { - DraftResponse { - task_attempt_id: d.task_attempt_id, - draft_type: d.draft_type, - retry_process_id: d.retry_process_id, - prompt: d.prompt, - queued: d.queued, - variant: d.variant, - image_ids: d.image_ids, - version: d.version, - } - } - - async fn ensure_follow_up_draft_row( - &self, - attempt_id: Uuid, - ) -> Result { - if let Some(d) = - Draft::find_by_task_attempt_and_type(self.pool(), attempt_id, DraftType::FollowUp) - .await? - { - return Ok(d); - } - - let _ = Draft::upsert( - self.pool(), - &UpsertDraft { - task_attempt_id: attempt_id, - draft_type: DraftType::FollowUp, - retry_process_id: None, - prompt: "".to_string(), - queued: false, - variant: None, - image_ids: None, - }, - ) - .await?; - - Draft::find_by_task_attempt_and_type(self.pool(), attempt_id, DraftType::FollowUp) - .await? - .ok_or(SqlxError::RowNotFound) - .map_err(DraftsServiceError::from) - } - - async fn associate_images_for_task_if_any( - &self, - task_id: Uuid, - image_ids: &Option>, - ) -> Result<(), DraftsServiceError> { - if let Some(ids) = image_ids - && !ids.is_empty() - { - TaskImage::associate_many_dedup(self.pool(), task_id, ids).await?; - } - Ok(()) - } - - async fn has_running_processes_for_attempt( - &self, - attempt_id: Uuid, - ) -> Result { - let processes = - ExecutionProcess::find_by_task_attempt_id(self.pool(), attempt_id, false).await?; - Ok(processes.into_iter().any(|p| { - matches!(p.status, ExecutionProcessStatus::Running) - && !matches!(p.run_reason, ExecutionProcessRunReason::DevServer) - })) - } - - async fn fetch_draft_response( - &self, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result { - let d = - Draft::find_by_task_attempt_and_type(self.pool(), task_attempt_id, draft_type).await?; - let resp = if let Some(d) = d { - Self::draft_to_response(d) - } else { - DraftResponse { - task_attempt_id, - draft_type, - retry_process_id: None, - prompt: "".to_string(), - queued: false, - variant: None, - image_ids: None, - version: 0, - } - }; - Ok(resp) - } - - async fn handle_images_for_prompt( - &self, - task_id: Uuid, - image_ids: &[Uuid], - prompt: &str, - worktree_path: &Path, - ) -> Result { - if image_ids.is_empty() { - return Ok(prompt.to_string()); - } - - TaskImage::associate_many_dedup(self.pool(), task_id, image_ids).await?; - self.image - .copy_images_by_ids_to_worktree(worktree_path, image_ids) - .await?; - Ok(ImageService::canonicalise_image_paths( - prompt, - worktree_path, - )) - } - - async fn start_follow_up_from_draft( - &self, - container: &(dyn ContainerService + Send + Sync), - task_attempt: &TaskAttempt, - draft: &Draft, - ) -> Result { - let worktree_ref = container.ensure_container_exists(task_attempt).await?; - let worktree_path = PathBuf::from(worktree_ref); - let base_profile = - ExecutionProcess::latest_executor_profile_for_attempt(self.pool(), task_attempt.id) - .await?; - let executor_profile_id = ExecutorProfileId { - executor: base_profile.executor, - variant: draft.variant.clone(), - }; - - let task = task_attempt - .parent_task(self.pool()) - .await? - .ok_or(SqlxError::RowNotFound) - .map_err(DraftsServiceError::from)?; - let project = task - .parent_project(self.pool()) - .await? - .ok_or(SqlxError::RowNotFound) - .map_err(DraftsServiceError::from)?; - - let cleanup_action = container.cleanup_action(project.cleanup_script); - - let mut prompt = draft.prompt.clone(); - if let Some(image_ids) = &draft.image_ids { - prompt = self - .handle_images_for_prompt(task_attempt.task_id, image_ids, &prompt, &worktree_path) - .await?; - } - - let latest_session_id = - ExecutionProcess::find_latest_session_id_by_task_attempt(self.pool(), task_attempt.id) - .await?; - - let action_type = if let Some(session_id) = latest_session_id { - ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { - prompt: prompt.clone(), - session_id, - executor_profile_id, - }) - } else { - ExecutorActionType::CodingAgentInitialRequest( - executors::actions::coding_agent_initial::CodingAgentInitialRequest { - prompt, - executor_profile_id, - }, - ) - }; - - let follow_up_action = ExecutorAction::new(action_type, cleanup_action); - - let execution_process = container - .start_execution( - task_attempt, - &follow_up_action, - &ExecutionProcessRunReason::CodingAgent, - ) - .await?; - - let _ = Draft::clear_after_send(self.pool(), task_attempt.id, DraftType::FollowUp).await; - - Ok(execution_process) - } - - pub async fn save_follow_up_draft( - &self, - task_attempt: &TaskAttempt, - payload: &UpdateFollowUpDraftRequest, - ) -> Result { - let pool = self.pool(); - let d = self.ensure_follow_up_draft_row(task_attempt.id).await?; - if d.queued { - return Err(DraftsServiceError::Conflict( - "Draft is queued; click Edit to unqueue before editing".to_string(), - )); - } - - if let Some(expected_version) = payload.version - && d.version != expected_version - { - return Err(DraftsServiceError::Conflict( - "Draft changed, please retry with latest".to_string(), - )); - } - - if payload.prompt.is_none() && payload.variant.is_none() && payload.image_ids.is_none() { - } else { - Draft::update_partial( - pool, - task_attempt.id, - DraftType::FollowUp, - payload.prompt.clone(), - payload.variant.clone(), - payload.image_ids.clone(), - None, - ) - .await?; - } - - if let Some(task) = task_attempt.parent_task(pool).await? { - self.associate_images_for_task_if_any(task.id, &payload.image_ids) - .await?; - } - - let current = - Draft::find_by_task_attempt_and_type(pool, task_attempt.id, DraftType::FollowUp) - .await? - .map(Self::draft_to_response) - .unwrap_or(DraftResponse { - task_attempt_id: task_attempt.id, - draft_type: DraftType::FollowUp, - retry_process_id: None, - prompt: "".to_string(), - queued: false, - variant: None, - image_ids: None, - version: 0, - }); - - Ok(current) - } - - pub async fn save_retry_follow_up_draft( - &self, - task_attempt: &TaskAttempt, - payload: &UpdateRetryFollowUpDraftRequest, - ) -> Result { - let pool = self.pool(); - let existing = - Draft::find_by_task_attempt_and_type(pool, task_attempt.id, DraftType::Retry).await?; - - if let Some(d) = &existing { - if d.queued { - return Err(DraftsServiceError::Conflict( - "Retry draft is queued; unqueue before editing".to_string(), - )); - } - if let Some(expected_version) = payload.version - && d.version != expected_version - { - return Err(DraftsServiceError::Conflict( - "Retry draft changed, please retry with latest".to_string(), - )); - } - } - - if existing.is_none() { - let draft = Draft::upsert( - pool, - &UpsertDraft { - task_attempt_id: task_attempt.id, - draft_type: DraftType::Retry, - retry_process_id: Some(payload.retry_process_id), - prompt: payload.prompt.clone().unwrap_or_default(), - queued: false, - variant: payload.variant.clone().unwrap_or(None), - image_ids: payload.image_ids.clone(), - }, - ) - .await?; - - return Ok(Self::draft_to_response(draft)); - } - - if payload.prompt.is_none() && payload.variant.is_none() && payload.image_ids.is_none() { - } else { - Draft::update_partial( - pool, - task_attempt.id, - DraftType::Retry, - payload.prompt.clone(), - payload.variant.clone(), - payload.image_ids.clone(), - Some(payload.retry_process_id), - ) - .await?; - } - - if let Some(task) = task_attempt.parent_task(pool).await? { - self.associate_images_for_task_if_any(task.id, &payload.image_ids) - .await?; - } - - let draft = Draft::find_by_task_attempt_and_type(pool, task_attempt.id, DraftType::Retry) - .await? - .ok_or(SqlxError::RowNotFound) - .map_err(DraftsServiceError::from)?; - Ok(Self::draft_to_response(draft)) - } - - pub async fn delete_retry_follow_up_draft( - &self, - task_attempt: &TaskAttempt, - ) -> Result<(), DraftsServiceError> { - Draft::delete_by_task_attempt_and_type(self.pool(), task_attempt.id, DraftType::Retry) - .await?; - - Ok(()) - } - - pub async fn set_follow_up_queue( - &self, - container: &(dyn ContainerService + Send + Sync), - task_attempt: &TaskAttempt, - payload: &SetQueueRequest, - ) -> Result { - let pool = self.pool(); - - let rows_updated = Draft::set_queued( - pool, - task_attempt.id, - DraftType::FollowUp, - payload.queued, - payload.expected_queued, - payload.expected_version, - ) - .await?; - - let draft = - Draft::find_by_task_attempt_and_type(pool, task_attempt.id, DraftType::FollowUp) - .await?; - - if rows_updated == 0 { - if draft.is_none() { - return Err(DraftsServiceError::Conflict( - "No draft to queue".to_string(), - )); - }; - - return Err(DraftsServiceError::Conflict( - "Draft changed, please refresh and try again".to_string(), - )); - } - - let should_consider_start = draft.as_ref().map(|c| c.queued).unwrap_or(false) - && !self - .has_running_processes_for_attempt(task_attempt.id) - .await?; - - if should_consider_start - && Draft::try_mark_sending(pool, task_attempt.id, DraftType::FollowUp) - .await - .unwrap_or(false) - { - let _ = self - .start_follow_up_from_draft(container, task_attempt, draft.as_ref().unwrap()) - .await; - } - - let draft = - Draft::find_by_task_attempt_and_type(pool, task_attempt.id, DraftType::FollowUp) - .await? - .ok_or(SqlxError::RowNotFound) - .map_err(DraftsServiceError::from)?; - - Ok(Self::draft_to_response(draft)) - } - - pub async fn get_draft( - &self, - task_attempt_id: Uuid, - draft_type: DraftType, - ) -> Result { - self.fetch_draft_response(task_attempt_id, draft_type).await - } -} diff --git a/crates/services/src/services/events.rs b/crates/services/src/services/events.rs index af842b30..c8ed9fa8 100644 --- a/crates/services/src/services/events.rs +++ b/crates/services/src/services/events.rs @@ -3,11 +3,8 @@ use std::{str::FromStr, sync::Arc}; use db::{ DBService, models::{ - draft::{Draft, DraftType}, - execution_process::ExecutionProcess, - shared_task::SharedTask as SharedDbTask, - task::Task, - task_attempt::TaskAttempt, + execution_process::ExecutionProcess, scratch::Scratch, + shared_task::SharedTask as SharedDbTask, task::Task, task_attempt::TaskAttempt, }, }; use serde_json::json; @@ -24,7 +21,7 @@ mod streams; pub mod types; pub use patches::{ - draft_patch, execution_process_patch, shared_task_patch, task_attempt_patch, task_patch, + execution_process_patch, scratch_patch, shared_task_patch, task_attempt_patch, task_patch, }; pub use types::{EventError, EventPatch, EventPatchInner, HookTables, RecordTypes}; @@ -136,28 +133,15 @@ impl EventService { msg_store_for_preupdate.push_patch(patch); } } - "drafts" => { - let draft_type = preupdate - .get_old_column_value(2) - .ok() - .and_then(|val| >::decode(val).ok()) - .and_then(|s| DraftType::from_str(&s).ok()); - let task_attempt_id = preupdate - .get_old_column_value(1) - .ok() - .and_then(|val| >::decode(val).ok()); - - if let (Some(draft_type), Some(task_attempt_id)) = - (draft_type, task_attempt_id) + "scratch" => { + // Composite key: need both id (column 0) and scratch_type (column 1) + if let Ok(id_val) = preupdate.get_old_column_value(0) + && let Ok(scratch_id) = >::decode(id_val) + && let Ok(type_val) = preupdate.get_old_column_value(1) + && let Ok(type_str) = + >::decode(type_val) { - let patch = match draft_type { - DraftType::FollowUp => { - draft_patch::follow_up_clear(task_attempt_id) - } - DraftType::Retry => { - draft_patch::retry_clear(task_attempt_id) - } - }; + let patch = scratch_patch::remove(scratch_id, &type_str); msg_store_for_preupdate.push_patch(patch); } } @@ -179,8 +163,8 @@ impl EventService { (HookTables::Tasks, SqliteOperation::Delete) | (HookTables::TaskAttempts, SqliteOperation::Delete) | (HookTables::ExecutionProcesses, SqliteOperation::Delete) - | (HookTables::Drafts, SqliteOperation::Delete) - | (HookTables::SharedTasks, SqliteOperation::Delete) => { + | (HookTables::SharedTasks, SqliteOperation::Delete) + | (HookTables::Scratch, SqliteOperation::Delete) => { // Deletions handled in preupdate hook for reliable data capture return; } @@ -247,19 +231,16 @@ impl EventService { } } } - (HookTables::Drafts, _) => { - match Draft::find_by_rowid(&db.pool, rowid).await { - Ok(Some(draft)) => match draft.draft_type { - DraftType::FollowUp => RecordTypes::Draft(draft), - DraftType::Retry => RecordTypes::RetryDraft(draft), - }, - Ok(None) => RecordTypes::DeletedDraft { + (HookTables::Scratch, _) => { + match Scratch::find_by_rowid(&db.pool, rowid).await { + Ok(Some(scratch)) => RecordTypes::Scratch(scratch), + Ok(None) => RecordTypes::DeletedScratch { rowid, - draft_type: DraftType::Retry, - task_attempt_id: None, + scratch_id: None, + scratch_type: None, }, Err(e) => { - tracing::error!("Failed to fetch draft: {:?}", e); + tracing::error!("Failed to fetch scratch: {:?}", e); return; } } @@ -299,17 +280,6 @@ impl EventService { return; } } - // Draft updates: emit direct patches used by the follow-up draft stream - RecordTypes::Draft(draft) => { - let patch = draft_patch::follow_up_replace(draft); - msg_store_for_hook.push_patch(patch); - return; - } - RecordTypes::RetryDraft(draft) => { - let patch = draft_patch::retry_replace(draft); - msg_store_for_hook.push_patch(patch); - return; - } RecordTypes::SharedTask(task) => { let patch = match hook.operation { SqliteOperation::Insert => shared_task_patch::add(task), @@ -319,14 +289,6 @@ impl EventService { msg_store_for_hook.push_patch(patch); return; } - RecordTypes::DeletedDraft { draft_type, task_attempt_id: Some(id), .. } => { - let patch = match draft_type { - DraftType::FollowUp => draft_patch::follow_up_clear(*id), - DraftType::Retry => draft_patch::retry_clear(*id), - }; - msg_store_for_hook.push_patch(patch); - return; - } RecordTypes::DeletedTask { task_id: Some(task_id), .. @@ -343,6 +305,24 @@ impl EventService { msg_store_for_hook.push_patch(patch); return; } + RecordTypes::Scratch(scratch) => { + let patch = match hook.operation { + SqliteOperation::Insert => scratch_patch::add(scratch), + SqliteOperation::Update => scratch_patch::replace(scratch), + _ => scratch_patch::replace(scratch), + }; + msg_store_for_hook.push_patch(patch); + return; + } + RecordTypes::DeletedScratch { + scratch_id: Some(scratch_id), + scratch_type: Some(scratch_type_str), + .. + } => { + let patch = scratch_patch::remove(*scratch_id, scratch_type_str); + msg_store_for_hook.push_patch(patch); + return; + } RecordTypes::TaskAttempt(attempt) => { // Task attempts should update the parent task with fresh data if let Ok(Some(task)) = diff --git a/crates/services/src/services/events/patches.rs b/crates/services/src/services/events/patches.rs index 4354d5e1..daa98f9e 100644 --- a/crates/services/src/services/events/patches.rs +++ b/crates/services/src/services/events/patches.rs @@ -1,9 +1,6 @@ use db::models::{ - draft::{Draft, DraftType}, - execution_process::ExecutionProcess, - shared_task::SharedTask as DbSharedTask, - task::TaskWithAttemptStatus, - task_attempt::TaskAttempt, + execution_process::ExecutionProcess, scratch::Scratch, shared_task::SharedTask as DbSharedTask, + task::TaskWithAttemptStatus, task_attempt::TaskAttempt, }; use json_patch::{AddOperation, Patch, PatchOperation, RemoveOperation, ReplaceOperation}; use uuid::Uuid; @@ -132,73 +129,6 @@ pub mod execution_process_patch { } } -/// Helper functions for creating draft-specific patches -pub mod draft_patch { - use super::*; - - fn follow_up_path(attempt_id: Uuid) -> String { - format!("/drafts/{attempt_id}/follow_up") - } - - fn retry_path(attempt_id: Uuid) -> String { - format!("/drafts/{attempt_id}/retry") - } - - /// Replace the follow-up draft for a specific attempt - pub fn follow_up_replace(draft: &Draft) -> Patch { - Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: follow_up_path(draft.task_attempt_id) - .try_into() - .expect("Path should be valid"), - value: serde_json::to_value(draft).expect("Draft serialization should not fail"), - })]) - } - - /// Replace the retry draft for a specific attempt - pub fn retry_replace(draft: &Draft) -> Patch { - Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: retry_path(draft.task_attempt_id) - .try_into() - .expect("Path should be valid"), - value: serde_json::to_value(draft).expect("Draft serialization should not fail"), - })]) - } - - /// Clear the follow-up draft for an attempt (replace with an empty draft) - pub fn follow_up_clear(attempt_id: Uuid) -> Patch { - let empty = Draft { - id: uuid::Uuid::new_v4(), - task_attempt_id: attempt_id, - draft_type: DraftType::FollowUp, - retry_process_id: None, - prompt: String::new(), - queued: false, - sending: false, - variant: None, - image_ids: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - version: 0, - }; - Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: follow_up_path(attempt_id) - .try_into() - .expect("Path should be valid"), - value: serde_json::to_value(empty).expect("Draft serialization should not fail"), - })]) - } - - /// Clear the retry draft for an attempt (set to null) - pub fn retry_clear(attempt_id: Uuid) -> Patch { - Patch(vec![PatchOperation::Replace(ReplaceOperation { - path: retry_path(attempt_id) - .try_into() - .expect("Path should be valid"), - value: serde_json::Value::Null, - })]) - } -} - /// Helper functions for creating task attempt-specific patches pub mod task_attempt_patch { use super::*; @@ -241,3 +171,46 @@ pub mod task_attempt_patch { })]) } } + +/// Helper functions for creating scratch-specific patches. +/// All patches use path "/scratch" - filtering is done by matching id and payload type in the value. +pub mod scratch_patch { + use super::*; + + const SCRATCH_PATH: &str = "/scratch"; + + /// Create patch for adding a new scratch + pub fn add(scratch: &Scratch) -> Patch { + Patch(vec![PatchOperation::Add(AddOperation { + path: SCRATCH_PATH + .try_into() + .expect("Scratch path should be valid"), + value: serde_json::to_value(scratch).expect("Scratch serialization should not fail"), + })]) + } + + /// Create patch for updating an existing scratch + pub fn replace(scratch: &Scratch) -> Patch { + Patch(vec![PatchOperation::Replace(ReplaceOperation { + path: SCRATCH_PATH + .try_into() + .expect("Scratch path should be valid"), + value: serde_json::to_value(scratch).expect("Scratch serialization should not fail"), + })]) + } + + /// Create patch for removing a scratch. + /// Uses Replace with deleted marker so clients can filter by id and payload type. + pub fn remove(scratch_id: Uuid, scratch_type_str: &str) -> Patch { + Patch(vec![PatchOperation::Replace(ReplaceOperation { + path: SCRATCH_PATH + .try_into() + .expect("Scratch path should be valid"), + value: serde_json::json!({ + "id": scratch_id, + "payload": { "type": scratch_type_str }, + "deleted": true + }), + })]) + } +} diff --git a/crates/services/src/services/events/streams.rs b/crates/services/src/services/events/streams.rs index a9dc833b..f367e2ac 100644 --- a/crates/services/src/services/events/streams.rs +++ b/crates/services/src/services/events/streams.rs @@ -1,7 +1,7 @@ use db::models::{ - draft::{Draft, DraftType}, execution_process::ExecutionProcess, project::Project, + scratch::Scratch, shared_task::SharedTask, task::{Task, TaskWithAttemptStatus}, }; @@ -344,94 +344,69 @@ impl EventService { Ok(combined_stream) } - /// Stream drafts for all task attempts in a project with initial snapshot (raw LogMsg) - pub async fn stream_drafts_for_project_raw( + /// Stream a single scratch item with initial snapshot (raw LogMsg format for WebSocket) + pub async fn stream_scratch_raw( &self, - project_id: Uuid, + scratch_id: Uuid, + scratch_type: &db::models::scratch::ScratchType, ) -> Result>, EventError> { - // Load all attempt ids for tasks in this project - let attempt_ids: Vec = sqlx::query_scalar( - r#"SELECT ta.id - FROM task_attempts ta - JOIN tasks t ON t.id = ta.task_id - WHERE t.project_id = ?"#, - ) - .bind(project_id) - .fetch_all(&self.db.pool) - .await?; - - // Build initial drafts map keyed by attempt_id - let mut drafts_map: serde_json::Map = serde_json::Map::new(); - for attempt_id in attempt_ids { - let fu = Draft::find_by_task_attempt_and_type( - &self.db.pool, - attempt_id, - DraftType::FollowUp, - ) - .await? - .unwrap_or(Draft { - id: uuid::Uuid::new_v4(), - task_attempt_id: attempt_id, - draft_type: DraftType::FollowUp, - retry_process_id: None, - prompt: String::new(), - queued: false, - sending: false, - variant: None, - image_ids: None, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - version: 0, - }); - let re = - Draft::find_by_task_attempt_and_type(&self.db.pool, attempt_id, DraftType::Retry) - .await?; - let entry = json!({ - "follow_up": fu, - "retry": serde_json::to_value(re).unwrap_or(serde_json::Value::Null), - }); - drafts_map.insert(attempt_id.to_string(), entry); - } - - let initial_patch = json!([ - { - "op": "replace", - "path": "/drafts", - "value": drafts_map + // Treat errors (e.g., corrupted/malformed data) the same as "scratch not found" + // This prevents the websocket from closing and retrying indefinitely + let scratch = match Scratch::find_by_id(&self.db.pool, scratch_id, scratch_type).await { + Ok(scratch) => scratch, + Err(e) => { + tracing::warn!( + scratch_id = %scratch_id, + scratch_type = %scratch_type, + error = %e, + "Failed to load scratch, treating as empty" + ); + None } - ]); + }; + + let initial_patch = json!([{ + "op": "replace", + "path": "/scratch", + "value": scratch + }]); let initial_msg = LogMsg::JsonPatch(serde_json::from_value(initial_patch).unwrap()); - let db_pool = self.db.pool.clone(); - // Live updates: accept direct draft patches and filter by project membership + let type_str = scratch_type.to_string(); + + // Filter to only this scratch's events by matching id and payload.type in the patch value let filtered_stream = BroadcastStream::new(self.msg_store.get_receiver()).filter_map(move |msg_result| { - let db_pool = db_pool.clone(); + let id_str = scratch_id.to_string(); + let type_str = type_str.clone(); async move { match msg_result { Ok(LogMsg::JsonPatch(patch)) => { - if let Some(op) = patch.0.first() { - let path = op.path(); - if let Some(rest) = path.strip_prefix("/drafts/") - && let Some((attempt_str, _)) = rest.split_once('/') - && let Ok(attempt_id) = Uuid::parse_str(attempt_str) - { - // Check project membership - if let Ok(Some(task_attempt)) = - db::models::task_attempt::TaskAttempt::find_by_id( - &db_pool, attempt_id, - ) - .await - && let Ok(Some(task)) = db::models::task::Task::find_by_id( - &db_pool, - task_attempt.task_id, - ) - .await - && task.project_id == project_id - { - return Some(Ok(LogMsg::JsonPatch(patch))); - } + if let Some(op) = patch.0.first() + && op.path() == "/scratch" + { + // Extract id and payload.type from the patch value + let value = match op { + json_patch::PatchOperation::Add(a) => Some(&a.value), + json_patch::PatchOperation::Replace(r) => Some(&r.value), + json_patch::PatchOperation::Remove(_) => None, + _ => None, + }; + + let matches = value.is_some_and(|v| { + let id_matches = + v.get("id").and_then(|v| v.as_str()) == Some(&id_str); + let type_matches = v + .get("payload") + .and_then(|p| p.get("type")) + .and_then(|t| t.as_str()) + == Some(&type_str); + id_matches && type_matches + }); + + if matches { + return Some(Ok(LogMsg::JsonPatch(patch))); } } None diff --git a/crates/services/src/services/events/types.rs b/crates/services/src/services/events/types.rs index 0dbe4de0..4c7a262a 100644 --- a/crates/services/src/services/events/types.rs +++ b/crates/services/src/services/events/types.rs @@ -1,9 +1,6 @@ use anyhow::Error as AnyhowError; use db::models::{ - draft::{Draft, DraftType}, - execution_process::ExecutionProcess, - shared_task::SharedTask, - task::Task, + execution_process::ExecutionProcess, scratch::Scratch, shared_task::SharedTask, task::Task, task_attempt::TaskAttempt, }; use serde::{Deserialize, Serialize}; @@ -31,10 +28,10 @@ pub enum HookTables { TaskAttempts, #[strum(to_string = "execution_processes")] ExecutionProcesses, - #[strum(to_string = "drafts")] - Drafts, #[strum(to_string = "shared_tasks")] SharedTasks, + #[strum(to_string = "scratch")] + Scratch, } #[derive(Serialize, Deserialize, TS)] @@ -43,9 +40,8 @@ pub enum RecordTypes { Task(Task), TaskAttempt(TaskAttempt), ExecutionProcess(ExecutionProcess), - Draft(Draft), - RetryDraft(Draft), SharedTask(SharedTask), + Scratch(Scratch), DeletedTask { rowid: i64, project_id: Option, @@ -60,15 +56,15 @@ pub enum RecordTypes { task_attempt_id: Option, process_id: Option, }, - DeletedDraft { - rowid: i64, - draft_type: DraftType, - task_attempt_id: Option, - }, DeletedSharedTask { rowid: i64, task_id: Option, }, + DeletedScratch { + rowid: i64, + scratch_id: Option, + scratch_type: Option, + }, } #[derive(Serialize, Deserialize, TS)] diff --git a/crates/services/src/services/image.rs b/crates/services/src/services/image.rs index 027b410c..25b5bfd9 100644 --- a/crates/services/src/services/image.rs +++ b/crates/services/src/services/image.rs @@ -4,7 +4,6 @@ use std::{ }; use db::models::image::{CreateImage, Image}; -use regex::{Captures, Regex}; use sha2::{Digest, Sha256}; use sqlx::SqlitePool; use uuid::Uuid; @@ -216,21 +215,4 @@ impl ImageService { Ok(()) } - - pub fn canonicalise_image_paths(prompt: &str, worktree_path: &Path) -> String { - let pattern = format!( - r#"!\[([^\]]*)\]\(({}/[^)\s]+)\)"#, - regex::escape(utils::path::VIBE_IMAGES_DIR) - ); - let re = Regex::new(&pattern).unwrap(); - - re.replace_all(prompt, |caps: &Captures| { - let alt = &caps[1]; - let rel = &caps[2]; - let abs = worktree_path.join(rel); - let abs = abs.to_string_lossy().replace('\\', "/"); - format!("![{alt}]({abs})") - }) - .into_owned() - } } diff --git a/crates/services/src/services/mod.rs b/crates/services/src/services/mod.rs index 18449bee..6b9efbad 100644 --- a/crates/services/src/services/mod.rs +++ b/crates/services/src/services/mod.rs @@ -4,7 +4,6 @@ pub mod auth; pub mod config; pub mod container; pub mod diff_stream; -pub mod drafts; pub mod events; pub mod file_ranker; pub mod file_search_cache; @@ -16,6 +15,7 @@ pub mod image; pub mod notification; pub mod oauth_credentials; pub mod pr_monitor; +pub mod queued_message; pub mod remote_client; pub mod share; pub mod worktree_manager; diff --git a/crates/services/src/services/queued_message.rs b/crates/services/src/services/queued_message.rs new file mode 100644 index 00000000..90ef4550 --- /dev/null +++ b/crates/services/src/services/queued_message.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use db::models::scratch::DraftFollowUpData; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; +use uuid::Uuid; + +/// Represents a queued follow-up message for a task attempt +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct QueuedMessage { + /// The task attempt this message is queued for + pub task_attempt_id: Uuid, + /// The follow-up data (message + variant) + pub data: DraftFollowUpData, + /// Timestamp when the message was queued + pub queued_at: DateTime, +} + +/// Status of the queue for a task attempt (for frontend display) +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "status", rename_all = "snake_case")] +#[ts(export)] +pub enum QueueStatus { + /// No message queued + Empty, + /// Message is queued and waiting for execution to complete + Queued { message: QueuedMessage }, +} + +/// In-memory service for managing queued follow-up messages. +/// One queued message per task attempt. +#[derive(Clone)] +pub struct QueuedMessageService { + queue: Arc>, +} + +impl QueuedMessageService { + pub fn new() -> Self { + Self { + queue: Arc::new(DashMap::new()), + } + } + + /// Queue a message for a task attempt. Replaces any existing queued message. + pub fn queue_message(&self, task_attempt_id: Uuid, data: DraftFollowUpData) -> QueuedMessage { + let queued = QueuedMessage { + task_attempt_id, + data, + queued_at: Utc::now(), + }; + self.queue.insert(task_attempt_id, queued.clone()); + queued + } + + /// Cancel/remove a queued message for a task attempt + pub fn cancel_queued(&self, task_attempt_id: Uuid) -> Option { + self.queue.remove(&task_attempt_id).map(|(_, v)| v) + } + + /// Get the queued message for a task attempt (if any) + pub fn get_queued(&self, task_attempt_id: Uuid) -> Option { + self.queue.get(&task_attempt_id).map(|r| r.clone()) + } + + /// Take (remove and return) the queued message for a task attempt. + /// Used by finalization flow to consume the queued message. + pub fn take_queued(&self, task_attempt_id: Uuid) -> Option { + self.queue.remove(&task_attempt_id).map(|(_, v)| v) + } + + /// Check if a task attempt has a queued message + pub fn has_queued(&self, task_attempt_id: Uuid) -> bool { + self.queue.contains_key(&task_attempt_id) + } + + /// Get queue status for frontend display + pub fn get_status(&self, task_attempt_id: Uuid) -> QueueStatus { + match self.get_queued(task_attempt_id) { + Some(msg) => QueueStatus::Queued { message: msg }, + None => QueueStatus::Empty, + } + } +} + +impl Default for QueuedMessageService { + fn default() -> Self { + Self::new() + } +} diff --git a/frontend/package.json b/frontend/package.json index 7f855722..6618ce14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,7 +62,6 @@ "lexical": "^0.36.2", "lodash": "^4.17.21", "lucide-react": "^0.539.0", - "markdown-to-jsx": "^7.7.13", "posthog-js": "^1.276.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx index 2a97fd24..3b0d4baf 100644 --- a/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx +++ b/frontend/src/components/NormalizedConversation/DisplayConversationEntry.tsx @@ -1,5 +1,5 @@ import { useTranslation } from 'react-i18next'; -import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { ActionType, NormalizedEntry, @@ -268,17 +268,26 @@ const CollapsibleEntry: React.FC<{ expansionKey: string; variant: CollapsibleVariant; contentClassName: string; -}> = ({ content, markdown, expansionKey, variant, contentClassName }) => { + taskAttemptId?: string; +}> = ({ + content, + markdown, + expansionKey, + variant, + contentClassName, + taskAttemptId, +}) => { const multiline = content.includes('\n'); const [expanded, toggle] = useExpandable(`entry:${expansionKey}`, false); const Inner = (
{markdown ? ( - ) : ( content @@ -290,10 +299,11 @@ const CollapsibleEntry: React.FC<{ const PreviewInner = (
{markdown ? ( - ) : ( firstLine @@ -356,11 +366,13 @@ const PlanPresentationCard: React.FC<{ expansionKey: string; defaultExpanded?: boolean; statusAppearance?: ToolStatusAppearance; + taskAttemptId?: string; }> = ({ plan, expansionKey, defaultExpanded = false, statusAppearance = 'default', + taskAttemptId, }) => { const { t } = useTranslation('common'); const [expanded, toggle] = useExpandable( @@ -406,10 +418,11 @@ const PlanPresentationCard: React.FC<{ {expanded && (
-
@@ -423,7 +436,8 @@ const ToolCallCard: React.FC<{ entry: NormalizedEntry | ProcessStartPayload; expansionKey: string; forceExpanded?: boolean; -}> = ({ entry, expansionKey, forceExpanded = false }) => { + taskAttemptId?: string; +}> = ({ entry, expansionKey, forceExpanded = false, taskAttemptId }) => { const { t } = useTranslation('common'); // Determine if this is a NormalizedEntry with tool_use @@ -549,8 +563,10 @@ const ToolCallCard: React.FC<{
{actionType.result?.type.type === 'markdown' && actionType.result.value && ( - )} {actionType.result?.type.type === 'json' && @@ -622,7 +638,11 @@ function DisplayConversationEntry({ if (isProcessStart(entry)) { return (
- +
); } @@ -664,9 +684,11 @@ function DisplayConversationEntry({ toolName: feedbackEntry.denied_tool, })}
-
@@ -711,6 +733,7 @@ function DisplayConversationEntry({ expansionKey={expansionKey} defaultExpanded={defaultExpanded} statusAppearance={statusAppearance} + taskAttemptId={taskAttempt?.id} /> ); } @@ -720,6 +743,7 @@ function DisplayConversationEntry({ entry={entry} expansionKey={expansionKey} forceExpanded={isPendingApproval} + taskAttemptId={taskAttempt?.id} /> ); })(); @@ -761,6 +785,7 @@ function DisplayConversationEntry({ expansionKey={expansionKey} variant={isSystem ? 'system' : 'error'} contentClassName={getContentClassName(entryType)} + taskAttemptId={taskAttempt?.id} /> ); @@ -793,10 +818,11 @@ function DisplayConversationEntry({
{shouldRenderMarkdown(entryType) ? ( - ) : isNormalizedEntry(entry) ? ( entry.content diff --git a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx index b0e7b63d..36357d61 100644 --- a/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx +++ b/frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx @@ -17,7 +17,7 @@ import { } from '@/components/ui/tooltip'; import { approvalsApi } from '@/lib/api'; import { Check, X } from 'lucide-react'; -import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { useHotkeysContext } from 'react-hotkeys-hook'; import { TabNavContext } from '@/contexts/TabNavigationContext'; @@ -130,7 +130,6 @@ function DenyReasonForm({ onChange, onCancel, onSubmit, - inputRef, projectId, }: { isResponding: boolean; @@ -138,21 +137,20 @@ function DenyReasonForm({ onChange: (v: string) => void; onCancel: () => void; onSubmit: () => void; - inputRef: React.RefObject; projectId?: string; }) { return ( -
- + -
+
+
+ -
- {showImageUpload && ( -
- imagesApi.uploadForTask(attempt.task_id, file)} - onDelete={imagesApi.delete} - onImageUploaded={(image) => { - handleImageUploaded(image); - setMessage((prev) => appendImageMarkdown(prev, image)); - }} - disabled={isSending || !!isFinalizing} - collapsible={false} - defaultExpanded={true} - /> -
- )} - {sendError && ( diff --git a/frontend/src/components/NormalizedConversation/UserMessage.tsx b/frontend/src/components/NormalizedConversation/UserMessage.tsx index fc81650a..26d86386 100644 --- a/frontend/src/components/NormalizedConversation/UserMessage.tsx +++ b/frontend/src/components/NormalizedConversation/UserMessage.tsx @@ -1,13 +1,10 @@ -import MarkdownRenderer from '@/components/ui/markdown-renderer'; -import { Button } from '@/components/ui/button'; -import { Pencil } from 'lucide-react'; -import { useEffect, useState } from 'react'; -import { useProcessRetry } from '@/hooks/useProcessRetry'; +import { useState } from 'react'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { TaskAttempt, BaseAgentCapability } from 'shared/types'; import { useUserSystem } from '@/components/ConfigProvider'; -import { useDraftStream } from '@/hooks/follow-up/useDraftStream'; -import { RetryEditorInline } from './RetryEditorInline'; import { useRetryUi } from '@/contexts/RetryUiContext'; +import { useAttemptExecution } from '@/hooks/useAttemptExecution'; +import { RetryEditorInline } from './RetryEditorInline'; const UserMessage = ({ content, @@ -19,11 +16,10 @@ const UserMessage = ({ taskAttempt?: TaskAttempt; }) => { const [isEditing, setIsEditing] = useState(false); - const retryHook = useProcessRetry(taskAttempt); const { capabilities } = useUserSystem(); - const attemptId = taskAttempt?.id; - const { retryDraft } = useDraftStream(attemptId); - const { activeRetryProcessId, isProcessGreyed } = useRetryUi(); + const { activeRetryProcessId, setActiveRetryProcessId, isProcessGreyed } = + useRetryUi(); + const { isAttemptRunning } = useAttemptExecution(taskAttempt?.id); const canFork = !!( taskAttempt?.executor && @@ -32,31 +28,16 @@ const UserMessage = ({ ) ); - // Enter retry mode: create retry draft; actual editor will render inline - const startRetry = async () => { + const startRetry = () => { if (!executionProcessId || !taskAttempt) return; setIsEditing(true); - retryHook?.startRetry(executionProcessId, content).catch(() => { - // rollback if server call fails - setIsEditing(false); - }); + setActiveRetryProcessId(executionProcessId); }; - // Exit editing state once draft disappears (sent/cancelled) - useEffect(() => { - if (!retryDraft?.retry_process_id) setIsEditing(false); - }, [retryDraft?.retry_process_id]); - - // On reload or when server provides a retry_draft for this process, show editor - useEffect(() => { - if ( - executionProcessId && - retryDraft?.retry_process_id && - retryDraft.retry_process_id === executionProcessId - ) { - setIsEditing(true); - } - }, [executionProcessId, retryDraft?.retry_process_id]); + const onCancelled = () => { + setIsEditing(false); + setActiveRetryProcessId(null); + }; const showRetryEditor = !!executionProcessId && @@ -67,48 +48,30 @@ const UserMessage = ({ isProcessGreyed(executionProcessId) && !showRetryEditor; - const retryState = executionProcessId - ? retryHook?.getRetryDisabledState(executionProcessId) - : { disabled: true, reason: 'Missing process id' }; - const disabled = !!retryState?.disabled; - const reason = retryState?.reason ?? undefined; - const editTitle = disabled && reason ? reason : 'Edit message'; + // Only show retry button when allowed (has process, can fork, not running) + const canRetry = executionProcessId && canFork && !isAttemptRunning; return (
-
-
- {showRetryEditor ? ( +
+
+ {showRetryEditor && taskAttempt ? ( { - setIsEditing(false); - }} + attempt={taskAttempt} + executionProcessId={executionProcessId} + initialContent={content} + onCancelled={onCancelled} /> ) : ( - )}
- {executionProcessId && canFork && !showRetryEditor && ( -
- -
- )}
); diff --git a/frontend/src/components/dialogs/tasks/RestoreLogsDialog.tsx b/frontend/src/components/dialogs/tasks/RestoreLogsDialog.tsx index e2ded7dd..da8e9948 100644 --- a/frontend/src/components/dialogs/tasks/RestoreLogsDialog.tsx +++ b/frontend/src/components/dialogs/tasks/RestoreLogsDialog.tsx @@ -1,33 +1,32 @@ -import { useState } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Dialog, DialogContent, - DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { AlertTriangle, GitCommit } from 'lucide-react'; +import { AlertTriangle, GitCommit, Loader2 } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { defineModal } from '@/lib/modals'; +import { useKeySubmitTask } from '@/keyboard/hooks'; +import { Scope } from '@/keyboard/registry'; +import { executionProcessesApi, commitsApi } from '@/lib/api'; +import { + shouldShowInLogs, + isCodingAgent, + PROCESS_RUN_REASONS, +} from '@/constants/processes'; +import type { BranchStatus, ExecutionProcess } from 'shared/types'; export interface RestoreLogsDialogProps { - targetSha: string | null; - targetSubject: string | null; - commitsToReset: number | null; - isLinear: boolean | null; - laterCount: number; - laterCoding: number; - laterSetup: number; - laterCleanup: number; - needGitReset: boolean; - canGitReset: boolean; - hasRisk: boolean; - uncommittedCount: number; - untrackedCount: number; - initialWorktreeResetOn: boolean; - initialForceReset: boolean; + attemptId: string; + executionProcessId: string; + branchStatus: BranchStatus | undefined; + processes: ExecutionProcess[] | undefined; + initialWorktreeResetOn?: boolean; + initialForceReset?: boolean; } export type RestoreLogsDialogResult = { @@ -38,31 +37,95 @@ export type RestoreLogsDialogResult = { const RestoreLogsDialogImpl = NiceModal.create( ({ - targetSha, - targetSubject, - commitsToReset, - isLinear, - laterCount, - laterCoding, - laterSetup, - laterCleanup, - needGitReset, - canGitReset, - hasRisk, - uncommittedCount, - untrackedCount, - initialWorktreeResetOn, - initialForceReset, + attemptId, + executionProcessId, + branchStatus, + processes, + initialWorktreeResetOn = true, + initialForceReset = false, }) => { const modal = useModal(); + const [isLoading, setIsLoading] = useState(true); const [worktreeResetOn, setWorktreeResetOn] = useState( initialWorktreeResetOn ); const [forceReset, setForceReset] = useState(initialForceReset); + // Fetched data + const [targetSha, setTargetSha] = useState(null); + const [targetSubject, setTargetSubject] = useState(null); + const [commitsToReset, setCommitsToReset] = useState(null); + const [isLinear, setIsLinear] = useState(null); + + // Fetch execution process and commit info + useEffect(() => { + let cancelled = false; + setIsLoading(true); + + (async () => { + try { + const proc = + await executionProcessesApi.getDetails(executionProcessId); + const sha = proc.before_head_commit || null; + if (cancelled) return; + setTargetSha(sha); + + if (sha) { + try { + const cmp = await commitsApi.compareToHead(attemptId, sha); + if (!cancelled) { + setTargetSubject(cmp.subject); + setCommitsToReset(cmp.is_linear ? cmp.ahead_from_head : null); + setIsLinear(cmp.is_linear); + } + } catch { + /* ignore commit info errors */ + } + } + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [attemptId, executionProcessId]); + + // Compute later processes from props + const { laterCount, laterCoding, laterSetup, laterCleanup } = + useMemo(() => { + const procs = (processes || []).filter( + (p) => !p.dropped && shouldShowInLogs(p.run_reason) + ); + const idx = procs.findIndex((p) => p.id === executionProcessId); + const later = idx >= 0 ? procs.slice(idx + 1) : []; + return { + laterCount: later.length, + laterCoding: later.filter((p) => isCodingAgent(p.run_reason)).length, + laterSetup: later.filter( + (p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT + ).length, + laterCleanup: later.filter( + (p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT + ).length, + }; + }, [processes, executionProcessId]); + + // Compute git reset state from branchStatus + const head = branchStatus?.head_oid || null; + const dirty = !!branchStatus?.has_uncommitted_changes; + const needGitReset = !!(targetSha && (targetSha !== head || dirty)); + const canGitReset = needGitReset && !dirty; + const hasRisk = dirty; + const uncommittedCount = branchStatus?.uncommitted_count ?? 0; + const untrackedCount = branchStatus?.untracked_count ?? 0; + const hasLater = laterCount > 0; const short = targetSha?.slice(0, 7); - // Note: confirm enabling logic handled in footer based on uncommitted changes + + const isConfirmDisabled = + isLoading || (hasRisk && worktreeResetOn && needGitReset && !forceReset); const handleConfirm = () => { modal.resolve({ @@ -84,6 +147,12 @@ const RestoreLogsDialogImpl = NiceModal.create( } }; + // CMD+Enter to confirm + useKeySubmitTask(handleConfirm, { + scope: Scope.DIALOG, + when: modal.visible && !isConfirmDisabled, + }); + return ( ( Confirm Retry - -
- {hasLater && ( -
- -
-

- History change -

- <> -

- Will delete this process - {laterCount > 0 && ( - <> - {' '} - and {laterCount} later process - {laterCount === 1 ? '' : 'es'} - - )}{' '} - from history. +

+ {isLoading ? ( +
+ +
+ ) : ( +
+ {hasLater && ( +
+ +
+

+ History change

-
    - {laterCoding > 0 && ( -
  • - {laterCoding} coding agent run - {laterCoding === 1 ? '' : 's'} -
  • - )} - {laterSetup + laterCleanup > 0 && ( -
  • - {laterSetup + laterCleanup} script process - {laterSetup + laterCleanup === 1 ? '' : 'es'} - {laterSetup > 0 && laterCleanup > 0 && ( - <> - {' '} - ({laterSetup} setup, {laterCleanup} cleanup) - - )} -
  • - )} -
- -

- This permanently alters history and cannot be undone. -

+ <> +

+ Will delete this process + {laterCount > 0 && ( + <> + {' '} + and {laterCount} later process + {laterCount === 1 ? '' : 'es'} + + )}{' '} + from history. +

+
    + {laterCoding > 0 && ( +
  • + {laterCoding} coding agent run + {laterCoding === 1 ? '' : 's'} +
  • + )} + {laterSetup + laterCleanup > 0 && ( +
  • + {laterSetup + laterCleanup} script process + {laterSetup + laterCleanup === 1 ? '' : 'es'} + {laterSetup > 0 && laterCleanup > 0 && ( + <> + {' '} + ({laterSetup} setup, {laterCleanup} cleanup) + + )} +
  • + )} +
+ +

+ This permanently alters history and cannot be undone. +

+
-
- )} + )} - {needGitReset && canGitReset && ( -
- -
-

Reset worktree

-
setWorktreeResetOn((v) => !v)} - > -
- {worktreeResetOn ? 'Enabled' : 'Disabled'} -
-
- - + > + +
+

Reset worktree

+
setWorktreeResetOn((v) => !v)} + > +
+ {worktreeResetOn ? 'Enabled' : 'Disabled'} +
+
+ + +
+ {worktreeResetOn && ( + <> +

+ Your worktree will be restored to this commit. +

+
+ + {short && ( + + {short} + + )} + {targetSubject && ( + + {targetSubject} + + )} +
+ {((isLinear && + commitsToReset !== null && + commitsToReset > 0) || + uncommittedCount > 0 || + untrackedCount > 0) && ( +
    + {isLinear && + commitsToReset !== null && + commitsToReset > 0 && ( +
  • + Roll back {commitsToReset} commit + {commitsToReset === 1 ? '' : 's'} from + current HEAD. +
  • + )} + {uncommittedCount > 0 && ( +
  • + Discard {uncommittedCount} uncommitted + change + {uncommittedCount === 1 ? '' : 's'}. +
  • + )} + {untrackedCount > 0 && ( +
  • + {untrackedCount} untracked file + {untrackedCount === 1 ? '' : 's'} present + (not affected by reset). +
  • + )} +
+ )} + + )}
- {worktreeResetOn && ( - <> -

- Your worktree will be restored to this commit. -

-
- - {short && ( +
+ )} + + {needGitReset && !canGitReset && ( +
+ +
+

+ Reset worktree +

+
{ + setWorktreeResetOn((on) => { + if (forceReset) return !on; // free toggle when forced + // Without force, only allow explicitly disabling reset + return false; + }); + }} + > +
+ {forceReset + ? worktreeResetOn + ? 'Enabled' + : 'Disabled' + : 'Disabled (uncommitted changes detected)'} +
+
+ + +
+
+
{ + setForceReset((v) => { + const next = !v; + if (next) setWorktreeResetOn(true); + return next; + }); + }} + > +
+ Force reset (discard uncommitted changes) +
+
+ + +
+
+

+ {forceReset + ? 'Uncommitted changes will be discarded.' + : 'Uncommitted changes present. Turn on Force reset or commit/stash to proceed.'} +

+ {short && ( + <> +

+ Your worktree will be restored to this commit. +

+
+ {short} - )} - {targetSubject && ( - - {targetSubject} - - )} -
- {((isLinear && - commitsToReset !== null && - commitsToReset > 0) || - uncommittedCount > 0 || - untrackedCount > 0) && ( -
    - {isLinear && - commitsToReset !== null && - commitsToReset > 0 && ( -
  • - Roll back {commitsToReset} commit - {commitsToReset === 1 ? '' : 's'} from - current HEAD. -
  • - )} - {uncommittedCount > 0 && ( -
  • - Discard {uncommittedCount} uncommitted change - {uncommittedCount === 1 ? '' : 's'}. -
  • + {targetSubject && ( + + {targetSubject} + )} - {untrackedCount > 0 && ( -
  • - {untrackedCount} untracked file - {untrackedCount === 1 ? '' : 's'} present (not - affected by reset). -
  • - )} -
- )} - - )} -
-
- )} - - {needGitReset && !canGitReset && ( -
- -
-

- Reset worktree -

-
{ - setWorktreeResetOn((on) => { - if (forceReset) return !on; // free toggle when forced - // Without force, only allow explicitly disabling reset - return false; - }); - }} - > -
- {forceReset - ? worktreeResetOn - ? 'Enabled' - : 'Disabled' - : 'Disabled (uncommitted changes detected)'} -
-
- - -
+
+ + )}
-
{ - setForceReset((v) => { - const next = !v; - if (next) setWorktreeResetOn(true); - return next; - }); - }} - > -
- Force reset (discard uncommitted changes) -
-
- - -
-
-

- {forceReset - ? 'Uncommitted changes will be discarded.' - : 'Uncommitted changes present. Turn on Force reset or commit/stash to proceed.'} -

- {short && ( - <> -

- Your worktree will be restored to this commit. -

-
- - - {short} - - {targetSubject && ( - - {targetSubject} - - )} -
- - )}
-
- )} -
- + )} +
+ )} +
- -
+
+
); } diff --git a/frontend/src/components/panels/SharedTaskPanel.tsx b/frontend/src/components/panels/SharedTaskPanel.tsx index b876c4c0..5b64e8ec 100644 --- a/frontend/src/components/panels/SharedTaskPanel.tsx +++ b/frontend/src/components/panels/SharedTaskPanel.tsx @@ -1,6 +1,6 @@ import type { SharedTaskRecord } from '@/hooks/useProjectTasks'; import { NewCardContent } from '@/components/ui/new-card'; -import MarkdownRenderer from '@/components/ui/markdown-renderer'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; interface SharedTaskPanelProps { task: SharedTaskRecord; @@ -18,7 +18,7 @@ const SharedTaskPanel = ({ task }: SharedTaskPanelProps) => {
{task.description ? ( - + ) : null}
diff --git a/frontend/src/components/panels/TaskAttemptPanel.tsx b/frontend/src/components/panels/TaskAttemptPanel.tsx index 2f19bd68..125928bc 100644 --- a/frontend/src/components/panels/TaskAttemptPanel.tsx +++ b/frontend/src/components/panels/TaskAttemptPanel.tsx @@ -32,11 +32,7 @@ const TaskAttemptPanel = ({ ), followUp: ( - {}} - /> + ), })} diff --git a/frontend/src/components/panels/TaskPanel.tsx b/frontend/src/components/panels/TaskPanel.tsx index 7226088d..f70216e8 100644 --- a/frontend/src/components/panels/TaskPanel.tsx +++ b/frontend/src/components/panels/TaskPanel.tsx @@ -9,7 +9,7 @@ import { NewCardContent } from '../ui/new-card'; import { Button } from '../ui/button'; import { PlusIcon } from 'lucide-react'; import { CreateAttemptDialog } from '@/components/dialogs/tasks/CreateAttemptDialog'; -import MarkdownRenderer from '@/components/ui/markdown-renderer'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { DataTable, type ColumnDef } from '@/components/ui/table'; interface TaskPanelProps { @@ -102,9 +102,9 @@ const TaskPanel = ({ task }: TaskPanelProps) => {
- + {descriptionContent && ( - + )}
diff --git a/frontend/src/components/tasks/TaskFollowUpSection.tsx b/frontend/src/components/tasks/TaskFollowUpSection.tsx index 815b69f5..762d4b8f 100644 --- a/frontend/src/components/tasks/TaskFollowUpSection.tsx +++ b/frontend/src/components/tasks/TaskFollowUpSection.tsx @@ -1,20 +1,17 @@ import { - ImageIcon, Loader2, Send, StopCircle, AlertCircle, + Clock, + X, + Paperclip, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { - ImageUploadSection, - type ImageUploadSectionHandle, -} from '@/components/ui/image-upload-section'; import { Alert, AlertDescription } from '@/components/ui/alert'; // import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; -import { imagesApi } from '@/lib/api.ts'; -import type { TaskWithAttemptStatus } from 'shared/types'; +import { ScratchType, type TaskWithAttemptStatus } from 'shared/types'; import { useBranchStatus } from '@/hooks'; import { useAttemptExecution } from '@/hooks/useAttemptExecution'; import { useUserSystem } from '@/components/ConfigProvider'; @@ -23,38 +20,41 @@ import { cn } from '@/lib/utils'; import { useReview } from '@/contexts/ReviewProvider'; import { useClickedElements } from '@/contexts/ClickedElementsProvider'; import { useEntries } from '@/contexts/EntriesContext'; -import { useKeyCycleVariant, useKeySubmitFollowUp, Scope } from '@/keyboard'; +import { useKeySubmitFollowUp, Scope } from '@/keyboard'; import { useHotkeysContext } from 'react-hotkeys-hook'; +import { useProject } from '@/contexts/ProjectContext'; // import { VariantSelector } from '@/components/tasks/VariantSelector'; -import { FollowUpStatusRow } from '@/components/tasks/FollowUpStatusRow'; import { useAttemptBranch } from '@/hooks/useAttemptBranch'; import { FollowUpConflictSection } from '@/components/tasks/follow-up/FollowUpConflictSection'; import { ClickedElementsBanner } from '@/components/tasks/ClickedElementsBanner'; -import { FollowUpEditorCard } from '@/components/tasks/follow-up/FollowUpEditorCard'; -import { useDraftStream } from '@/hooks/follow-up/useDraftStream'; +import WYSIWYGEditor from '@/components/ui/wysiwyg'; import { useRetryUi } from '@/contexts/RetryUiContext'; -import { useDraftEditor } from '@/hooks/follow-up/useDraftEditor'; -import { useDraftAutosave } from '@/hooks/follow-up/useDraftAutosave'; -import { useDraftQueue } from '@/hooks/follow-up/useDraftQueue'; -import { useFollowUpSend } from '@/hooks/follow-up/useFollowUpSend'; -import { useDefaultVariant } from '@/hooks/follow-up/useDefaultVariant'; +import { useFollowUpSend } from '@/hooks/useFollowUpSend'; +import { useVariant } from '@/hooks/useVariant'; +import type { + DraftFollowUpData, + ExecutorAction, + ExecutorProfileId, +} from 'shared/types'; import { buildResolveConflictsInstructions } from '@/lib/conflicts'; -import { appendImageMarkdown } from '@/utils/markdownImages'; import { useTranslation } from 'react-i18next'; +import { useScratch } from '@/hooks/useScratch'; +import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; +import { useQueueStatus } from '@/hooks/useQueueStatus'; +import { imagesApi } from '@/lib/api'; interface TaskFollowUpSectionProps { task: TaskWithAttemptStatus; selectedAttemptId?: string; - jumpToLogsTab: () => void; } export function TaskFollowUpSection({ task, selectedAttemptId, - jumpToLogsTab, }: TaskFollowUpSectionProps) { const { t } = useTranslation('tasks'); + const { projectId } = useProject(); const { isAttemptRunning, stopExecution, isStopping, processes } = useAttemptExecution(selectedAttemptId, task.id); @@ -97,83 +97,179 @@ export function TaskFollowUpSection({ branchStatus?.conflict_op, ]); - // Draft stream and synchronization - const { draft, isDraftLoaded } = useDraftStream(selectedAttemptId); - - // Editor state + // Editor state (persisted via scratch) const { - message: followUpMessage, - setMessage: setFollowUpMessage, - images, - setImages, - newlyUploadedImageIds, - handleImageUploaded, - clearImagesAndUploads, - } = useDraftEditor({ - draft, - taskId: task.id, - }); + scratch, + updateScratch, + isLoading: isScratchLoading, + } = useScratch(ScratchType.DRAFT_FOLLOW_UP, selectedAttemptId ?? ''); - // Presentation-only: show/hide image upload panel - const [showImageUpload, setShowImageUpload] = useState(false); - const imageUploadRef = useRef(null); - - const handlePasteImages = useCallback((files: File[]) => { - if (files.length === 0) return; - setShowImageUpload(true); - void imageUploadRef.current?.addFiles(files); - }, []); + // Derive the message and variant from scratch + const scratchData: DraftFollowUpData | undefined = + scratch?.payload?.type === 'DRAFT_FOLLOW_UP' + ? scratch.payload.data + : undefined; // Track whether the follow-up textarea is focused const [isTextareaFocused, setIsTextareaFocused] = useState(false); - // Variant selection (with keyboard cycling) - const { selectedVariant, setSelectedVariant, currentProfile } = - useDefaultVariant({ processes, profiles: profiles ?? null }); + // Local message state for immediate UI feedback (before debounced save) + const [localMessage, setLocalMessage] = useState(''); - // Cycle to the next variant when Shift+Tab is pressed - const cycleVariant = useCallback(() => { - if (!currentProfile) return; - const variants = Object.keys(currentProfile); // Include DEFAULT - if (variants.length === 0) return; + // Variant selection - derive default from latest process + const latestProfileId = useMemo(() => { + if (!processes?.length) return null; - // Treat null as "DEFAULT" for finding current position - const currentVariantForLookup = selectedVariant ?? 'DEFAULT'; - const currentIndex = variants.indexOf(currentVariantForLookup); - const nextIndex = (currentIndex + 1) % variants.length; - const nextVariant = variants[nextIndex]; + const extractProfile = ( + action: ExecutorAction | null + ): ExecutorProfileId | null => { + let curr: ExecutorAction | null = action; + while (curr) { + const typ = curr.typ; + switch (typ.type) { + case 'CodingAgentInitialRequest': + case 'CodingAgentFollowUpRequest': + return typ.executor_profile_id; + case 'ScriptRequest': + curr = curr.next_action; + continue; + } + } + return null; + }; + return ( + processes + .slice() + .reverse() + .map((p) => extractProfile(p.executor_action ?? null)) + .find((pid) => pid !== null) ?? null + ); + }, [processes]); - // Keep using null to represent DEFAULT (backend expects it) - // But for display/cycling purposes, treat DEFAULT as a real option - setSelectedVariant(nextVariant === 'DEFAULT' ? null : nextVariant); - }, [currentProfile, selectedVariant, setSelectedVariant]); + const processVariant = latestProfileId?.variant ?? null; - // Queue management (including derived lock flag) - const { onQueue, onUnqueue } = useDraftQueue({ - attemptId: selectedAttemptId, - draft, - message: followUpMessage, - selectedVariant, - images, - }); + const currentProfile = useMemo(() => { + if (!latestProfileId) return null; + return profiles?.[latestProfileId.executor] ?? null; + }, [latestProfileId, profiles]); - // Presentation-only queue state - const [isQueuing, setIsQueuing] = useState(false); - const [isUnqueuing, setIsUnqueuing] = useState(false); - // Local queued state override after server action completes; null = rely on server - const [queuedOptimistic, setQueuedOptimistic] = useState( - null + // Variant selection with priority: user selection > scratch > process + const { selectedVariant, setSelectedVariant: setVariantFromHook } = + useVariant({ + processVariant, + scratchVariant: scratchData?.variant, + }); + + // Ref to track current variant for use in message save callback + const variantRef = useRef(selectedVariant); + useEffect(() => { + variantRef.current = selectedVariant; + }, [selectedVariant]); + + // Refs to stabilize callbacks - avoid re-creating callbacks when these values change + const scratchRef = useRef(scratch); + useEffect(() => { + scratchRef.current = scratch; + }, [scratch]); + + // Save scratch helper (used for both message and variant changes) + // Uses scratchRef to avoid callback invalidation when scratch updates + const saveToScratch = useCallback( + async (message: string, variant: string | null) => { + if (!selectedAttemptId) return; + // Don't create empty scratch entries - only save if there's actual content, + // a variant is selected, or scratch already exists (to allow clearing a draft) + if (!message.trim() && !variant && !scratchRef.current) return; + try { + await updateScratch({ + payload: { + type: 'DRAFT_FOLLOW_UP', + data: { message, variant }, + }, + }); + } catch (e) { + console.error('Failed to save follow-up draft', e); + } + }, + [selectedAttemptId, updateScratch] ); - // Server + presentation derived flags (computed early so they are usable below) - const isQueued = !!draft?.queued; - const displayQueued = queuedOptimistic ?? isQueued; + // Wrapper to update variant and save to scratch immediately + const setSelectedVariant = useCallback( + (variant: string | null) => { + setVariantFromHook(variant); + // Save immediately when user changes variant + saveToScratch(localMessage, variant); + }, + [setVariantFromHook, saveToScratch, localMessage] + ); + + // Debounced save for message changes (uses current variant from ref) + const { debounced: setFollowUpMessage, cancel: cancelDebouncedSave } = + useDebouncedCallback( + useCallback( + (value: string) => saveToScratch(value, variantRef.current), + [saveToScratch] + ), + 500 + ); + + // Sync local message from scratch when it loads + useEffect(() => { + if (isScratchLoading) return; + setLocalMessage(scratchData?.message ?? ''); + }, [isScratchLoading, scratchData?.message]); // During retry, follow-up box is greyed/disabled (not hidden) // Use RetryUi context so optimistic retry immediately disables this box const { activeRetryProcessId } = useRetryUi(); const isRetryActive = !!activeRetryProcessId; + // Queue status for queuing follow-up messages while agent is running + const { + isQueued, + queuedMessage, + isLoading: isQueueLoading, + queueMessage, + cancelQueue, + refresh: refreshQueueStatus, + } = useQueueStatus(selectedAttemptId); + + // Track previous process count to detect new processes + const prevProcessCountRef = useRef(processes.length); + + // Refresh queue status when execution stops OR when a new process starts + useEffect(() => { + const prevCount = prevProcessCountRef.current; + prevProcessCountRef.current = processes.length; + + if (!selectedAttemptId) return; + + // Refresh when execution stops + if (!isAttemptRunning) { + refreshQueueStatus(); + return; + } + + // Refresh when a new process starts (could be queued message consumption or follow-up) + if (processes.length > prevCount) { + refreshQueueStatus(); + // Re-sync local message from current scratch state + // If scratch was deleted, scratchData will be undefined, so localMessage becomes '' + setLocalMessage(scratchData?.message ?? ''); + } + }, [ + isAttemptRunning, + selectedAttemptId, + processes.length, + refreshQueueStatus, + scratchData?.message, + ]); + + // When queued, display the queued message content so user can edit it + const displayMessage = + isQueued && queuedMessage ? queuedMessage.data.message : localMessage; + // Check if there's a pending approval - users shouldn't be able to type during approvals const { entries } = useEntries(); const hasPendingApproval = useMemo(() => { @@ -187,41 +283,24 @@ export function TaskFollowUpSection({ }); }, [entries]); - // Autosave draft when editing - const { isSaving, saveStatus } = useDraftAutosave({ - attemptId: selectedAttemptId, - serverDraft: draft, - current: { - prompt: followUpMessage, - variant: selectedVariant, - image_ids: images.map((img) => img.id), - }, - isQueuedUI: displayQueued, - isDraftSending: !!draft?.sending, - isQueuing: isQueuing, - isUnqueuing: isUnqueuing, - }); - // Send follow-up action const { isSendingFollowUp, followUpError, setFollowUpError, onSendFollowUp } = useFollowUpSend({ attemptId: selectedAttemptId, - message: followUpMessage, + message: localMessage, conflictMarkdown: conflictResolutionInstructions, reviewMarkdown, clickedMarkdown, selectedVariant, - images, - newlyUploadedImageIds, clearComments, clearClickedElements, - jumpToLogsTab, - onAfterSendCleanup: clearImagesAndUploads, - setMessage: setFollowUpMessage, + onAfterSendCleanup: () => { + cancelDebouncedSave(); // Cancel any pending debounced save to avoid race condition + setLocalMessage(''); // Clear local state immediately + // Scratch deletion is handled by the backend when the queued message is consumed + }, }); - // Profile/variant derived from processes only (see useDefaultVariant) - // Separate logic for when textarea should be disabled vs when send button should be disabled const canTypeFollowUp = useMemo(() => { if (!selectedAttemptId || processes.length === 0 || isSendingFollowUp) { @@ -240,6 +319,7 @@ export function TaskFollowUpSection({ if (isRetryActive) return false; // disable typing while retry editor is active if (hasPendingApproval) return false; // disable typing during approval + // Note: isQueued no longer blocks typing - editing auto-cancels the queue return true; }, [ selectedAttemptId, @@ -260,65 +340,185 @@ export function TaskFollowUpSection({ conflictResolutionInstructions || reviewMarkdown || clickedMarkdown || - followUpMessage.trim() + localMessage.trim() ); }, [ canTypeFollowUp, conflictResolutionInstructions, reviewMarkdown, clickedMarkdown, - followUpMessage, + localMessage, ]); - // currentProfile is provided by useDefaultVariant + const isEditable = !isRetryActive && !hasPendingApproval; - const isDraftLocked = - displayQueued || isQueuing || isUnqueuing || !!draft?.sending; - const isEditable = - isDraftLoaded && !isDraftLocked && !isRetryActive && !hasPendingApproval; + // Handler to queue the current message for execution after agent finishes + const handleQueueMessage = useCallback(async () => { + if ( + !localMessage.trim() && + !conflictResolutionInstructions && + !reviewMarkdown && + !clickedMarkdown + ) { + return; + } - // Keyboard shortcut handler - unified submit (send or queue depending on state) + // Cancel any pending debounced save and save immediately before queueing + // This prevents the race condition where the debounce fires after queueing + cancelDebouncedSave(); + await saveToScratch(localMessage, selectedVariant); + + // Combine all the content that would be sent (same as follow-up send) + const parts = [ + conflictResolutionInstructions, + clickedMarkdown, + reviewMarkdown, + localMessage, + ].filter(Boolean); + const combinedMessage = parts.join('\n\n'); + await queueMessage(combinedMessage, selectedVariant); + }, [ + localMessage, + conflictResolutionInstructions, + reviewMarkdown, + clickedMarkdown, + selectedVariant, + queueMessage, + cancelDebouncedSave, + saveToScratch, + ]); + + // Keyboard shortcut handler - send follow-up or queue depending on state const handleSubmitShortcut = useCallback( - async (e?: KeyboardEvent) => { + (e?: KeyboardEvent) => { e?.preventDefault(); - - // When attempt is running, queue or unqueue if (isAttemptRunning) { - if (displayQueued) { - setIsUnqueuing(true); - try { - const ok = await onUnqueue(); - if (ok) setQueuedOptimistic(false); - } finally { - setIsUnqueuing(false); - } - } else { - setIsQueuing(true); - try { - const ok = await onQueue(); - if (ok) setQueuedOptimistic(true); - } finally { - setIsQueuing(false); - } + // When running, CMD+Enter queues the message (if not already queued) + if (!isQueued) { + handleQueueMessage(); } } else { - // When attempt is idle, send immediately onSendFollowUp(); } }, - [isAttemptRunning, displayQueued, onQueue, onUnqueue, onSendFollowUp] + [isAttemptRunning, isQueued, handleQueueMessage, onSendFollowUp] + ); + + // Ref to access setFollowUpMessage without adding it as a dependency + const setFollowUpMessageRef = useRef(setFollowUpMessage); + useEffect(() => { + setFollowUpMessageRef.current = setFollowUpMessage; + }, [setFollowUpMessage]); + + // Ref for followUpError to use in stable onChange handler + const followUpErrorRef = useRef(followUpError); + useEffect(() => { + followUpErrorRef.current = followUpError; + }, [followUpError]); + + // Refs for queue state to use in stable onChange handler + const isQueuedRef = useRef(isQueued); + useEffect(() => { + isQueuedRef.current = isQueued; + }, [isQueued]); + + const cancelQueueRef = useRef(cancelQueue); + useEffect(() => { + cancelQueueRef.current = cancelQueue; + }, [cancelQueue]); + + const queuedMessageRef = useRef(queuedMessage); + useEffect(() => { + queuedMessageRef.current = queuedMessage; + }, [queuedMessage]); + + // Handle image paste - upload to container and insert markdown + const handlePasteFiles = useCallback( + async (files: File[]) => { + if (!selectedAttemptId) return; + + for (const file of files) { + try { + const response = await imagesApi.uploadForAttempt( + selectedAttemptId, + file + ); + // Append markdown image to current message + const imageMarkdown = `![${response.original_name}](${response.file_path})`; + + // If queued, cancel queue and use queued message as base (same as editor change behavior) + if (isQueuedRef.current && queuedMessageRef.current) { + cancelQueueRef.current(); + const base = queuedMessageRef.current.data.message; + const newMessage = base + ? `${base}\n\n${imageMarkdown}` + : imageMarkdown; + setLocalMessage(newMessage); + setFollowUpMessageRef.current(newMessage); + } else { + setLocalMessage((prev) => { + const newMessage = prev + ? `${prev}\n\n${imageMarkdown}` + : imageMarkdown; + setFollowUpMessageRef.current(newMessage); // Debounced save to scratch + return newMessage; + }); + } + } catch (error) { + console.error('Failed to upload image:', error); + } + } + }, + [selectedAttemptId] + ); + + // Attachment button - file input ref and handlers + const fileInputRef = useRef(null); + const handleAttachClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []).filter((f) => + f.type.startsWith('image/') + ); + if (files.length > 0) { + handlePasteFiles(files); + } + // Reset input so same file can be selected again + e.target.value = ''; + }, + [handlePasteFiles] + ); + + // Stable onChange handler for WYSIWYGEditor + const handleEditorChange = useCallback( + (value: string) => { + // Auto-cancel queue when user starts editing + if (isQueuedRef.current) { + cancelQueueRef.current(); + } + setLocalMessage(value); // Immediate update for UI responsiveness + setFollowUpMessageRef.current(value); // Debounced save to scratch + if (followUpErrorRef.current) setFollowUpError(null); + }, + [setFollowUpError] + ); + + // Memoize placeholder to avoid re-renders + const hasExtraContext = !!(reviewMarkdown || conflictResolutionInstructions); + const editorPlaceholder = useMemo( + () => + hasExtraContext + ? '(Optional) Add additional instructions... Type @ to insert tags or search files.' + : 'Continue working on this task attempt... Type @ to insert tags or search files.', + [hasExtraContext] ); // Register keyboard shortcuts - useKeyCycleVariant(cycleVariant, { - scope: Scope.FOLLOW_UP, - enableOnFormTags: ['textarea', 'TEXTAREA'], - preventDefault: true, - }); - useKeySubmitFollowUp(handleSubmitShortcut, { scope: Scope.FOLLOW_UP_READY, enableOnFormTags: ['textarea', 'TEXTAREA'], - when: canSendFollowUp && !isDraftLocked && !isQueuing && !isUnqueuing, + when: canSendFollowUp && isEditable, }); // Enable FOLLOW_UP scope when textarea is focused AND editable @@ -333,14 +533,9 @@ export function TaskFollowUpSection({ }; }, [isEditable, isTextareaFocused, enableScope, disableScope]); - // Enable FOLLOW_UP_READY scope when ready to send/queue + // Enable FOLLOW_UP_READY scope when ready to send useEffect(() => { - const isReady = - isTextareaFocused && - isEditable && - isDraftLoaded && - !isSendingFollowUp && - !isRetryActive; + const isReady = isTextareaFocused && isEditable; if (isReady) { enableScope(Scope.FOLLOW_UP_READY); @@ -350,15 +545,7 @@ export function TaskFollowUpSection({ return () => { disableScope(Scope.FOLLOW_UP_READY); }; - }, [ - isTextareaFocused, - isEditable, - isDraftLoaded, - isSendingFollowUp, - isRetryActive, - enableScope, - disableScope, - ]); + }, [isTextareaFocused, isEditable, enableScope, disableScope]); // When a process completes (e.g., agent resolved conflicts), refresh branch status promptly const prevRunningRef = useRef(isAttemptRunning); @@ -375,174 +562,176 @@ export function TaskFollowUpSection({ refetchAttemptBranch, ]); - // When server indicates sending started, clear draft and images; hide upload panel - const prevSendingRef = useRef(!!draft?.sending); - useEffect(() => { - const now = !!draft?.sending; - if (now && !prevSendingRef.current) { - if (followUpMessage !== '') setFollowUpMessage(''); - if (images.length > 0 || newlyUploadedImageIds.length > 0) { - clearImagesAndUploads(); - } - if (showImageUpload) setShowImageUpload(false); - if (queuedOptimistic !== null) setQueuedOptimistic(null); - } - prevSendingRef.current = now; - }, [ - draft?.sending, - followUpMessage, - setFollowUpMessage, - images.length, - newlyUploadedImageIds.length, - clearImagesAndUploads, - showImageUpload, - queuedOptimistic, - ]); + if (!selectedAttemptId) return null; - // On server queued state change, drop optimistic override and stop spinners accordingly - useEffect(() => { - setQueuedOptimistic(null); - if (isQueued) { - if (isQueuing) setIsQueuing(false); - } else { - if (isUnqueuing) setIsUnqueuing(false); - } - }, [isQueued, isQueuing, isUnqueuing]); + if (isScratchLoading) { + return ( +
+ +
+ ); + } return ( - selectedAttemptId && ( -
- {/* Scrollable content area */} -
+
+ {/* Scrollable content area */} +
+
+ {followUpError && ( + + + {followUpError} + + )}
- {followUpError && ( - - - {followUpError} - - )} -
-
- imagesApi.uploadForTask(task.id, file)} - onDelete={imagesApi.delete} - onImageUploaded={(image) => { - handleImageUploaded(image); - setFollowUpMessage((prev) => - appendImageMarkdown(prev, image) - ); - }} - disabled={!isEditable} - collapsible={false} - defaultExpanded={true} - /> -
- - {/* Review comments preview */} - {reviewMarkdown && ( -
-
- {reviewMarkdown} -
+ {/* Review comments preview */} + {reviewMarkdown && ( +
+
+ {reviewMarkdown}
- )} - - {/* Conflict notice and actions (optional UI) */} - {branchStatus && ( - - )} - - {/* Clicked elements notice and actions */} - - -
- { - setFollowUpMessage(value); - if (followUpError) setFollowUpError(null); - }} - disabled={!isEditable} - showLoadingOverlay={isUnqueuing || !isDraftLoaded} - onPasteFiles={handlePasteImages} - onFocusChange={setIsTextareaFocused} - /> -
+ )} + + {/* Conflict notice and actions (optional UI) */} + {branchStatus && ( + + )} + + {/* Clicked elements notice and actions */} + + + {/* Queued message indicator */} + {isQueued && queuedMessage && ( +
+ +
+ {t( + 'followUp.queuedMessage', + 'Message queued - will execute when current run finishes' + )} +
+
+ )} + +
setIsTextareaFocused(true)} + onBlur={(e) => { + // Only blur if focus is leaving the container entirely + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsTextareaFocused(false); + } + }} + > +
+
- {/* Always-visible action bar */} -
-
-
- {/* Image button */} - + {/* Always-visible action bar */} +
+
+
+ +
- -
+ {/* Hidden file input for attachment - always present */} + - {isAttemptRunning ? ( + {/* Attach button - always visible */} + + + {isAttemptRunning ? ( +
+ {/* Queue/Cancel Queue button when running */} + {isQueued ? ( + + ) : ( + + )} - ) : ( -
- {comments.length > 0 && ( - - )} +
+ ) : ( +
+ {comments.length > 0 && ( - {isQueued && ( - + )} +
- )} - {isAttemptRunning && ( -
- -
- )} -
+ +
+ )}
- ) +
); } diff --git a/frontend/src/components/tasks/VariantSelector.tsx b/frontend/src/components/tasks/VariantSelector.tsx index 648a94de..0d6fe123 100644 --- a/frontend/src/components/tasks/VariantSelector.tsx +++ b/frontend/src/components/tasks/VariantSelector.tsx @@ -1,5 +1,5 @@ import { memo, forwardRef, useEffect, useState } from 'react'; -import { ChevronDown } from 'lucide-react'; +import { ChevronDown, Settings2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -59,12 +59,13 @@ const VariantSelectorInner = forwardRef( variant="secondary" size="sm" className={cn( - 'w-18 md:w-24 px-2 flex items-center justify-between transition-all', + 'px-2 flex items-center justify-between transition-all', isAnimating && 'scale-105 bg-accent', className )} disabled={disabled} > + {selectedVariant || 'DEFAULT'} diff --git a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx b/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx deleted file mode 100644 index 5b955610..00000000 --- a/frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Loader2 } from 'lucide-react'; -import { FileSearchTextarea } from '@/components/ui/file-search-textarea'; -import { cn } from '@/lib/utils'; -import { useProject } from '@/contexts/ProjectContext'; -import { useCallback } from 'react'; - -type Props = { - placeholder: string; - value: string; - onChange: (v: string) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - disabled: boolean; - // Loading overlay - showLoadingOverlay: boolean; - onPasteFiles?: (files: File[]) => void; - textareaClassName?: string; - onFocusChange?: (isFocused: boolean) => void; -}; - -export function FollowUpEditorCard({ - placeholder, - value, - onChange, - onKeyDown, - disabled, - showLoadingOverlay, - onPasteFiles, - textareaClassName, - onFocusChange, -}: Props) { - const { projectId } = useProject(); - - const handleFocus = useCallback(() => { - onFocusChange?.(true); - }, [onFocusChange]); - - const handleBlur = useCallback(() => { - onFocusChange?.(false); - }, [onFocusChange]); - - return ( -
- - {showLoadingOverlay && ( -
- -
- )} -
- ); -} diff --git a/frontend/src/components/ui/file-search-textarea.tsx b/frontend/src/components/ui/file-search-textarea.tsx deleted file mode 100644 index 6dfc48c7..00000000 --- a/frontend/src/components/ui/file-search-textarea.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import { - useEffect, - useRef, - useState, - forwardRef, - useLayoutEffect, - useCallback, -} from 'react'; -import { createPortal } from 'react-dom'; -import { AutoExpandingTextarea } from '@/components/ui/auto-expanding-textarea'; -import { projectsApi, tagsApi } from '@/lib/api'; -import { Tag as TagIcon, FileText } from 'lucide-react'; -import { getCaretClientRect } from '@/lib/caretPosition'; - -import type { SearchResult, Tag } from 'shared/types'; - -const DROPDOWN_MIN_WIDTH = 320; -const DROPDOWN_MAX_HEIGHT = 320; -const DROPDOWN_MIN_HEIGHT = 120; -const DROPDOWN_VIEWPORT_PADDING = 16; -const DROPDOWN_VIEWPORT_PADDING_TOTAL = DROPDOWN_VIEWPORT_PADDING * 2; -const DROPDOWN_GAP = 4; - -interface FileSearchResult extends SearchResult { - name: string; -} - -// Unified result type for both tags and files -interface SearchResultItem { - type: 'tag' | 'file'; - // For tags - tag?: Tag; - // For files - file?: FileSearchResult; -} - -interface FileSearchTextareaProps { - value: string; - onChange: (value: string) => void; - placeholder?: string; - rows?: number; - disabled?: boolean; - className?: string; - projectId?: string; - onKeyDown?: (e: React.KeyboardEvent) => void; - maxRows?: number; - onPasteFiles?: (files: File[]) => void; - onFocus?: (e: React.FocusEvent) => void; - onBlur?: (e: React.FocusEvent) => void; - disableScroll?: boolean; -} - -export const FileSearchTextarea = forwardRef< - HTMLTextAreaElement, - FileSearchTextareaProps ->(function FileSearchTextarea( - { - value, - onChange, - placeholder, - rows = 3, - disabled = false, - className, - projectId, - onKeyDown, - maxRows = 10, - onPasteFiles, - onFocus, - onBlur, - disableScroll = false, - }, - ref -) { - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [showDropdown, setShowDropdown] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(-1); - - const [atSymbolPosition, setAtSymbolPosition] = useState(-1); - const [isLoading, setIsLoading] = useState(false); - - const internalRef = useRef(null); - const textareaRef = - (ref as React.RefObject) || internalRef; - const dropdownRef = useRef(null); - - // Search for both tags and files when query changes - useEffect(() => { - // No @ context, hide dropdown - if (atSymbolPosition === -1) { - setSearchResults([]); - setShowDropdown(false); - return; - } - - // Normal case: search both tags and files with query - const searchBoth = async () => { - setIsLoading(true); - - try { - const results: SearchResultItem[] = []; - - // Fetch all tags and filter client-side - const tags = await tagsApi.list(); - const filteredTags = tags.filter((tag) => - tag.tag_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); - results.push( - ...filteredTags.map((tag) => ({ type: 'tag' as const, tag })) - ); - - // Fetch files (if projectId is available and query has content) - if (projectId && searchQuery.length > 0) { - const fileResults = await projectsApi.searchFiles( - projectId, - searchQuery - ); - const fileSearchResults: FileSearchResult[] = fileResults.map( - (item) => ({ - ...item, - name: item.path.split('/').pop() || item.path, - }) - ); - results.push( - ...fileSearchResults.map((file) => ({ - type: 'file' as const, - file, - })) - ); - } - - setSearchResults(results); - setShowDropdown(results.length > 0); - setSelectedIndex(-1); - } catch (error) { - console.error('Failed to search:', error); - } finally { - setIsLoading(false); - } - }; - - const debounceTimer = setTimeout(searchBoth, 300); - return () => clearTimeout(debounceTimer); - }, [searchQuery, projectId, atSymbolPosition]); - - const handlePaste = (e: React.ClipboardEvent) => { - if (!onPasteFiles) return; - - const clipboardData = e.clipboardData; - if (!clipboardData) return; - - const files: File[] = []; - - if (clipboardData.files && clipboardData.files.length > 0) { - files.push(...Array.from(clipboardData.files)); - } else if (clipboardData.items && clipboardData.items.length > 0) { - Array.from(clipboardData.items).forEach((item) => { - if (item.kind !== 'file') return; - const file = item.getAsFile(); - if (file) files.push(file); - }); - } - - const imageFiles = files.filter((file) => - file.type.toLowerCase().startsWith('image/') - ); - - if (imageFiles.length > 0) { - e.preventDefault(); - onPasteFiles(imageFiles); - } - }; - - // Handle text changes and detect @ symbol - const handleChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - const newCursorPosition = e.target.selectionStart || 0; - - onChange(newValue); - - // Check if @ was just typed - const textBeforeCursor = newValue.slice(0, newCursorPosition); - const lastAtIndex = textBeforeCursor.lastIndexOf('@'); - - if (lastAtIndex !== -1) { - // Check if there's no space after the @ (still typing the search query) - const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); - const hasSpace = textAfterAt.includes(' ') || textAfterAt.includes('\n'); - - if (!hasSpace) { - setAtSymbolPosition(lastAtIndex); - setSearchQuery(textAfterAt); - return; - } - } - - // If no valid @ context, hide dropdown - setShowDropdown(false); - setSearchQuery(''); - setAtSymbolPosition(-1); - }; - - // Select a result item (either tag or file) and insert it - const selectResult = (result: SearchResultItem) => { - if (atSymbolPosition === -1) return; - - const beforeAt = value.slice(0, atSymbolPosition); - const afterQuery = value.slice(atSymbolPosition + 1 + searchQuery.length); - - let insertText = ''; - let newCursorPos = atSymbolPosition; - - if (result.type === 'tag' && result.tag) { - // Insert tag content - insertText = result.tag.content || ''; - newCursorPos = atSymbolPosition + insertText.length; - } else if (result.type === 'file' && result.file) { - // Insert file path (keep @ for files) - insertText = result.file.path; - newCursorPos = atSymbolPosition + insertText.length; - } - - const newValue = beforeAt + insertText + afterQuery; - onChange(newValue); - setShowDropdown(false); - setSearchQuery(''); - setAtSymbolPosition(-1); - - // Focus back to textarea - setTimeout(() => { - if (textareaRef.current) { - textareaRef.current.focus(); - textareaRef.current.setSelectionRange(newCursorPos, newCursorPos); - } - }, 0); - }; - - // Calculate dropdown position relative to textarea - const getDropdownPosition = useCallback(() => { - if (typeof window === 'undefined' || !textareaRef.current) { - return { - top: 0, - left: 0, - maxHeight: DROPDOWN_MAX_HEIGHT, - }; - } - - const caretRect = getCaretClientRect(textareaRef.current); - const referenceRect = - caretRect ?? textareaRef.current.getBoundingClientRect(); - const currentDropdownRect = dropdownRef.current?.getBoundingClientRect(); - - const availableWidth = Math.max( - window.innerWidth - DROPDOWN_VIEWPORT_PADDING * 2, - 0 - ); - const fallbackWidth = - availableWidth > 0 - ? Math.min(DROPDOWN_MIN_WIDTH, availableWidth) - : DROPDOWN_MIN_WIDTH; - const measuredWidth = - currentDropdownRect && currentDropdownRect.width > 0 - ? currentDropdownRect.width - : fallbackWidth; - const dropdownWidth = - availableWidth > 0 - ? Math.min(Math.max(measuredWidth, fallbackWidth), availableWidth) - : Math.max(measuredWidth, fallbackWidth); - - // Position dropdown near the caret by default - let finalTop = referenceRect.bottom + DROPDOWN_GAP; - let finalLeft = referenceRect.left; - let maxHeight = DROPDOWN_MAX_HEIGHT; - - // Ensure dropdown doesn't go off the right edge - if ( - finalLeft + dropdownWidth > - window.innerWidth - DROPDOWN_VIEWPORT_PADDING - ) { - finalLeft = window.innerWidth - dropdownWidth - DROPDOWN_VIEWPORT_PADDING; - } - - // Ensure dropdown doesn't go off the left edge - if (finalLeft < DROPDOWN_VIEWPORT_PADDING) { - finalLeft = DROPDOWN_VIEWPORT_PADDING; - } - - // Calculate available space below and above the caret - const availableSpaceBelow = - window.innerHeight - referenceRect.bottom - DROPDOWN_VIEWPORT_PADDING * 2; - const availableSpaceAbove = - referenceRect.top - DROPDOWN_VIEWPORT_PADDING * 2; - - // If not enough space below, position above - if ( - availableSpaceBelow < DROPDOWN_MIN_HEIGHT && - availableSpaceAbove > availableSpaceBelow - ) { - const actualHeight = currentDropdownRect?.height || DROPDOWN_MIN_HEIGHT; - finalTop = referenceRect.top - actualHeight - DROPDOWN_GAP; - maxHeight = Math.min( - DROPDOWN_MAX_HEIGHT, - Math.max(availableSpaceAbove, DROPDOWN_MIN_HEIGHT) - ); - } else { - // Position below with available space - maxHeight = Math.min( - DROPDOWN_MAX_HEIGHT, - Math.max(availableSpaceBelow, DROPDOWN_MIN_HEIGHT) - ); - } - - const estimatedHeight = - currentDropdownRect?.height || Math.min(maxHeight, DROPDOWN_MAX_HEIGHT); - const maxTop = - window.innerHeight - - DROPDOWN_VIEWPORT_PADDING - - Math.max(estimatedHeight, DROPDOWN_MIN_HEIGHT); - - if (finalTop > maxTop) { - finalTop = Math.max(DROPDOWN_VIEWPORT_PADDING, maxTop); - } - - if (finalTop < DROPDOWN_VIEWPORT_PADDING) { - finalTop = DROPDOWN_VIEWPORT_PADDING; - } - - return { - top: finalTop, - left: finalLeft, - maxHeight, - }; - }, [textareaRef]); - - const [dropdownPosition, setDropdownPosition] = useState(() => - getDropdownPosition() - ); - - // Keep dropdown positioned near the caret and within viewport bounds - useLayoutEffect(() => { - if (!showDropdown) return; - - const updatePosition = () => { - const newPosition = getDropdownPosition(); - setDropdownPosition((prev) => { - if ( - prev.top === newPosition.top && - prev.left === newPosition.left && - prev.maxHeight === newPosition.maxHeight - ) { - return prev; - } - return newPosition; - }); - }; - - updatePosition(); - let frameId = requestAnimationFrame(updatePosition); - - const scheduleUpdate = () => { - cancelAnimationFrame(frameId); - frameId = requestAnimationFrame(updatePosition); - }; - - window.addEventListener('resize', scheduleUpdate); - window.addEventListener('scroll', scheduleUpdate, true); - - return () => { - cancelAnimationFrame(frameId); - window.removeEventListener('resize', scheduleUpdate); - window.removeEventListener('scroll', scheduleUpdate, true); - }; - }, [showDropdown, searchResults.length, getDropdownPosition]); - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Handle dropdown navigation first - if (showDropdown && searchResults.length > 0) { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex((prev) => - prev < searchResults.length - 1 ? prev + 1 : 0 - ); - return; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex((prev) => - prev > 0 ? prev - 1 : searchResults.length - 1 - ); - return; - case 'Enter': - if (selectedIndex >= 0) { - e.preventDefault(); - selectResult(searchResults[selectedIndex]); - return; - } - break; - case 'Escape': - e.preventDefault(); - setShowDropdown(false); - setSearchQuery(''); - setAtSymbolPosition(-1); - return; - } - } else { - switch (e.key) { - case 'Escape': - e.preventDefault(); - textareaRef.current?.blur(); - break; - } - } - - // Propagate event to parent component for additional handling - onKeyDown?.(e); - }; - - // Group results by type for rendering - const tagResults = searchResults.filter((r) => r.type === 'tag'); - const fileResults = searchResults.filter((r) => r.type === 'file'); - - return ( -
- - - {showDropdown && - createPortal( -
- {isLoading ? ( -
- Searching... -
- ) : searchResults.length === 0 ? ( -
- No tags or files found -
- ) : ( -
- {/* Tags Section */} - {tagResults.length > 0 && ( - <> -
- Tags -
- {tagResults.map((result) => { - const index = searchResults.indexOf(result); - const tag = result.tag!; - return ( -
selectResult(result)} - aria-selected={index === selectedIndex} - role="option" - > -
- - @{tag.tag_name} -
- {tag.content && ( -
- {tag.content.slice(0, 60)} - {tag.content.length > 60 ? '...' : ''} -
- )} -
- ); - })} - - )} - - {/* Files Section */} - {fileResults.length > 0 && ( - <> - {tagResults.length > 0 &&
} -
- Files -
- {fileResults.map((result) => { - const index = searchResults.indexOf(result); - const file = result.file!; - return ( -
selectResult(result)} - aria-selected={index === selectedIndex} - role="option" - > -
- - {file.name} -
-
- {file.path} -
-
- ); - })} - - )} -
- )} -
, - document.body - )} -
- ); -}); diff --git a/frontend/src/components/ui/image-upload-section.tsx b/frontend/src/components/ui/image-upload-section.tsx index 684cf38e..1c613e93 100644 --- a/frontend/src/components/ui/image-upload-section.tsx +++ b/frontend/src/components/ui/image-upload-section.tsx @@ -15,7 +15,7 @@ import { } from 'lucide-react'; import { Button } from './button'; import { Alert, AlertDescription } from './alert'; -import { cn } from '@/lib/utils'; +import { cn, formatFileSize } from '@/lib/utils'; import { imagesApi } from '@/lib/api'; import type { ImageResponse } from 'shared/types'; @@ -213,14 +213,6 @@ export const ImageUploadSection = forwardRef< [images, onImagesChange, onDelete] ); - const formatFileSize = (bytes: bigint) => { - const kb = Number(bytes) / 1024; - if (kb < 1024) { - return `${kb.toFixed(1)} KB`; - } - return `${(kb / 1024).toFixed(1)} MB`; - }; - const content = (
{/* Error message */} diff --git a/frontend/src/components/ui/markdown-renderer.tsx b/frontend/src/components/ui/markdown-renderer.tsx deleted file mode 100644 index dfbed637..00000000 --- a/frontend/src/components/ui/markdown-renderer.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import Markdown from 'markdown-to-jsx'; -import { memo, useMemo, useState, useCallback } from 'react'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip.tsx'; -import { Button } from '@/components/ui/button.tsx'; -import { Check, Clipboard } from 'lucide-react'; -import { writeClipboardViaBridge } from '@/vscode/bridge'; - -const HIGHLIGHT_LINK = - 'rounded-sm bg-muted/50 px-1 py-0.5 underline-offset-2 transition-colors'; -const HIGHLIGHT_LINK_HOVER = 'hover:bg-muted'; -const HIGHLIGHT_CODE = 'rounded-sm bg-muted/50 px-1 py-0.5 font-mono text-sm'; - -function sanitizeHref(href?: string): string | undefined { - if (typeof href !== 'string') return undefined; - const trimmed = href.trim(); - // Block dangerous protocols - if (/^(javascript|vbscript|data):/i.test(trimmed)) return undefined; - // Allow anchors and common relative forms - if ( - trimmed.startsWith('#') || - trimmed.startsWith('./') || - trimmed.startsWith('../') || - trimmed.startsWith('/') - ) - return trimmed; - // Allow only https - if (/^https:\/\//i.test(trimmed)) return trimmed; - // Block everything else by default - return undefined; -} - -function isExternalHref(href?: string): boolean { - if (!href) return false; - return /^https:\/\//i.test(href); -} - -function LinkOverride({ - href, - children, - title, -}: { - href?: string; - children: React.ReactNode; - title?: string; -}) { - const rawHref = typeof href === 'string' ? href : ''; - const safeHref = sanitizeHref(rawHref); - - const external = isExternalHref(safeHref); - const internalOrDisabled = !external; - - if (!safeHref || internalOrDisabled) { - // Disabled internal link (relative paths and anchors) - return ( - - {children} - - ); - } - - // External link - return ( - { - e.stopPropagation(); - }} - > - {children} - - ); -} - -function InlineCodeOverride({ - children, - className, - ...props -}: React.ComponentProps<'code'>) { - // Only highlight inline code, not fenced code blocks - const hasLanguage = - typeof className === 'string' && /\blanguage-/.test(className); - if (hasLanguage) { - // Likely a fenced block's ; leave className as-is for syntax highlighting - return ( - - {children} - - ); - } - return ( - - {children} - - ); -} - -interface MarkdownRendererProps { - content: string; - className?: string; - enableCopyButton?: boolean; -} - -function MarkdownRenderer({ - content, - className = '', - enableCopyButton = false, -}: MarkdownRendererProps) { - const overrides = useMemo( - () => ({ - a: { component: LinkOverride }, - code: { component: InlineCodeOverride }, - strong: { - component: ({ children, ...props }: React.ComponentProps<'strong'>) => ( - - {children} - - ), - }, - em: { - component: ({ children, ...props }: React.ComponentProps<'em'>) => ( - - {children} - - ), - }, - p: { - component: ({ children, ...props }: React.ComponentProps<'p'>) => ( -

- {children} -

- ), - }, - h1: { - component: ({ children, ...props }: React.ComponentProps<'h1'>) => ( -

- {children} -

- ), - }, - h2: { - component: ({ children, ...props }: React.ComponentProps<'h2'>) => ( -

- {children} -

- ), - }, - h3: { - component: ({ children, ...props }: React.ComponentProps<'h3'>) => ( -

- {children} -

- ), - }, - ul: { - component: ({ children, ...props }: React.ComponentProps<'ul'>) => ( -
    - {children} -
- ), - }, - ol: { - component: ({ children, ...props }: React.ComponentProps<'ol'>) => ( -
    - {children} -
- ), - }, - li: { - component: ({ children, ...props }: React.ComponentProps<'li'>) => ( -
  • - {children} -
  • - ), - }, - pre: { - component: ({ children, ...props }: React.ComponentProps<'pre'>) => ( -
    -            {children}
    -          
    - ), - }, - }), - [] - ); - - const [copied, setCopied] = useState(false); - const handleCopy = useCallback(async () => { - try { - await writeClipboardViaBridge(content); - setCopied(true); - window.setTimeout(() => setCopied(false), 400); - } catch { - // noop – bridge handles fallback - } - }, [content]); - - return ( -
    - {enableCopyButton && ( -
    -
    - - - -
    - - {copied && ( -
    - Copied -
    - )} -
    -
    - - {copied ? 'Copied!' : 'Copy as Markdown'} - -
    -
    -
    -
    - )} -
    - - {content} - -
    -
    - ); -} - -export default memo(MarkdownRenderer); diff --git a/frontend/src/components/ui/title-description-editor.tsx b/frontend/src/components/ui/title-description-editor.tsx index 3790a7f7..338d3f0e 100644 --- a/frontend/src/components/ui/title-description-editor.tsx +++ b/frontend/src/components/ui/title-description-editor.tsx @@ -6,6 +6,7 @@ type Props = { description: string | null | undefined; onTitleChange: (v: string) => void; onDescriptionChange: (v: string) => void; + projectId?: string; }; const TitleDescriptionEditor = ({ @@ -13,6 +14,7 @@ const TitleDescriptionEditor = ({ description, onTitleChange, onDescriptionChange, + projectId, }: Props) => { return (
    @@ -26,6 +28,7 @@ const TitleDescriptionEditor = ({ placeholder="Description" value={description ?? ''} onChange={onDescriptionChange} + projectId={projectId} />
    ); diff --git a/frontend/src/components/ui/wysiwyg.tsx b/frontend/src/components/ui/wysiwyg.tsx index caa45019..e6697b3d 100644 --- a/frontend/src/components/ui/wysiwyg.tsx +++ b/frontend/src/components/ui/wysiwyg.tsx @@ -1,54 +1,133 @@ -import { useEffect, useMemo, useRef } from 'react'; +import { useMemo, useState, useCallback, memo } from 'react'; import { LexicalComposer } from '@lexical/react/LexicalComposer'; import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; import { ContentEditable } from '@lexical/react/LexicalContentEditable'; import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; +import { TRANSFORMERS, INLINE_CODE, type Transformer } from '@lexical/markdown'; +import { ImageNode } from './wysiwyg/nodes/image-node'; +import { InlineCodeNode } from './wysiwyg/nodes/inline-code-node'; +import { IMAGE_TRANSFORMER } from './wysiwyg/transformers/image-transformer'; +import { CODE_BLOCK_TRANSFORMER } from './wysiwyg/transformers/code-block-transformer'; +import { INLINE_CODE_TRANSFORMER } from './wysiwyg/transformers/inline-code-transformer'; import { - $convertToMarkdownString, - $convertFromMarkdownString, - TRANSFORMERS, - type Transformer, -} from '@lexical/markdown'; -import { - ImageChipNode, - InsertImageChipPlugin, -} from './wysiwyg/image-chip-node'; + TaskAttemptContext, + TaskContext, + LocalImagesContext, + type LocalImageMetadata, +} from './wysiwyg/context/task-attempt-context'; +import { FileTagTypeaheadPlugin } from './wysiwyg/plugins/file-tag-typeahead-plugin'; +import { KeyboardCommandsPlugin } from './wysiwyg/plugins/keyboard-commands-plugin'; +import { ImageKeyboardPlugin } from './wysiwyg/plugins/image-keyboard-plugin'; +import { ReadOnlyLinkPlugin } from './wysiwyg/plugins/read-only-link-plugin'; +import { ToolbarPlugin } from './wysiwyg/plugins/toolbar-plugin'; +import { CodeBlockShortcutPlugin } from './wysiwyg/plugins/code-block-shortcut-plugin'; +import { MarkdownSyncPlugin } from './wysiwyg/plugins/markdown-sync-plugin'; import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; import { HeadingNode, QuoteNode } from '@lexical/rich-text'; import { ListNode, ListItemNode } from '@lexical/list'; import { ListPlugin } from '@lexical/react/LexicalListPlugin'; -import { CodeNode } from '@lexical/code'; +import { CodeNode, CodeHighlightNode } from '@lexical/code'; +import { CodeHighlightPlugin } from './wysiwyg/plugins/code-highlight-plugin'; +import { CODE_HIGHLIGHT_CLASSES } from './wysiwyg/lib/code-highlight-theme'; import { LinkNode } from '@lexical/link'; import { EditorState } from 'lexical'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { - IMAGE_CHIP_EXPORT, - IMAGE_CHIP_IMPORT, -} from './wysiwyg/image-chip-markdown'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Check, Clipboard, Pencil, Trash2 } from 'lucide-react'; +import { writeClipboardViaBridge } from '@/vscode/bridge'; + +/** Markdown string representing the editor content */ +export type SerializedEditorState = string; type WysiwygProps = { - placeholder: string; - value?: string; // controlled markdown - onChange?: (md: string) => void; - defaultValue?: string; // uncontrolled initial markdown + placeholder?: string; + /** Markdown string representing the editor content */ + value: SerializedEditorState; + onChange?: (state: SerializedEditorState) => void; onEditorStateChange?: (s: EditorState) => void; + disabled?: boolean; + onPasteFiles?: (files: File[]) => void; + className?: string; + projectId?: string; // for file search in typeahead + onCmdEnter?: () => void; + onShiftCmdEnter?: () => void; + /** Task attempt ID for resolving .vibe-images paths (preferred over taskId) */ + taskAttemptId?: string; + /** Task ID for resolving .vibe-images paths when taskAttemptId is not available */ + taskId?: string; + /** Local images for immediate rendering (before saved to server) */ + localImages?: LocalImageMetadata[]; + /** Optional edit callback - shows edit button in read-only mode when provided */ + onEdit?: () => void; + /** Optional delete callback - shows delete button in read-only mode when provided */ + onDelete?: () => void; }; -export default function WYSIWYGEditor({ - placeholder, +function WYSIWYGEditor({ + placeholder = '', value, onChange, - defaultValue, onEditorStateChange, + disabled = false, + onPasteFiles, + className, + projectId, + onCmdEnter, + onShiftCmdEnter, + taskAttemptId, + taskId, + localImages, + onEdit, + onDelete, }: WysiwygProps) { + // Copy button state + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { + if (!value) return; + try { + await writeClipboardViaBridge(value); + setCopied(true); + window.setTimeout(() => setCopied(false), 400); + } catch { + // noop – bridge handles fallback + } + }, [value]); + const initialConfig = useMemo( () => ({ namespace: 'md-wysiwyg', onError: console.error, theme: { - heading: { h1: 'text-2xl font-semibold', h2: 'text-xl font-semibold' }, - text: { bold: 'font-bold', italic: 'italic' }, + paragraph: 'mb-2 last:mb-0 text-sm', + heading: { + h1: 'mt-4 mb-2 text-2xl font-semibold', + h2: 'mt-3 mb-2 text-xl font-semibold', + h3: 'mt-3 mb-2 text-lg font-semibold', + h4: 'mt-2 mb-1 text-base font-medium', + h5: 'mt-2 mb-1 text-sm font-medium', + h6: 'mt-2 mb-1 text-xs font-medium uppercase tracking-wide', + }, + quote: + 'my-3 border-l-4 border-primary-foreground pl-4 text-muted-foreground', + list: { + ul: 'my-1 list-disc list-inside', + ol: 'my-1 list-decimal list-inside', + listitem: '', + nested: { + listitem: 'pl-4', + }, + }, + link: 'text-primary underline underline-offset-2 cursor-pointer hover:text-primary/80', + text: { + bold: 'font-semibold', + italic: 'italic', + underline: 'underline underline-offset-2', + strikethrough: 'line-through', + code: 'font-mono bg-muted px-1 py-0.5 rounded', + }, + code: 'block font-mono text-sm bg-secondary rounded-md px-3 py-2 my-2 whitespace-pre overflow-x-auto', + codeHighlight: CODE_HIGHLIGHT_CLASSES, }, nodes: [ HeadingNode, @@ -56,155 +135,174 @@ export default function WYSIWYGEditor({ ListNode, ListItemNode, CodeNode, + CodeHighlightNode, LinkNode, - ImageChipNode, + ImageNode, + InlineCodeNode, ], }), [] ); - // Shared ref to avoid update loops and redundant imports - const lastMdRef = useRef(''); - - const exportTransformers: Transformer[] = useMemo( - () => [...TRANSFORMERS, IMAGE_CHIP_EXPORT], - [] - ); - const importTransformers: Transformer[] = useMemo( - () => [...TRANSFORMERS, IMAGE_CHIP_IMPORT], + // Extended transformers with image and code block support (memoized to prevent unnecessary re-renders) + // Filter out default INLINE_CODE to use our custom INLINE_CODE_TRANSFORMER with syntax highlighting + const extendedTransformers: Transformer[] = useMemo( + () => [ + IMAGE_TRANSFORMER, + CODE_BLOCK_TRANSFORMER, + INLINE_CODE_TRANSFORMER, + ...TRANSFORMERS.filter((t) => t !== INLINE_CODE), + ], [] ); - return ( -
    - -
    - - } - placeholder={ -
    - {placeholder} -
    - } - ErrorBoundary={LexicalErrorBoundary} - /> + // Memoized handlers for ContentEditable to prevent re-renders + const handlePaste = useCallback( + (event: React.ClipboardEvent) => { + if (!onPasteFiles || disabled) return; + + const dt = event.clipboardData; + if (!dt) return; + + const files: File[] = Array.from(dt.files || []).filter((f) => + f.type.startsWith('image/') + ); + + if (files.length > 0) { + onPasteFiles(files); + } + }, + [onPasteFiles, disabled] + ); + + // Memoized placeholder element + const placeholderElement = useMemo( + () => + !disabled ? ( +
    + {placeholder}
    + ) : null, + [disabled, placeholder] + ); - - - - + const editorContent = ( +
    + + + + + + {!disabled && } +
    + + } + placeholder={placeholderElement} + ErrorBoundary={LexicalErrorBoundary} + /> +
    - {/* Emit markdown on change */} - - - {/* Apply external controlled value (markdown) */} - - - {/* Apply defaultValue once in uncontrolled mode */} - {value === undefined && defaultValue ? ( - - ) : null} -
    + + + {/* Only include editing plugins when not in read-only mode */} + {!disabled && ( + <> + + + + + + + + )} + {/* Link sanitization for read-only mode */} + {disabled && } + +
    +
    +
    ); + + // Wrap with action buttons in read-only mode + if (disabled) { + return ( +
    +
    +
    + {/* Copy button */} + + {/* Edit button - only if onEdit provided */} + {onEdit && ( + + )} + {/* Delete button - only if onDelete provided */} + {onDelete && ( + + )} +
    +
    + {editorContent} +
    + ); + } + + return editorContent; } -function MarkdownOnChangePlugin({ - onMarkdownChange, - onEditorStateChange, - exportTransformers, - lastMdRef, -}: { - onMarkdownChange?: (md: string) => void; - onEditorStateChange?: (s: EditorState) => void; - exportTransformers: Transformer[]; - lastMdRef: React.MutableRefObject; -}) { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - return editor.registerUpdateListener(({ editorState }) => { - // Tap editor state if requested - if (onEditorStateChange) { - onEditorStateChange(editorState); - } - // Emit markdown - editorState.read(() => { - const md = $convertToMarkdownString(exportTransformers); - lastMdRef.current = md; - if (onMarkdownChange) onMarkdownChange(md); - }); - }); - }, [ - editor, - onMarkdownChange, - onEditorStateChange, - exportTransformers, - lastMdRef, - ]); - return null; -} - -function MarkdownValuePlugin({ - value, - importTransformers, - lastMdRef, -}: { - value?: string; - importTransformers: Transformer[]; - lastMdRef: React.MutableRefObject; -}) { - const [editor] = useLexicalComposerContext(); - useEffect(() => { - if (value === undefined) return; // uncontrolled mode - if (value === lastMdRef.current) return; // avoid redundant imports - - editor.update(() => { - // Replace content with external value - $convertFromMarkdownString(value || '', importTransformers); - }); - lastMdRef.current = value || ''; - }, [editor, value, importTransformers, lastMdRef]); - return null; -} - -function MarkdownDefaultValuePlugin({ - defaultValue, - importTransformers, - lastMdRef, -}: { - defaultValue: string; - importTransformers: Transformer[]; - lastMdRef: React.MutableRefObject; -}) { - const [editor] = useLexicalComposerContext(); - const didInit = useRef(false); - useEffect(() => { - if (didInit.current) return; - didInit.current = true; - - editor.update(() => { - $convertFromMarkdownString(defaultValue || '', importTransformers); - }); - lastMdRef.current = defaultValue || ''; - }, [editor, defaultValue, importTransformers, lastMdRef]); - return null; -} +export default memo(WYSIWYGEditor); diff --git a/frontend/src/components/ui/wysiwyg/context/task-attempt-context.tsx b/frontend/src/components/ui/wysiwyg/context/task-attempt-context.tsx new file mode 100644 index 00000000..a573c4f9 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/context/task-attempt-context.tsx @@ -0,0 +1,28 @@ +import { createContext, useContext } from 'react'; + +export const TaskAttemptContext = createContext(undefined); + +export function useTaskAttemptId() { + return useContext(TaskAttemptContext); +} + +export const TaskContext = createContext(undefined); + +export function useTaskId() { + return useContext(TaskContext); +} + +// Local images metadata for rendering uploaded images before they're saved +export type LocalImageMetadata = { + path: string; // ".vibe-images/uuid.png" + proxy_url: string; // "/api/images/{id}/file" + file_name: string; + size_bytes: number; + format: string; +}; + +export const LocalImagesContext = createContext([]); + +export function useLocalImages() { + return useContext(LocalImagesContext); +} diff --git a/frontend/src/components/ui/wysiwyg/image-chip-markdown.ts b/frontend/src/components/ui/wysiwyg/image-chip-markdown.ts deleted file mode 100644 index 85ca428b..00000000 --- a/frontend/src/components/ui/wysiwyg/image-chip-markdown.ts +++ /dev/null @@ -1,35 +0,0 @@ -// imageChipMarkdown.ts -import type { TextMatchTransformer } from '@lexical/markdown'; -import { - $isImageChipNode, - ImageChipNode, - $createImageChipNode, -} from './image-chip-node'; - -export const IMAGE_CHIP_EXPORT: TextMatchTransformer = { - type: 'text-match', - dependencies: [ImageChipNode], - - // required by the type but unused here: - regExp: /$^/, // never matches - - export: (node) => { - if (!$isImageChipNode(node)) return null; - const alt = node.__alt ?? ''; - const src = node.__src; - return `![${alt}](${src})`; - }, -}; - -export const IMAGE_CHIP_IMPORT: TextMatchTransformer = { - type: 'text-match', - dependencies: [ImageChipNode], - - regExp: /!\[([^\]]*)\]\(([^)]+)\)/, - - replace: (textNode, match) => { - const [, alt, src] = match; - const imageChipNode = $createImageChipNode({ src, alt }); - textNode.replace(imageChipNode); - }, -}; diff --git a/frontend/src/components/ui/wysiwyg/image-chip-node.tsx b/frontend/src/components/ui/wysiwyg/image-chip-node.tsx deleted file mode 100644 index e0cf4d41..00000000 --- a/frontend/src/components/ui/wysiwyg/image-chip-node.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// ImageChipNode.tsx -import { - DecoratorNode, - type NodeKey, - createCommand, - $getSelection, - $isRangeSelection, -} from 'lexical'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $insertNodes } from 'lexical'; -import React from 'react'; - -// ---- Node payload & command -export type ImageChipPayload = { - src: string; - name?: string; // filename or label - sizeKB?: number; // optional metadata - alt?: string; -}; -export const INSERT_IMAGE_CHIP_COMMAND = - createCommand('INSERT_IMAGE_CHIP'); - -// ---- Node definition -export class ImageChipNode extends DecoratorNode { - __src: string; - __name?: string; - __sizeKB?: number; - __alt?: string; - - static getType(): string { - return 'image-chip'; - } - - static clone(node: ImageChipNode): ImageChipNode { - return new ImageChipNode( - { - src: node.__src, - name: node.__name, - sizeKB: node.__sizeKB, - alt: node.__alt, - }, - node.__key - ); - } - - constructor(payload: ImageChipPayload, key?: NodeKey) { - super(key); - this.__src = payload.src; - this.__name = payload.name; - this.__sizeKB = payload.sizeKB; - this.__alt = payload.alt; - } - - // Render as a React “chip”, not an - decorate(): JSX.Element { - const name = this.__name ?? this.__src.split('/').pop() ?? 'image'; - const meta = this.__sizeKB - ? `${this.__sizeKB} KB` - : this.__alt - ? this.__alt - : ''; - return ( - - - - - {name} - {meta && · {meta}} - - ); - } - - createDOM(): HTMLElement { - // container for the React decoration - return document.createElement('span'); - } - - updateDOM(): boolean { - return false; - } - - static importJSON(json: unknown): ImageChipNode { - return new ImageChipNode(json as ImageChipPayload); - } - exportJSON(): ImageChipPayload & { type: string; version: number } { - return { - type: 'image-chip', - version: 1, - src: this.__src, - name: this.__name, - sizeKB: this.__sizeKB, - alt: this.__alt, - }; - } - isInline(): boolean { - return true; - } -} - -// ---- Helper to create the node -export function $createImageChipNode(payload: ImageChipPayload): ImageChipNode { - return new ImageChipNode(payload); -} - -// ---- Tiny plugin: wire a demo button + command -export function InsertImageChipPlugin() { - const [editor] = useLexicalComposerContext(); - - React.useEffect(() => { - return editor.registerCommand( - INSERT_IMAGE_CHIP_COMMAND, - (payload) => { - editor.update(() => { - const sel = $getSelection(); - if ($isRangeSelection(sel)) { - $insertNodes([$createImageChipNode(payload)]); - } - }); - return true; - }, - 0 - ); - }, [editor]); - - return null; - - // // Example UI (replace with your own image picker/uploader) - // return ( - //
    - // - //
    - // ); -} - -export function $isImageChipNode(node: unknown): node is ImageChipNode { - return node instanceof ImageChipNode; -} diff --git a/frontend/src/components/ui/wysiwyg/lib/code-highlight-theme.ts b/frontend/src/components/ui/wysiwyg/lib/code-highlight-theme.ts new file mode 100644 index 00000000..1240a977 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/lib/code-highlight-theme.ts @@ -0,0 +1,36 @@ +/** + * Shared code highlight theme classes for Prism token types. + * Used by both Lexical's codeHighlight theme and InlineCodeNode rendering. + */ +export const CODE_HIGHLIGHT_CLASSES: Record = { + atrule: 'text-[var(--syntax-keyword)]', + attr: 'text-[var(--syntax-constant)]', + boolean: 'text-[var(--syntax-constant)]', + builtin: 'text-[var(--syntax-variable)]', + cdata: 'text-[var(--syntax-comment)]', + char: 'text-[var(--syntax-string)]', + class: 'text-[var(--syntax-function)]', + 'class-name': 'text-[var(--syntax-function)]', + comment: 'text-[var(--syntax-comment)] italic', + constant: 'text-[var(--syntax-constant)]', + deleted: 'text-[var(--syntax-deleted)]', + doctype: 'text-[var(--syntax-comment)]', + entity: 'text-[var(--syntax-function)]', + function: 'text-[var(--syntax-function)]', + important: 'text-[var(--syntax-keyword)] font-bold', + inserted: 'text-[var(--syntax-tag)]', + keyword: 'text-[var(--syntax-keyword)]', + namespace: 'text-[var(--syntax-comment)]', + number: 'text-[var(--syntax-constant)]', + operator: 'text-[var(--syntax-constant)]', + prolog: 'text-[var(--syntax-comment)]', + property: 'text-[var(--syntax-constant)]', + punctuation: 'text-[var(--syntax-punctuation)]', + regex: 'text-[var(--syntax-string)]', + selector: 'text-[var(--syntax-tag)]', + string: 'text-[var(--syntax-string)]', + symbol: 'text-[var(--syntax-variable)]', + tag: 'text-[var(--syntax-tag)]', + url: 'text-[var(--syntax-constant)]', + variable: 'text-[var(--syntax-variable)]', +}; diff --git a/frontend/src/components/ui/wysiwyg/nodes/image-node.tsx b/frontend/src/components/ui/wysiwyg/nodes/image-node.tsx new file mode 100644 index 00000000..95eb2368 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/nodes/image-node.tsx @@ -0,0 +1,292 @@ +import { useCallback } from 'react'; +import { + $createTextNode, + $getNodeByKey, + DecoratorNode, + DOMConversionMap, + DOMExportOutput, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { HelpCircle, Loader2 } from 'lucide-react'; +import { + useTaskAttemptId, + useTaskId, + useLocalImages, +} from '../context/task-attempt-context'; +import { useImageMetadata } from '@/hooks/useImageMetadata'; +import { ImagePreviewDialog } from '@/components/dialogs/wysiwyg/ImagePreviewDialog'; +import { formatFileSize } from '@/lib/utils'; + +export type SerializedImageNode = Spread< + { + src: string; + altText: string; + }, + SerializedLexicalNode +>; + +function truncatePath(path: string, maxLength = 24): string { + const filename = path.split('/').pop() || path; + if (filename.length <= maxLength) return filename; + return filename.slice(0, maxLength - 3) + '...'; +} + +function ImageComponent({ + src, + altText, + nodeKey, +}: { + src: string; + altText: string; + nodeKey: NodeKey; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + const taskAttemptId = useTaskAttemptId(); + const taskId = useTaskId(); + const localImages = useLocalImages(); + + const isVibeImage = src.startsWith('.vibe-images/'); + + // Use TanStack Query for caching metadata across component recreations + // Pass both taskAttemptId and taskId - the hook prefers taskAttemptId when available + // Also pass localImages for immediate rendering of newly uploaded images + const { data: metadata, isLoading: loading } = useImageMetadata( + taskAttemptId, + src, + taskId, + localImages + ); + + const handleClick = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + // Open preview dialog if we have a valid image URL + if (metadata?.exists && metadata.proxy_url) { + ImagePreviewDialog.show({ + imageUrl: metadata.proxy_url, + altText, + fileName: metadata.file_name ?? undefined, + format: metadata.format ?? undefined, + sizeBytes: metadata.size_bytes, + }); + } + }, + [metadata, altText] + ); + + const handleDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Don't allow editing in read-only mode + if (!editor.isEditable()) return; + + event.preventDefault(); + event.stopPropagation(); + + // Convert back to markdown text for editing + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + const markdownText = `![${node.getAltText()}](${node.getSrc()})`; + const textNode = $createTextNode(markdownText); + node.replace(textNode); + textNode.select(markdownText.length, markdownText.length); + } + }); + }, + [editor, nodeKey] + ); + + // Determine what to show as thumbnail + let thumbnailContent: React.ReactNode; + let displayName: string; + let metadataLine: string | null = null; + + // Check if we have context for fetching metadata (either taskAttemptId or taskId) + const hasContext = !!taskAttemptId || !!taskId; + // Check if image exists in local images (for create mode where no task context exists yet) + const hasLocalImage = localImages.some((img) => img.path === src); + + if (isVibeImage && (hasLocalImage || hasContext)) { + if (loading) { + thumbnailContent = ( +
    + +
    + ); + displayName = truncatePath(src); + } else if (metadata?.exists && metadata.proxy_url) { + thumbnailContent = ( + {altText} + ); + displayName = truncatePath(metadata.file_name || altText || src); + // Build metadata line + const parts: string[] = []; + if (metadata.format) { + parts.push(metadata.format.toUpperCase()); + } + const sizeStr = formatFileSize(metadata.size_bytes); + if (sizeStr) { + parts.push(sizeStr); + } + if (parts.length > 0) { + metadataLine = parts.join(' · '); + } + } else { + // Vibe image but not found or error + thumbnailContent = ( +
    + +
    + ); + displayName = truncatePath(src); + } + } else if (!isVibeImage) { + // Non-vibe-image: show question mark and path + thumbnailContent = ( +
    + +
    + ); + displayName = truncatePath(altText || src); + } else { + // isVibeImage but no context available - fallback to question mark + thumbnailContent = ( +
    + +
    + ); + displayName = truncatePath(src); + } + + return ( + + {thumbnailContent} + + + {displayName} + + {metadataLine && ( + + {metadataLine} + + )} + + + ); +} + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode(node.__src, node.__altText, node.__key); + } + + constructor(src: string, altText: string, key?: NodeKey) { + super(key); + this.__src = src; + this.__altText = altText; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + return span; + } + + updateDOM(): false { + return false; + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const { src, altText } = serializedNode; + return $createImageNode(src, altText); + } + + exportJSON(): SerializedImageNode { + return { + type: 'image', + version: 1, + src: this.__src, + altText: this.__altText, + }; + } + + static importDOM(): DOMConversionMap | null { + return { + img: () => ({ + conversion: (domNode: HTMLElement) => { + const img = domNode as HTMLImageElement; + const src = img.getAttribute('src') || ''; + const altText = img.getAttribute('alt') || ''; + return { node: $createImageNode(src, altText) }; + }, + priority: 0, + }), + }; + } + + exportDOM(): DOMExportOutput { + const img = document.createElement('img'); + img.setAttribute('src', this.__src); + img.setAttribute('alt', this.__altText); + return { element: img }; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } + + decorate(): JSX.Element { + return ( + + ); + } + + isInline(): boolean { + return true; + } + + isKeyboardSelectable(): boolean { + return true; + } +} + +export function $createImageNode(src: string, altText: string): ImageNode { + return new ImageNode(src, altText); +} + +export function $isImageNode( + node: LexicalNode | null | undefined +): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/frontend/src/components/ui/wysiwyg/nodes/inline-code-node.tsx b/frontend/src/components/ui/wysiwyg/nodes/inline-code-node.tsx new file mode 100644 index 00000000..5cd1f6ba --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/nodes/inline-code-node.tsx @@ -0,0 +1,154 @@ +import { + DecoratorNode, + DOMConversionMap, + DOMExportOutput, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import { PrismTokenizer } from '@lexical/code'; +import { CODE_HIGHLIGHT_CLASSES } from '../lib/code-highlight-theme'; + +export type SerializedInlineCodeNode = Spread< + { + code: string; + }, + SerializedLexicalNode +>; + +interface Token { + type: string; + content: string | Token | (string | Token)[]; +} + +function renderToken(token: string | Token, index: number): React.ReactNode { + if (typeof token === 'string') { + return token; + } + + const className = CODE_HIGHLIGHT_CLASSES[token.type] || ''; + + // Handle nested tokens + let content: React.ReactNode; + if (typeof token.content === 'string') { + content = token.content; + } else if (Array.isArray(token.content)) { + content = token.content.map((t, i) => renderToken(t, i)); + } else { + content = renderToken(token.content, 0); + } + + return ( + + {content} + + ); +} + +function InlineCodeComponent({ code }: { code: string }): JSX.Element { + // Use PrismTokenizer to tokenize the code + const tokens = PrismTokenizer.tokenize(code); + + return ( + + {tokens.map((token, index) => renderToken(token, index))} + + ); +} + +export class InlineCodeNode extends DecoratorNode { + __code: string; + + static getType(): string { + return 'inline-code'; + } + + static clone(node: InlineCodeNode): InlineCodeNode { + return new InlineCodeNode(node.__code, node.__key); + } + + constructor(code: string, key?: NodeKey) { + super(key); + this.__code = code; + } + + createDOM(): HTMLElement { + const span = document.createElement('span'); + return span; + } + + updateDOM(): false { + return false; + } + + static importJSON(serializedNode: SerializedInlineCodeNode): InlineCodeNode { + const { code } = serializedNode; + return $createInlineCodeNode(code); + } + + exportJSON(): SerializedInlineCodeNode { + return { + type: 'inline-code', + version: 1, + code: this.__code, + }; + } + + static importDOM(): DOMConversionMap | null { + return { + code: (domNode: HTMLElement) => { + // Only import inline code elements (not block code) + const isBlock = + domNode.parentElement?.tagName === 'PRE' || + domNode.style.display === 'block'; + if (isBlock) { + return null; + } + return { + conversion: (node: HTMLElement) => { + const code = node.textContent || ''; + return { node: $createInlineCodeNode(code) }; + }, + priority: 0, + }; + }, + }; + } + + exportDOM(): DOMExportOutput { + const code = document.createElement('code'); + code.textContent = this.__code; + return { element: code }; + } + + getCode(): string { + return this.__code; + } + + getTextContent(): string { + return this.__code; + } + + decorate(): JSX.Element { + return ; + } + + isInline(): boolean { + return true; + } + + isKeyboardSelectable(): boolean { + return true; + } +} + +export function $createInlineCodeNode(code: string): InlineCodeNode { + return new InlineCodeNode(code); +} + +export function $isInlineCodeNode( + node: LexicalNode | null | undefined +): node is InlineCodeNode { + return node instanceof InlineCodeNode; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx new file mode 100644 index 00000000..aa3e1e41 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/code-block-shortcut-plugin.tsx @@ -0,0 +1,91 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEffect } from 'react'; +import { $createCodeNode } from '@lexical/code'; +import { + $getSelection, + $isRangeSelection, + $isTextNode, + $isParagraphNode, + $createTextNode, + ElementNode, +} from 'lexical'; + +const CODE_START_REGEX = /^```([\w-]*)$/; +const CODE_END_REGEX = /^```$/; + +/** + * Plugin that detects when user types closing ``` and converts the + * paragraphs between opening and closing backticks into a code block. + * + * This handles the typing case - paste/import is handled by CODE_BLOCK_TRANSFORMER. + */ +export function CodeBlockShortcutPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerUpdateListener(({ dirtyLeaves }) => { + // Only process if there are dirty leaves (actual changes) + if (dirtyLeaves.size === 0) return; + + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return; + + const anchorNode = selection.anchor.getNode(); + if (!$isTextNode(anchorNode)) return; + + const currentParagraph = anchorNode.getParent(); + if (!$isParagraphNode(currentParagraph)) return; + + const currentText = currentParagraph.getTextContent(); + + // Check if current line is closing ``` + if (!CODE_END_REGEX.test(currentText)) return; + + // Scan backward to find opening ``` + let openingParagraph: ElementNode | null = null; + let language: string | undefined; + const contentParagraphs: ElementNode[] = []; + + let sibling = currentParagraph.getPreviousSibling(); + while (sibling) { + if ($isParagraphNode(sibling)) { + const text = sibling.getTextContent(); + const startMatch = text.match(CODE_START_REGEX); + if (startMatch) { + openingParagraph = sibling; + language = startMatch[1] || undefined; + break; + } + contentParagraphs.unshift(sibling); + } + sibling = sibling.getPreviousSibling(); + } + + if (!openingParagraph) return; + + // Collect content from paragraphs between opening and closing + const codeLines = contentParagraphs.map((p) => p.getTextContent()); + const code = codeLines.join('\n'); + + // Create code node + const codeNode = $createCodeNode(language); + if (code) { + codeNode.append($createTextNode(code)); + } + + // Replace opening paragraph with code node + openingParagraph.replace(codeNode); + + // Remove content paragraphs and closing paragraph + contentParagraphs.forEach((p) => p.remove()); + currentParagraph.remove(); + + // Position cursor at end of code block + codeNode.selectEnd(); + }); + }); + }, [editor]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/code-highlight-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/code-highlight-plugin.tsx new file mode 100644 index 00000000..b739337a --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/code-highlight-plugin.tsx @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { registerCodeHighlighting } from '@lexical/code'; + +export function CodeHighlightPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx new file mode 100644 index 00000000..764b7dad --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/file-tag-typeahead-plugin.tsx @@ -0,0 +1,235 @@ +import { useState, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + LexicalTypeaheadMenuPlugin, + MenuOption, +} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import { $createTextNode } from 'lexical'; +import { Tag as TagIcon, FileText } from 'lucide-react'; +import { + searchTagsAndFiles, + type SearchResultItem, +} from '@/lib/searchTagsAndFiles'; + +class FileTagOption extends MenuOption { + item: SearchResultItem; + + constructor(item: SearchResultItem) { + const key = + item.type === 'tag' ? `tag-${item.tag!.id}` : `file-${item.file!.path}`; + super(key); + this.item = item; + } +} + +const MAX_DIALOG_HEIGHT = 320; +const VIEWPORT_MARGIN = 8; +const VERTICAL_GAP = 4; +const VERTICAL_GAP_ABOVE = 24; +const MIN_WIDTH = 320; + +function getMenuPosition(anchorEl: HTMLElement) { + const rect = anchorEl.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; + + const spaceAbove = rect.top; + const spaceBelow = viewportHeight - rect.bottom; + + const showBelow = spaceBelow >= spaceAbove; + + const availableVerticalSpace = showBelow ? spaceBelow : spaceAbove; + + const maxHeight = Math.max( + 0, + Math.min(MAX_DIALOG_HEIGHT, availableVerticalSpace - 2 * VIEWPORT_MARGIN) + ); + + let top: number | undefined; + let bottom: number | undefined; + + if (showBelow) { + top = rect.bottom + VERTICAL_GAP; + } else { + bottom = viewportHeight - rect.top + VERTICAL_GAP_ABOVE; + } + + let left = rect.left; + const maxLeft = viewportWidth - MIN_WIDTH - VIEWPORT_MARGIN; + if (left > maxLeft) { + left = Math.max(VIEWPORT_MARGIN, maxLeft); + } + + return { top, bottom, left, maxHeight }; +} + +export function FileTagTypeaheadPlugin({ projectId }: { projectId?: string }) { + const [editor] = useLexicalComposerContext(); + const [options, setOptions] = useState([]); + + const onQueryChange = useCallback( + (query: string | null) => { + // Lexical uses null to indicate "no active query / close menu" + if (query === null) { + setOptions([]); + return; + } + + // Here query is a string, including possible empty string '' + searchTagsAndFiles(query, projectId) + .then((results) => { + setOptions(results.map((r) => new FileTagOption(r))); + }) + .catch((err) => { + console.error('Failed to search tags/files', err); + }); + }, + [projectId] + ); + + return ( + + triggerFn={(text) => { + // Match @ followed by any non-whitespace characters + const match = /(?:^|\s)@([^\s@]*)$/.exec(text); + if (!match) return null; + const offset = match.index + match[0].indexOf('@'); + return { + leadOffset: offset, + matchingString: match[1], + replaceableString: match[0], + }; + }} + options={options} + onQueryChange={onQueryChange} + onSelectOption={(option, nodeToReplace, closeMenu) => { + editor.update(() => { + const textToInsert = + option.item.type === 'tag' + ? (option.item.tag?.content ?? '') + : (option.item.file?.path ?? ''); + + if (!nodeToReplace) return; + + // Create the node we want to insert + const textNode = $createTextNode(textToInsert); + + // Replace the trigger text (e.g., "@test") with selected content + nodeToReplace.replace(textNode); + + // Move the cursor to the end of the inserted text + textNode.select(textToInsert.length, textToInsert.length); + }); + + closeMenu(); + }} + menuRenderFn={( + anchorRef, + { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex } + ) => { + if (!anchorRef.current) return null; + + const { top, bottom, left, maxHeight } = getMenuPosition( + anchorRef.current + ); + + const tagResults = options.filter((r) => r.item.type === 'tag'); + const fileResults = options.filter((r) => r.item.type === 'file'); + + return createPortal( +
    + {options.length === 0 ? ( +
    + No tags or files found +
    + ) : ( +
    + {/* Tags Section */} + {tagResults.length > 0 && ( + <> +
    + Tags +
    + {tagResults.map((option) => { + const index = options.indexOf(option); + const tag = option.item.tag!; + return ( +
    setHighlightedIndex(index)} + onClick={() => selectOptionAndCleanUp(option)} + > +
    + + @{tag.tag_name} +
    + {tag.content && ( +
    + {tag.content.slice(0, 60)} + {tag.content.length > 60 ? '...' : ''} +
    + )} +
    + ); + })} + + )} + + {/* Files Section */} + {fileResults.length > 0 && ( + <> + {tagResults.length > 0 &&
    } +
    + Files +
    + {fileResults.map((option) => { + const index = options.indexOf(option); + const file = option.item.file!; + return ( +
    setHighlightedIndex(index)} + onClick={() => selectOptionAndCleanUp(option)} + > +
    + + {file.name} +
    +
    + {file.path} +
    +
    + ); + })} + + )} +
    + )} +
    , + document.body + ); + }} + /> + ); +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/image-keyboard-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/image-keyboard-plugin.tsx new file mode 100644 index 00000000..fb81267a --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/image-keyboard-plugin.tsx @@ -0,0 +1,51 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + COMMAND_PRIORITY_LOW, + $getSelection, + $isNodeSelection, +} from 'lexical'; +import { $isImageNode } from '../nodes/image-node'; + +export function ImageKeyboardPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + const deleteSelectedImages = (): boolean => { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) return false; + + const nodes = selection.getNodes(); + const imageNodes = nodes.filter($isImageNode); + + if (imageNodes.length === 0) return false; + + for (const imageNode of imageNodes) { + imageNode.remove(); + } + + return true; + }; + + const unregisterBackspace = editor.registerCommand( + KEY_BACKSPACE_COMMAND, + () => deleteSelectedImages(), + COMMAND_PRIORITY_LOW + ); + + const unregisterDelete = editor.registerCommand( + KEY_DELETE_COMMAND, + () => deleteSelectedImages(), + COMMAND_PRIORITY_LOW + ); + + return () => { + unregisterBackspace(); + unregisterDelete(); + }; + }, [editor]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/keyboard-commands-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/keyboard-commands-plugin.tsx new file mode 100644 index 00000000..09994b08 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/keyboard-commands-plugin.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + KEY_MODIFIER_COMMAND, + KEY_ENTER_COMMAND, + COMMAND_PRIORITY_NORMAL, + COMMAND_PRIORITY_HIGH, +} from 'lexical'; + +type Props = { + onCmdEnter?: () => void; + onShiftCmdEnter?: () => void; +}; + +export function KeyboardCommandsPlugin({ onCmdEnter, onShiftCmdEnter }: Props) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!onCmdEnter && !onShiftCmdEnter) return; + + // Handle the modifier command to trigger the callbacks + const unregisterModifier = editor.registerCommand( + KEY_MODIFIER_COMMAND, + (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key !== 'Enter') { + return false; + } + + event.preventDefault(); + event.stopPropagation(); + + if (event.shiftKey && onShiftCmdEnter) { + onShiftCmdEnter(); + return true; + } + + if (!event.shiftKey && onCmdEnter) { + onCmdEnter(); + return true; + } + + return false; + }, + COMMAND_PRIORITY_NORMAL + ); + + // Block KEY_ENTER_COMMAND when CMD/Ctrl is pressed to prevent + // RichTextPlugin from inserting a new line + const unregisterEnter = editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent | null) => { + if (event && (event.metaKey || event.ctrlKey)) { + return true; // Mark as handled, preventing line break insertion + } + return false; + }, + COMMAND_PRIORITY_HIGH + ); + + return () => { + unregisterModifier(); + unregisterEnter(); + }; + }, [editor, onCmdEnter, onShiftCmdEnter]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/markdown-sync-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/markdown-sync-plugin.tsx new file mode 100644 index 00000000..86dd0534 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/markdown-sync-plugin.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $convertToMarkdownString, + $convertFromMarkdownString, + type Transformer, +} from '@lexical/markdown'; +import { $getRoot, type EditorState } from 'lexical'; + +type MarkdownSyncPluginProps = { + value: string; + onChange?: (markdown: string) => void; + onEditorStateChange?: (state: EditorState) => void; + editable: boolean; + transformers: Transformer[]; +}; + +/** + * Handles bidirectional markdown synchronization between Lexical editor and external state. + * + * Uses an internal ref to prevent infinite update loops during bidirectional sync. + */ +export function MarkdownSyncPlugin({ + value, + onChange, + onEditorStateChange, + editable, + transformers, +}: MarkdownSyncPluginProps) { + const [editor] = useLexicalComposerContext(); + const lastSerializedRef = useRef(undefined); + + // Handle editable state + useEffect(() => { + editor.setEditable(editable); + }, [editor, editable]); + + // Handle controlled value changes (external → editor) + useEffect(() => { + if (value === lastSerializedRef.current) return; + + try { + editor.update(() => { + if (value.trim() === '') { + $getRoot().clear(); + } else { + $convertFromMarkdownString(value, transformers); + } + }); + lastSerializedRef.current = value; + } catch (err) { + console.error('Failed to parse markdown', err); + } + }, [editor, value, transformers]); + + // Handle editor changes (editor → external) + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + onEditorStateChange?.(editorState); + if (!onChange) return; + + const markdown = editorState.read(() => + $convertToMarkdownString(transformers) + ); + if (markdown === lastSerializedRef.current) return; + + lastSerializedRef.current = markdown; + onChange(markdown); + }); + }, [editor, onChange, onEditorStateChange, transformers]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/read-only-link-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/read-only-link-plugin.tsx new file mode 100644 index 00000000..a0a27c37 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/read-only-link-plugin.tsx @@ -0,0 +1,125 @@ +import { useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { LinkNode } from '@lexical/link'; + +/** + * Sanitize href to block dangerous protocols. + * Returns undefined if the href is blocked. + */ +function sanitizeHref(href?: string): string | undefined { + if (typeof href !== 'string') return undefined; + const trimmed = href.trim(); + // Block dangerous protocols + if (/^(javascript|vbscript|data):/i.test(trimmed)) return undefined; + // Allow anchors and common relative forms (but they'll be disabled) + if ( + trimmed.startsWith('#') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + trimmed.startsWith('/') + ) + return trimmed; + // Allow only https + if (/^https:\/\//i.test(trimmed)) return trimmed; + // Block everything else by default + return undefined; +} + +/** + * Check if href is an external HTTPS link. + */ +function isExternalHref(href?: string): boolean { + if (!href) return false; + return /^https:\/\//i.test(href); +} + +/** + * Plugin that handles link sanitization and security attributes in read-only mode. + * - Blocks dangerous protocols (javascript:, vbscript:, data:) + * - External HTTPS links: clickable with target="_blank" and rel="noopener noreferrer" + * - Internal/relative links: rendered but not clickable + */ +export function ReadOnlyLinkPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + // Register a mutation listener to modify link DOM elements + const unregister = editor.registerMutationListener( + LinkNode, + (mutations) => { + for (const [nodeKey, mutation] of mutations) { + if (mutation === 'destroyed') continue; + + const dom = editor.getElementByKey(nodeKey); + if (!dom || !(dom instanceof HTMLAnchorElement)) continue; + + const href = dom.getAttribute('href'); + const safeHref = sanitizeHref(href ?? undefined); + + if (!safeHref) { + // Dangerous protocol - remove href entirely + dom.removeAttribute('href'); + dom.style.cursor = 'not-allowed'; + dom.style.pointerEvents = 'none'; + continue; + } + + const isExternal = isExternalHref(safeHref); + + if (isExternal) { + // External HTTPS link - add security attributes + dom.setAttribute('target', '_blank'); + dom.setAttribute('rel', 'noopener noreferrer'); + dom.onclick = (e) => e.stopPropagation(); + } else { + // Internal/relative link - disable clicking + dom.removeAttribute('href'); + dom.style.cursor = 'not-allowed'; + dom.style.pointerEvents = 'none'; + dom.setAttribute('role', 'link'); + dom.setAttribute('aria-disabled', 'true'); + dom.title = href ?? ''; + } + } + } + ); + + // Also handle existing links on mount by triggering a read + editor.getEditorState().read(() => { + const root = editor.getRootElement(); + if (!root) return; + + const links = root.querySelectorAll('a'); + links.forEach((link) => { + const href = link.getAttribute('href'); + const safeHref = sanitizeHref(href ?? undefined); + + if (!safeHref) { + link.removeAttribute('href'); + link.style.cursor = 'not-allowed'; + link.style.pointerEvents = 'none'; + return; + } + + const isExternal = isExternalHref(safeHref); + + if (isExternal) { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + link.onclick = (e) => e.stopPropagation(); + } else { + link.removeAttribute('href'); + link.style.cursor = 'not-allowed'; + link.style.pointerEvents = 'none'; + link.setAttribute('role', 'link'); + link.setAttribute('aria-disabled', 'true'); + link.title = href ?? ''; + } + }); + }); + + return unregister; + }, [editor]); + + return null; +} diff --git a/frontend/src/components/ui/wysiwyg/plugins/toolbar-plugin.tsx b/frontend/src/components/ui/wysiwyg/plugins/toolbar-plugin.tsx new file mode 100644 index 00000000..e2f07798 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/plugins/toolbar-plugin.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useState, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + SELECTION_CHANGE_COMMAND, + COMMAND_PRIORITY_CRITICAL, +} from 'lexical'; +import { Bold, Italic, Underline, Strikethrough, Code } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const TOOLBAR_HEIGHT = 36; +const GAP = 8; +const VIEWPORT_PADDING = 10; + +function ToolbarButton({ + active, + onClick, + children, + title, +}: { + active?: boolean; + onClick: (e: React.MouseEvent) => void; + children: React.ReactNode; + title: string; +}) { + return ( + + ); +} + +export function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + + // Visibility and position state + const [isVisible, setIsVisible] = useState(false); + const [position, setPosition] = useState<{ + top: number; + left: number; + } | null>(null); + + // Text format state + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection) || selection.isCollapsed()) { + setIsVisible(false); + setPosition(null); + return; + } + + // Update text format state + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); + + // Check if selection has actual text content + const text = selection.getTextContent(); + if (!text || text.trim().length === 0) { + setIsVisible(false); + setPosition(null); + return; + } + + setIsVisible(true); + }, []); + + const updatePosition = useCallback(() => { + const domSelection = window.getSelection(); + if ( + !domSelection || + domSelection.rangeCount === 0 || + domSelection.isCollapsed + ) { + return; + } + + const range = domSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // Skip if rect is empty (happens during certain selection states) + if (rect.width === 0 && rect.height === 0) { + return; + } + + // Calculate toolbar width (approximate) + const toolbarWidth = 180; + + // Position above selection, centered + let top = rect.top - TOOLBAR_HEIGHT - GAP + window.scrollY; + let left = rect.left + rect.width / 2 - toolbarWidth / 2 + window.scrollX; + + // Flip below if not enough space above + if (rect.top < TOOLBAR_HEIGHT + GAP + VIEWPORT_PADDING) { + top = rect.bottom + GAP + window.scrollY; + } + + // Keep within viewport horizontally + left = Math.max( + VIEWPORT_PADDING, + Math.min(left, window.innerWidth - toolbarWidth - VIEWPORT_PADDING) + ); + + setPosition({ top, left }); + }, []); + + // Update toolbar state on selection change + useEffect(() => { + return editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateToolbar(); + return false; + }, + COMMAND_PRIORITY_CRITICAL + ); + }, [editor, updateToolbar]); + + // Update toolbar state on editor updates + useEffect(() => { + return editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }); + }, [editor, updateToolbar]); + + // Update position when visible + useLayoutEffect(() => { + if (!isVisible) return; + + updatePosition(); + + // Update position on scroll and resize + const handleUpdate = () => { + requestAnimationFrame(updatePosition); + }; + + window.addEventListener('scroll', handleUpdate, true); + window.addEventListener('resize', handleUpdate); + + return () => { + window.removeEventListener('scroll', handleUpdate, true); + window.removeEventListener('resize', handleUpdate); + }; + }, [isVisible, updatePosition]); + + // Hide toolbar when editor loses focus + useEffect(() => { + const rootElement = editor.getRootElement(); + if (!rootElement) return; + + const handleFocusOut = (e: FocusEvent) => { + // Don't hide if focus is moving to the toolbar itself + const relatedTarget = e.relatedTarget as HTMLElement | null; + if (relatedTarget?.closest('[data-floating-toolbar]')) { + return; + } + setIsVisible(false); + setPosition(null); + }; + + rootElement.addEventListener('focusout', handleFocusOut); + return () => { + rootElement.removeEventListener('focusout', handleFocusOut); + }; + }, [editor]); + + const iconSize = 16; + + // Don't render until we have both visibility and position + if (!isVisible || !position) { + return null; + } + + return createPortal( +
    + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')} + title="Bold (Cmd+B)" + > + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')} + title="Italic (Cmd+I)" + > + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')} + title="Underline (Cmd+U)" + > + + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough') + } + title="Strikethrough" + > + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')} + title="Inline Code" + > + + +
    , + document.body + ); +} diff --git a/frontend/src/components/ui/wysiwyg/transformers/code-block-transformer.ts b/frontend/src/components/ui/wysiwyg/transformers/code-block-transformer.ts new file mode 100644 index 00000000..dedb085c --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/transformers/code-block-transformer.ts @@ -0,0 +1,61 @@ +import { MultilineElementTransformer } from '@lexical/markdown'; +import { $createCodeNode, $isCodeNode, CodeNode } from '@lexical/code'; +import { $createTextNode } from 'lexical'; + +/** + * Code block transformer for markdown imports (paste operations). + * Requires both opening and closing ``` to create a code block. + * + * For typing detection, see CodeBlockShortcutPlugin. + */ +export const CODE_BLOCK_TRANSFORMER: MultilineElementTransformer = { + type: 'multiline-element', + dependencies: [CodeNode], + regExpStart: /^```([\w-]*)$/, + regExpEnd: { + optional: true, + regExp: /^```$/, + }, + replace: ( + _rootNode, + _children, + startMatch, + endMatch, + linesInBetween, + isImport + ) => { + // Only handle imports - typing is handled by CodeBlockShortcutPlugin + if (!isImport) { + return false; + } + + // Require closing backticks for imports + if (!endMatch) { + return false; + } + + const language = startMatch[1] || undefined; + const codeNode = $createCodeNode(language); + + if (linesInBetween) { + const code = linesInBetween.join('\n'); + if (code) { + codeNode.append($createTextNode(code)); + } + } + + _rootNode.append(codeNode); + }, + export: (node) => { + if (!$isCodeNode(node)) { + return null; + } + const textContent = node.getTextContent(); + return ( + '```' + + (node.getLanguage() || '') + + (textContent ? '\n' + textContent : '') + + '\n```' + ); + }, +}; diff --git a/frontend/src/components/ui/wysiwyg/transformers/image-transformer.ts b/frontend/src/components/ui/wysiwyg/transformers/image-transformer.ts new file mode 100644 index 00000000..3834661a --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/transformers/image-transformer.ts @@ -0,0 +1,21 @@ +import { TextMatchTransformer } from '@lexical/markdown'; +import { $createImageNode, ImageNode } from '../nodes/image-node'; + +export const IMAGE_TRANSFORMER: TextMatchTransformer = { + dependencies: [ImageNode], + export: (node) => { + if (node instanceof ImageNode) { + return `![${node.getAltText()}](${node.getSrc()})`; + } + return null; + }, + importRegExp: /!\[([^\]]*)\]\(([^)]+)\)/, + regExp: /!\[([^\]]*)\]\(([^)]+)\)$/, + replace: (textNode, match) => { + const [, altText, src] = match; + const imageNode = $createImageNode(src, altText); + textNode.replace(imageNode); + }, + trigger: ')', + type: 'text-match', +}; diff --git a/frontend/src/components/ui/wysiwyg/transformers/inline-code-transformer.ts b/frontend/src/components/ui/wysiwyg/transformers/inline-code-transformer.ts new file mode 100644 index 00000000..6b45ef41 --- /dev/null +++ b/frontend/src/components/ui/wysiwyg/transformers/inline-code-transformer.ts @@ -0,0 +1,27 @@ +import { TextMatchTransformer } from '@lexical/markdown'; +import { + $createInlineCodeNode, + $isInlineCodeNode, + InlineCodeNode, +} from '../nodes/inline-code-node'; + +export const INLINE_CODE_TRANSFORMER: TextMatchTransformer = { + dependencies: [InlineCodeNode], + export: (node) => { + if ($isInlineCodeNode(node)) { + return '`' + node.getCode() + '`'; + } + return null; + }, + // Match backtick-wrapped code during import (paste) + importRegExp: /`([^`]+)`/, + // Match at end of line while typing + regExp: /`([^`]+)`$/, + replace: (textNode, match) => { + const [, code] = match; + const inlineCodeNode = $createInlineCodeNode(code); + textNode.replace(inlineCodeNode); + }, + trigger: '`', + type: 'text-match', +}; diff --git a/frontend/src/contexts/ClickedElementsProvider.tsx b/frontend/src/contexts/ClickedElementsProvider.tsx index ce9aa43a..0ff01c63 100644 --- a/frontend/src/contexts/ClickedElementsProvider.tsx +++ b/frontend/src/contexts/ClickedElementsProvider.tsx @@ -4,6 +4,7 @@ import { useState, ReactNode, useEffect, + useCallback, } from 'react'; import type { OpenInEditorPayload, @@ -373,14 +374,14 @@ export function ClickedElementsProvider({ ); }; - const generateMarkdown = () => { + const generateMarkdown = useCallback(() => { if (elements.length === 0) return ''; const header = `## Clicked Elements (${elements.length})\n\n`; const body = elements .map((e) => formatClickedMarkdown(e, workspaceRoot || undefined)) .join('\n\n'); return header + body; - }; + }, [elements, workspaceRoot]); return ( void; processOrder: Record; isProcessGreyed: (processId?: string) => boolean; }; @@ -11,7 +17,6 @@ type RetryUiContextType = { const RetryUiContext = createContext(null); export function RetryUiProvider({ - attemptId, children, }: { attemptId?: string; @@ -19,7 +24,10 @@ export function RetryUiProvider({ }) { const { executionProcessesAll: executionProcesses } = useExecutionProcessesContext(); - const { retryDraft } = useDraftStream(attemptId); + + const [activeRetryProcessId, setActiveRetryProcessId] = useState< + string | null + >(null); const processOrder = useMemo(() => { const order: Record = {}; @@ -29,20 +37,20 @@ export function RetryUiProvider({ return order; }, [executionProcesses]); - const activeRetryProcessId = retryDraft?.retry_process_id ?? null; - const targetOrder = activeRetryProcessId - ? (processOrder[activeRetryProcessId] ?? -1) - : -1; - - const isProcessGreyed = (processId?: string) => { - if (!activeRetryProcessId || !processId) return false; - const idx = processOrder[processId]; - if (idx === undefined) return false; - return idx >= targetOrder; // grey target and later - }; + const isProcessGreyed = useCallback( + (processId?: string) => { + if (!activeRetryProcessId || !processId) return false; + const activeOrder = processOrder[activeRetryProcessId]; + const thisOrder = processOrder[processId]; + // Grey out processes that come AFTER the retry target + return thisOrder > activeOrder; + }, + [activeRetryProcessId, processOrder] + ); const value: RetryUiContextType = { activeRetryProcessId, + setActiveRetryProcessId, processOrder, isProcessGreyed, }; @@ -57,6 +65,7 @@ export function useRetryUi() { if (!ctx) return { activeRetryProcessId: null, + setActiveRetryProcessId: () => {}, processOrder: {}, isProcessGreyed: () => false, } as RetryUiContextType; diff --git a/frontend/src/contexts/ReviewProvider.tsx b/frontend/src/contexts/ReviewProvider.tsx index bf658355..f11091ed 100644 --- a/frontend/src/contexts/ReviewProvider.tsx +++ b/frontend/src/contexts/ReviewProvider.tsx @@ -5,6 +5,7 @@ import { useState, ReactNode, useEffect, + useCallback, } from 'react'; import { genId } from '@/utils/id'; @@ -96,7 +97,7 @@ export function ReviewProvider({ }); }; - const generateReviewMarkdown = () => { + const generateReviewMarkdown = useCallback(() => { if (comments.length === 0) return ''; const commentsNum = comments.length; @@ -125,7 +126,7 @@ export function ReviewProvider({ .join('\n'); return header + commentsMd; - }; + }, [comments]); return ( | null; -}; - -export function useDefaultVariant({ processes, profiles }: Args) { - const latestProfileId = useMemo(() => { - if (!processes?.length) return null; - - // Walk processes from newest to oldest and extract the first executor_profile_id - // from either the action itself or its next_action (when current is a ScriptRequest). - const extractProfile = ( - action: ExecutorAction | null - ): ExecutorProfileId | null => { - let curr: ExecutorAction | null = action; - while (curr) { - const typ = curr.typ; - switch (typ.type) { - case 'CodingAgentInitialRequest': - case 'CodingAgentFollowUpRequest': - return typ.executor_profile_id; - case 'ScriptRequest': - curr = curr.next_action; - continue; - } - } - return null; - }; - return ( - processes - .slice() - .reverse() - .map((p) => extractProfile(p.executor_action ?? null)) - .find((pid) => pid !== null) ?? null - ); - }, [processes]); - - const defaultFollowUpVariant = latestProfileId?.variant ?? null; - - const [selectedVariant, setSelectedVariant] = useState( - defaultFollowUpVariant - ); - useEffect( - () => setSelectedVariant(defaultFollowUpVariant), - [defaultFollowUpVariant] - ); - - const currentProfile = useMemo(() => { - if (!latestProfileId) return null; - return profiles?.[latestProfileId.executor] ?? null; - }, [latestProfileId, profiles]); - - return { selectedVariant, setSelectedVariant, currentProfile } as const; -} diff --git a/frontend/src/hooks/follow-up/useDraftAutosave.ts b/frontend/src/hooks/follow-up/useDraftAutosave.ts deleted file mode 100644 index 965e89a4..00000000 --- a/frontend/src/hooks/follow-up/useDraftAutosave.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { - attemptsApi, - type UpdateFollowUpDraftRequest, - type UpdateRetryFollowUpDraftRequest, -} from '@/lib/api'; -import type { Draft, DraftResponse } from 'shared/types'; -import { useEffect, useRef, useState } from 'react'; - -export type SaveStatus = 'idle' | 'saving' | 'saved' | 'offline' | 'sent'; - -// Small helper to diff common draft fields -type BaseCurrent = { - prompt: string; - variant: string | null | undefined; - image_ids: string[] | null | undefined; -}; -type BaseServer = { - prompt?: string | null; - variant?: string | null; - image_ids?: string[] | null; -} | null; -type BasePayload = { - prompt?: string; - variant?: string | null; - image_ids?: string[]; -}; - -function diffBaseDraft(current: BaseCurrent, server: BaseServer): BasePayload { - const payload: BasePayload = {}; - const serverPrompt = (server?.prompt ?? '') || ''; - const serverVariant = server?.variant ?? null; - const serverIds = (server?.image_ids as string[] | undefined) ?? []; - - if (current.prompt !== serverPrompt) payload.prompt = current.prompt || ''; - if ((current.variant ?? null) !== serverVariant) - payload.variant = (current.variant ?? null) as string | null; - - const currIds = (current.image_ids as string[] | null) ?? []; - const idsEqual = - currIds.length === serverIds.length && - currIds.every((id, i) => id === serverIds[i]); - if (!idsEqual) payload.image_ids = currIds; - - return payload; -} - -function diffDraftPayload< - TExtra extends Record = Record, ->( - current: BaseCurrent, - server: BaseServer, - extra?: TExtra, - requireBaseChange: boolean = true -): (BasePayload & TExtra) | null { - const base = diffBaseDraft(current, server); - const baseChanged = Object.keys(base).length > 0; - if (!baseChanged && requireBaseChange) return null; - return { ...(extra as object), ...base } as BasePayload & TExtra; -} - -// Private core -function useDraftAutosaveCore({ - attemptId, - serverDraft, - current, - isDraftSending, - skipConditions = [], - buildPayload, - saveDraft, - fetchLatest, - debugLabel, -}: { - attemptId?: string; - serverDraft: TServer | null; - current: TCurrent; - isDraftSending: boolean; - skipConditions?: boolean[]; - buildPayload: ( - current: TCurrent, - serverDraft: TServer | null - ) => TPayload | null; - saveDraft: (attemptId: string, payload: TPayload) => Promise; - fetchLatest?: (attemptId: string) => Promise; - debugLabel?: string; -}) { - const [isSaving, setIsSaving] = useState(false); - const [saveStatus, setSaveStatus] = useState('idle'); - const lastSentRef = useRef(''); - const saveTimeoutRef = useRef(undefined); - - useEffect(() => { - if (!attemptId) return; - if (isDraftSending) { - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] skip: draft is sending`, { - attemptId, - }); - return; - } - if (skipConditions.some((c) => c)) { - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] skip: skipConditions`, { - attemptId, - skipConditions, - }); - return; - } - - const doSave = async () => { - const payload = buildPayload(current, serverDraft); - if (!payload) { - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] no changes`, { attemptId }); - return; - } - const payloadKey = JSON.stringify(payload); - if (payloadKey === lastSentRef.current) { - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] deduped identical payload`, { - attemptId, - payload, - }); - return; - } - lastSentRef.current = payloadKey; - - try { - setIsSaving(true); - setSaveStatus(navigator.onLine ? 'saving' : 'offline'); - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] saving`, { - attemptId, - payload, - }); - await saveDraft(attemptId, payload); - setSaveStatus('saved'); - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] saved`, { attemptId }); - } catch { - if (import.meta.env.DEV) - console.debug(`[autosave:${debugLabel}] error -> fetchLatest`, { - attemptId, - }); - if (fetchLatest) { - try { - await fetchLatest(attemptId); - } catch { - /* empty */ - } - } - setSaveStatus(navigator.onLine ? 'idle' : 'offline'); - } finally { - setIsSaving(false); - } - }; - - if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current); - saveTimeoutRef.current = window.setTimeout(doSave, 400); - return () => { - if (saveTimeoutRef.current) window.clearTimeout(saveTimeoutRef.current); - }; - }, [ - attemptId, - serverDraft, - current, - isDraftSending, - skipConditions, - buildPayload, - saveDraft, - fetchLatest, - debugLabel, - ]); - - return { isSaving, saveStatus } as const; -} - -type DraftData = Pick; - -type DraftArgs = { - attemptId?: string; - serverDraft: TServer | null; - current: TCurrent; - isDraftSending: boolean; - // Queue-related flags (used for follow_up; not used for retry) - isQueuedUI?: boolean; - isQueuing?: boolean; - isUnqueuing?: boolean; - // Discriminant - draftType?: 'follow_up' | 'retry'; -}; - -type FollowUpAutosaveArgs = DraftArgs & { - draftType?: 'follow_up'; -}; -type RetryAutosaveArgs = DraftArgs & { - draftType: 'retry'; -}; - -export function useDraftAutosave( - args: FollowUpAutosaveArgs | RetryAutosaveArgs -) { - const skipConditions = - args.draftType === 'retry' - ? [!args.serverDraft] - : [!!args.isQueuing, !!args.isUnqueuing, !!args.isQueuedUI]; - - return useDraftAutosaveCore< - Draft | RetryDraftResponse, - DraftData | RetryDraftData, - UpdateFollowUpDraftRequest | UpdateRetryFollowUpDraftRequest - >({ - attemptId: args.attemptId, - serverDraft: args.serverDraft as Draft | RetryDraftResponse | null, - current: args.current as DraftData | RetryDraftData, - isDraftSending: args.isDraftSending, - skipConditions, - debugLabel: (args.draftType ?? 'follow_up') as string, - buildPayload: (current, serverDraft) => { - if (args.draftType === 'retry') { - const c = current as RetryDraftData; - const s = serverDraft as RetryDraftResponse | null; - return diffDraftPayload( - c, - s, - { retry_process_id: c.retry_process_id }, - true - ) as UpdateRetryFollowUpDraftRequest | null; - } - const c = current as DraftData; - const s = serverDraft as Draft | null; - return diffDraftPayload(c, s) as UpdateFollowUpDraftRequest | null; - }, - saveDraft: (id, payload) => { - if (args.draftType === 'retry') { - return attemptsApi.saveDraft( - id, - 'retry', - payload as UpdateRetryFollowUpDraftRequest - ); - } - return attemptsApi.saveDraft( - id, - 'follow_up', - payload as UpdateFollowUpDraftRequest - ); - }, - fetchLatest: (id) => { - if (args.draftType === 'retry') return attemptsApi.getDraft(id, 'retry'); - return attemptsApi.getDraft(id, 'follow_up'); - }, - }); -} - -export type RetrySaveStatus = SaveStatus; - -type RetryDraftResponse = DraftResponse; - -type RetryDraftData = Pick< - DraftResponse, - 'prompt' | 'variant' | 'image_ids' -> & { - retry_process_id: string; -}; diff --git a/frontend/src/hooks/follow-up/useDraftEditor.ts b/frontend/src/hooks/follow-up/useDraftEditor.ts deleted file mode 100644 index d5b398e6..00000000 --- a/frontend/src/hooks/follow-up/useDraftEditor.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; -import type { Draft, ImageResponse } from 'shared/types'; -import { imagesApi } from '@/lib/api'; -import { useQuery } from '@tanstack/react-query'; - -type PartialDraft = Pick; - -type Args = { - draft: PartialDraft | null; - taskId: string; -}; - -export function useDraftEditor({ draft, taskId }: Args) { - const [message, setMessageInner] = useState(''); - const [localImages, setLocalImages] = useState([]); - const [newlyUploadedImageIds, setNewlyUploadedImageIds] = useState( - [] - ); - - const localDirtyRef = useRef(false); - const imagesDirtyRef = useRef(false); - - const isMessageLocallyDirty = useCallback(() => localDirtyRef.current, []); - - // Sync message with server when not locally dirty - useEffect(() => { - if (!draft) return; - const serverPrompt = draft.prompt || ''; - if (!localDirtyRef.current) { - setMessageInner(serverPrompt); - } else if (serverPrompt === message) { - // When server catches up to local text, clear dirty - localDirtyRef.current = false; - } - }, [draft, message]); - - // Fetch images for task via react-query and map to the draft's image_ids - const serverIds = (draft?.image_ids ?? []).filter(Boolean); - const idsKey = serverIds.join(','); - const imagesQuery = useQuery({ - queryKey: ['taskImagesForDraft', taskId, idsKey], - enabled: !!taskId, - queryFn: async () => { - const all = await imagesApi.getTaskImages(taskId); - const want = new Set(serverIds); - return all.filter((img) => want.has(img.id)); - }, - staleTime: 60_000, - }); - - const images = imagesDirtyRef.current - ? localImages - : (imagesQuery.data ?? []); - - const setMessage = (v: React.SetStateAction) => { - localDirtyRef.current = true; - if (typeof v === 'function') { - setMessageInner((prev) => v(prev)); - } else { - setMessageInner(v); - } - }; - - const setImages = (next: ImageResponse[]) => { - imagesDirtyRef.current = true; - setLocalImages(next); - }; - - const handleImageUploaded = useCallback((image: ImageResponse) => { - imagesDirtyRef.current = true; - setLocalImages((prev) => [...prev, image]); - setNewlyUploadedImageIds((prev) => [...prev, image.id]); - }, []); - - const clearImagesAndUploads = useCallback(() => { - imagesDirtyRef.current = false; - setLocalImages([]); - setNewlyUploadedImageIds([]); - }, []); - - return { - message, - setMessage, - images, - setImages, - newlyUploadedImageIds, - handleImageUploaded, - clearImagesAndUploads, - isMessageLocallyDirty, - } as const; -} diff --git a/frontend/src/hooks/follow-up/useDraftQueue.ts b/frontend/src/hooks/follow-up/useDraftQueue.ts deleted file mode 100644 index 4de1171e..00000000 --- a/frontend/src/hooks/follow-up/useDraftQueue.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback } from 'react'; -import { attemptsApi, type UpdateFollowUpDraftRequest } from '@/lib/api'; -import type { Draft, ImageResponse } from 'shared/types'; - -type Args = { - attemptId?: string; - draft: Draft | null; - message: string; - selectedVariant: string | null; - images: ImageResponse[]; -}; - -export function useDraftQueue({ - attemptId, - draft, - message, - selectedVariant, - images, -}: Args) { - const onQueue = useCallback(async (): Promise => { - if (!attemptId) return false; - if (draft?.queued) return true; - if (message.trim().length === 0) return false; - try { - const immediatePayload: Partial = { - prompt: message, - }; - if ((draft?.variant ?? null) !== (selectedVariant ?? null)) - immediatePayload.variant = (selectedVariant ?? null) as string | null; - const currentIds = images.map((img) => img.id); - const serverIds = (draft?.image_ids as string[] | undefined) ?? []; - const idsEqual = - currentIds.length === serverIds.length && - currentIds.every((id, i) => id === serverIds[i]); - if (!idsEqual) immediatePayload.image_ids = currentIds; - await attemptsApi.saveDraft( - attemptId, - 'follow_up', - immediatePayload as UpdateFollowUpDraftRequest - ); - const resp = await attemptsApi.setDraftQueue(attemptId, true); - return !!resp?.queued; - } finally { - // presentation-only state handled by caller - } - return false; - }, [ - attemptId, - draft?.variant, - draft?.image_ids, - draft?.queued, - images, - message, - selectedVariant, - ]); - - const onUnqueue = useCallback(async (): Promise => { - if (!attemptId) return false; - try { - const resp = await attemptsApi.setDraftQueue(attemptId, false); - return !!resp && !resp.queued; - } finally { - // presentation-only state handled by caller - } - return false; - }, [attemptId]); - - return { onQueue, onUnqueue } as const; -} diff --git a/frontend/src/hooks/follow-up/useDraftStream.ts b/frontend/src/hooks/follow-up/useDraftStream.ts deleted file mode 100644 index 07029860..00000000 --- a/frontend/src/hooks/follow-up/useDraftStream.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { applyPatch } from 'rfc6902'; -import type { Operation } from 'rfc6902'; -import useWebSocket from 'react-use-websocket'; -import type { Draft, DraftResponse } from 'shared/types'; -import { useProject } from '@/contexts/ProjectContext'; - -interface Drafts { - [attemptId: string]: { follow_up: Draft; retry: DraftResponse | null }; -} - -type DraftsContainer = { - drafts: Drafts; -}; - -type WsJsonPatchMsg = { JsonPatch: Operation[] }; -type WsFinishedMsg = { finished: boolean }; -type WsMsg = WsJsonPatchMsg | WsFinishedMsg; - -export function useDraftStream(attemptId?: string) { - const { projectId } = useProject(); - const drafts = useDraftsStreamState(projectId); - - const attemptDrafts = useMemo(() => { - if (!attemptId || !drafts) return null; - return drafts[attemptId] ?? null; - }, [drafts, attemptId]); - - return { - draft: attemptDrafts?.follow_up ?? null, - retryDraft: attemptDrafts?.retry ?? null, - isRetryLoaded: !!attemptDrafts, - isDraftLoaded: !!attemptDrafts, - } as const; -} - -function useDraftsStreamState(projectId?: string): Drafts | undefined { - const endpoint = useMemo( - () => - projectId - ? `/api/drafts/stream/ws?project_id=${encodeURIComponent(projectId)}` - : undefined, - [projectId] - ); - const wsUrl = useMemo(() => toWsUrl(endpoint), [endpoint]); - const isStreamEnabled = !!endpoint && !!wsUrl; - - const queryClient = useQueryClient(); - const initialData = useCallback((): DraftsContainer => ({ drafts: {} }), []); - const queryKey = useMemo(() => ['ws-json-patch', wsUrl], [wsUrl]); - - const { data } = useQuery({ - queryKey, - queryFn: initialData, // Defensive fallback (won't run because enabled: false) - enabled: false, // State holder only - data comes from WebSocket - staleTime: Infinity, - gcTime: 0, - initialData, - }); - - const { getWebSocket } = useWebSocket( - wsUrl ?? 'ws://invalid', - { - share: true, - shouldReconnect: () => true, - reconnectInterval: (attempt) => - Math.min(8000, 1000 * Math.pow(2, attempt)), - retryOnError: true, - onMessage: (event) => { - try { - const msg: WsMsg = JSON.parse(event.data); - if ('JsonPatch' in msg) { - const patches = msg.JsonPatch; - if (!patches.length) return; - queryClient.setQueryData(queryKey, (prev) => { - const base = prev ?? initialData(); - const next = structuredClone(base) as DraftsContainer; - applyPatch(next, patches); - return next; - }); - } else if ('finished' in msg) { - try { - getWebSocket()?.close(); - } catch { - /* noop */ - } - } - } catch (e) { - console.error('Failed to process WebSocket message:', e); - } - }, - }, - isStreamEnabled - ); - - return isStreamEnabled ? data.drafts : undefined; -} - -function toWsUrl(endpoint?: string): string | undefined { - if (!endpoint) return undefined; - try { - const url = new URL(endpoint, window.location.origin); - url.protocol = url.protocol.replace('http', 'ws'); - return url.toString(); - } catch { - return undefined; - } -} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 00acf937..7317f83e 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -27,3 +27,5 @@ export { useOrganizationSelection } from './useOrganizationSelection'; export { useOrganizationMembers } from './useOrganizationMembers'; export { useOrganizationInvitations } from './useOrganizationInvitations'; export { useOrganizationMutations } from './useOrganizationMutations'; +export { useVariant } from './useVariant'; +export { useRetryProcess } from './useRetryProcess'; diff --git a/frontend/src/hooks/useDebouncedCallback.ts b/frontend/src/hooks/useDebouncedCallback.ts new file mode 100644 index 00000000..9782e95d --- /dev/null +++ b/frontend/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,48 @@ +import { useRef, useEffect } from 'react'; + +/** + * Returns a debounced version of the callback that delays invocation + * until after `delay` milliseconds have elapsed since the last call. + * Also returns a cancel function to clear any pending invocation. + */ +export function useDebouncedCallback( + callback: (...args: Args) => void, + delay: number +): { debounced: (...args: Args) => void; cancel: () => void } { + const timeoutRef = useRef | null>(null); + const callbackRef = useRef(callback); + + // Keep callback ref up to date + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + // Return stable function reference + const debouncedRef = useRef((...args: Args) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }); + + // Cancel function to clear pending timeout + const cancelRef = useRef(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + }); + + return { debounced: debouncedRef.current, cancel: cancelRef.current }; +} diff --git a/frontend/src/hooks/follow-up/useFollowUpSend.ts b/frontend/src/hooks/useFollowUpSend.ts similarity index 77% rename from frontend/src/hooks/follow-up/useFollowUpSend.ts rename to frontend/src/hooks/useFollowUpSend.ts index cff2cf60..09d649a3 100644 --- a/frontend/src/hooks/follow-up/useFollowUpSend.ts +++ b/frontend/src/hooks/useFollowUpSend.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { attemptsApi } from '@/lib/api'; -import type { ImageResponse, CreateFollowUpAttempt } from 'shared/types'; +import type { CreateFollowUpAttempt } from 'shared/types'; type Args = { attemptId?: string; @@ -9,13 +9,9 @@ type Args = { reviewMarkdown: string; clickedMarkdown?: string; selectedVariant: string | null; - images: ImageResponse[]; - newlyUploadedImageIds: string[]; clearComments: () => void; clearClickedElements?: () => void; - jumpToLogsTab: () => void; onAfterSendCleanup: () => void; - setMessage: (v: string) => void; }; export function useFollowUpSend({ @@ -25,13 +21,9 @@ export function useFollowUpSend({ reviewMarkdown, clickedMarkdown, selectedVariant, - images, - newlyUploadedImageIds, clearComments, clearClickedElements, - jumpToLogsTab, onAfterSendCleanup, - setMessage, }: Args) { const [isSendingFollowUp, setIsSendingFollowUp] = useState(false); const [followUpError, setFollowUpError] = useState(null); @@ -51,26 +43,18 @@ export function useFollowUpSend({ try { setIsSendingFollowUp(true); setFollowUpError(null); - const image_ids = - newlyUploadedImageIds.length > 0 - ? newlyUploadedImageIds - : images.length > 0 - ? images.map((img) => img.id) - : null; const body: CreateFollowUpAttempt = { prompt: finalPrompt, variant: selectedVariant, - image_ids, retry_process_id: null, force_when_dirty: null, perform_git_reset: null, }; await attemptsApi.followUp(attemptId, body); - setMessage(''); clearComments(); clearClickedElements?.(); onAfterSendCleanup(); - jumpToLogsTab(); + // Don't call jumpToLogsTab() - preserves focus on the follow-up editor } catch (error: unknown) { const err = error as { message?: string }; setFollowUpError( @@ -85,14 +69,10 @@ export function useFollowUpSend({ conflictMarkdown, reviewMarkdown, clickedMarkdown, - newlyUploadedImageIds, - images, selectedVariant, clearComments, clearClickedElements, - jumpToLogsTab, onAfterSendCleanup, - setMessage, ]); return { diff --git a/frontend/src/hooks/useImageMetadata.ts b/frontend/src/hooks/useImageMetadata.ts new file mode 100644 index 00000000..40ada3b4 --- /dev/null +++ b/frontend/src/hooks/useImageMetadata.ts @@ -0,0 +1,69 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { ImageMetadata } from 'shared/types'; +import type { LocalImageMetadata } from '@/components/ui/wysiwyg/context/task-attempt-context'; + +export function useImageMetadata( + taskAttemptId: string | undefined, + src: string, + taskId?: string | undefined, + localImages?: LocalImageMetadata[] +) { + const isVibeImage = src.startsWith('.vibe-images/'); + + // Synchronous lookup for local images + const localImage = useMemo( + () => localImages?.find((img) => img.path === src), + [localImages, src] + ); + + // Convert to ImageMetadata format + const localImageMetadata: ImageMetadata | null = useMemo( + () => + localImage + ? { + exists: true, + file_name: localImage.file_name, + path: localImage.path, + size_bytes: BigInt(localImage.size_bytes), + format: localImage.format, + proxy_url: localImage.proxy_url, + } + : null, + [localImage] + ); + + const hasContext = !!taskAttemptId || !!taskId; + // Only fetch from API if: vibe image, has context, and NO local image + const shouldFetch = isVibeImage && hasContext && !localImage; + + const query = useQuery({ + queryKey: ['imageMetadata', taskAttemptId, taskId, src], + queryFn: async (): Promise => { + // Pure API logic - no local image handling + if (taskAttemptId) { + const res = await fetch( + `/api/task-attempts/${taskAttemptId}/images/metadata?path=${encodeURIComponent(src)}` + ); + const data = await res.json(); + return data.data as ImageMetadata | null; + } + if (taskId) { + const res = await fetch( + `/api/images/task/${taskId}/metadata?path=${encodeURIComponent(src)}` + ); + const data = await res.json(); + return data.data as ImageMetadata | null; + } + return null; + }, + enabled: shouldFetch, + staleTime: Infinity, + }); + + // Return local data if available, otherwise query result + return { + data: localImageMetadata ?? query.data, + isLoading: localImage ? false : query.isLoading, + }; +} diff --git a/frontend/src/hooks/useProcessRetry.ts b/frontend/src/hooks/useProcessRetry.ts deleted file mode 100644 index 272fd736..00000000 --- a/frontend/src/hooks/useProcessRetry.ts +++ /dev/null @@ -1,106 +0,0 @@ -// hooks/useProcessRetry.ts -import { useCallback, useState } from 'react'; -import { useAttemptExecution } from '@/hooks/useAttemptExecution'; -import { useBranchStatus } from '@/hooks/useBranchStatus'; -import { attemptsApi, executionProcessesApi } from '@/lib/api'; -import type { - ExecutionProcess, - TaskAttempt, - ExecutorActionType, -} from 'shared/types'; - -/** - * Reusable hook to retry a process given its executionProcessId and a new prompt. - * Handles: - * - Preventing retry while anything is running (or that process is already running) - * - Optional worktree reset (via modal) - * - Variant extraction for coding-agent processes - * - Refetching attempt + branch data after replace - */ -export function useProcessRetry(attempt: TaskAttempt | undefined) { - const attemptId = attempt?.id; - - // Fetch attempt + branch state the same way your component did - const { attemptData, isAttemptRunning } = useAttemptExecution(attemptId); - useBranchStatus(attemptId); - - const [busy, setBusy] = useState(false); - - // Convenience lookups - const getProcessById = useCallback( - (pid: string): ExecutionProcess | undefined => - (attemptData.processes || []).find((p) => p.id === pid), - [attemptData.processes] - ); - - /** - * Returns whether a process is currently allowed to retry, and why not. - * Useful if you want to gray out buttons in any component. - */ - const getRetryDisabledState = useCallback( - (pid: string) => { - const proc = getProcessById(pid); - const isRunningProc = proc?.status === 'running'; - const disabled = busy || isAttemptRunning || isRunningProc; - let reason: string | undefined; - if (isRunningProc) reason = 'Finish or stop this run to retry.'; - else if (isAttemptRunning) - reason = 'Cannot retry while an agent is running.'; - else if (busy) reason = 'Retry in progress.'; - return { disabled, reason }; - }, - [busy, isAttemptRunning, getProcessById] - ); - - /** - * Primary entrypoint: retry a process with a new prompt. - */ - // Initialize retry mode by creating a retry draft populated from the process - const startRetry = useCallback( - async (executionProcessId: string, newPrompt: string) => { - if (!attemptId) return; - const proc = getProcessById(executionProcessId); - if (!proc) return; - const { disabled } = getRetryDisabledState(executionProcessId); - if (disabled) return; - - let variant: string | null = null; - try { - const details = - await executionProcessesApi.getDetails(executionProcessId); - const typ: ExecutorActionType | undefined = - details?.executor_action?.typ; - if ( - typ && - (typ.type === 'CodingAgentInitialRequest' || - typ.type === 'CodingAgentFollowUpRequest') - ) { - variant = typ.executor_profile_id?.variant ?? null; - } - } catch { - /* ignore */ - } - - setBusy(true); - try { - await attemptsApi.saveDraft(attemptId, 'retry', { - retry_process_id: executionProcessId, - prompt: newPrompt, - variant, - image_ids: [], - version: null, - }); - } finally { - setBusy(false); - } - }, - [attemptId, getProcessById, getRetryDisabledState] - ); - - return { - startRetry, - getRetryDisabledState, - }; -} - -export type UseProcessRetryReturn = ReturnType; diff --git a/frontend/src/hooks/useQueueStatus.ts b/frontend/src/hooks/useQueueStatus.ts new file mode 100644 index 00000000..db5a2d39 --- /dev/null +++ b/frontend/src/hooks/useQueueStatus.ts @@ -0,0 +1,86 @@ +import { useState, useCallback, useEffect } from 'react'; +import { queueApi } from '@/lib/api'; +import type { QueueStatus, QueuedMessage } from 'shared/types'; + +interface UseQueueStatusResult { + /** Current queue status */ + queueStatus: QueueStatus; + /** Whether a message is currently queued */ + isQueued: boolean; + /** The queued message if any */ + queuedMessage: QueuedMessage | null; + /** Whether an operation is in progress */ + isLoading: boolean; + /** Queue a new message */ + queueMessage: (message: string, variant: string | null) => Promise; + /** Cancel the queued message */ + cancelQueue: () => Promise; + /** Refresh the queue status from the server */ + refresh: () => Promise; +} + +export function useQueueStatus(attemptId?: string): UseQueueStatusResult { + const [queueStatus, setQueueStatus] = useState({ + status: 'empty', + }); + const [isLoading, setIsLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!attemptId) return; + try { + const status = await queueApi.getStatus(attemptId); + setQueueStatus(status); + } catch (e) { + console.error('Failed to fetch queue status:', e); + } + }, [attemptId]); + + const queueMessage = useCallback( + async (message: string, variant: string | null) => { + if (!attemptId) return; + setIsLoading(true); + try { + const status = await queueApi.queue(attemptId, { message, variant }); + setQueueStatus(status); + } finally { + setIsLoading(false); + } + }, + [attemptId] + ); + + const cancelQueue = useCallback(async () => { + if (!attemptId) return; + setIsLoading(true); + try { + const status = await queueApi.cancel(attemptId); + setQueueStatus(status); + } finally { + setIsLoading(false); + } + }, [attemptId]); + + // Fetch initial status when attemptId changes + useEffect(() => { + if (attemptId) { + refresh(); + } else { + setQueueStatus({ status: 'empty' }); + } + }, [attemptId, refresh]); + + const isQueued = queueStatus.status === 'queued'; + const queuedMessage = isQueued + ? (queueStatus as Extract).message + : null; + + return { + queueStatus, + isQueued, + queuedMessage, + isLoading, + queueMessage, + cancelQueue, + refresh, + }; +} diff --git a/frontend/src/hooks/useRetryProcess.ts b/frontend/src/hooks/useRetryProcess.ts new file mode 100644 index 00000000..47baef8e --- /dev/null +++ b/frontend/src/hooks/useRetryProcess.ts @@ -0,0 +1,76 @@ +import { useMutation } from '@tanstack/react-query'; +import { attemptsApi } from '@/lib/api'; +import { + RestoreLogsDialog, + type RestoreLogsDialogResult, +} from '@/components/dialogs'; +import type { BranchStatus, ExecutionProcess } from 'shared/types'; + +export interface RetryProcessParams { + message: string; + variant: string | null; + executionProcessId: string; + branchStatus: BranchStatus | undefined; + processes: ExecutionProcess[] | undefined; +} + +class RetryDialogCancelledError extends Error { + constructor() { + super('Retry dialog was cancelled'); + this.name = 'RetryDialogCancelledError'; + } +} + +export function useRetryProcess( + attemptId: string, + onSuccess?: () => void, + onError?: (err: unknown) => void +) { + return useMutation({ + mutationFn: async ({ + message, + variant, + executionProcessId, + branchStatus, + processes, + }: RetryProcessParams) => { + // Ask user for confirmation - dialog fetches its own preflight data + let modalResult: RestoreLogsDialogResult | undefined; + try { + modalResult = await RestoreLogsDialog.show({ + attemptId, + executionProcessId, + branchStatus, + processes, + }); + } catch { + throw new RetryDialogCancelledError(); + } + if (!modalResult || modalResult.action !== 'confirmed') { + throw new RetryDialogCancelledError(); + } + + // Send the retry request + await attemptsApi.followUp(attemptId, { + prompt: message, + variant, + retry_process_id: executionProcessId, + force_when_dirty: modalResult.forceWhenDirty ?? false, + perform_git_reset: modalResult.performGitReset ?? true, + }); + }, + onSuccess: () => { + onSuccess?.(); + }, + onError: (err) => { + // Don't report cancellation as an error + if (err instanceof RetryDialogCancelledError) { + return; + } + console.error('Failed to send retry:', err); + onError?.(err); + }, + }); +} + +export { RetryDialogCancelledError }; diff --git a/frontend/src/hooks/useScratch.ts b/frontend/src/hooks/useScratch.ts new file mode 100644 index 00000000..c03a3b73 --- /dev/null +++ b/frontend/src/hooks/useScratch.ts @@ -0,0 +1,62 @@ +import { useCallback } from 'react'; +import { useJsonPatchWsStream } from './useJsonPatchWsStream'; +import { scratchApi } from '@/lib/api'; +import { ScratchType, type Scratch, type UpdateScratch } from 'shared/types'; + +type ScratchState = { + scratch: Scratch | null; +}; + +export interface UseScratchResult { + scratch: Scratch | null; + isLoading: boolean; + isConnected: boolean; + error: string | null; + updateScratch: (update: UpdateScratch) => Promise; + deleteScratch: () => Promise; +} + +/** + * Stream a single scratch item via WebSocket (JSON Patch). + * Server sends the scratch object directly at /scratch. + */ +export const useScratch = ( + scratchType: ScratchType, + id: string +): UseScratchResult => { + const endpoint = scratchApi.getStreamUrl(scratchType, id); + + const initialData = useCallback((): ScratchState => ({ scratch: null }), []); + + const { data, isConnected, error } = useJsonPatchWsStream( + endpoint, + true, + initialData + ); + + // Treat deleted scratches as null + const rawScratch = data?.scratch as (Scratch & { deleted?: boolean }) | null; + const scratch = rawScratch?.deleted ? null : rawScratch; + + const updateScratch = useCallback( + async (update: UpdateScratch) => { + await scratchApi.update(scratchType, id, update); + }, + [scratchType, id] + ); + + const deleteScratch = useCallback(async () => { + await scratchApi.delete(scratchType, id); + }, [scratchType, id]); + + const isLoading = !data && !error && !isConnected; + + return { + scratch, + isLoading, + isConnected, + error, + updateScratch, + deleteScratch, + }; +}; diff --git a/frontend/src/hooks/useVariant.ts b/frontend/src/hooks/useVariant.ts new file mode 100644 index 00000000..df02d44c --- /dev/null +++ b/frontend/src/hooks/useVariant.ts @@ -0,0 +1,42 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type Args = { + processVariant: string | null; + scratchVariant?: string | null; +}; + +/** + * Hook to manage variant selection with priority: + * 1. User dropdown selection (current session) - highest priority + * 2. Scratch-persisted variant (from previous session) + * 3. Last execution process variant (fallback) + */ +export function useVariant({ processVariant, scratchVariant }: Args) { + // Track if user has explicitly selected a variant this session + const hasUserSelectionRef = useRef(false); + + // Compute initial value: scratch takes priority over process + const getInitialVariant = () => + scratchVariant !== undefined ? scratchVariant : processVariant; + + const [selectedVariant, setSelectedVariantState] = useState( + getInitialVariant + ); + + // Sync state when inputs change (if user hasn't made a selection) + useEffect(() => { + if (hasUserSelectionRef.current) return; + + const newVariant = + scratchVariant !== undefined ? scratchVariant : processVariant; + setSelectedVariantState(newVariant); + }, [scratchVariant, processVariant]); + + // When user explicitly selects a variant, mark it and update state + const setSelectedVariant = useCallback((variant: string | null) => { + hasUserSelectionRef.current = true; + setSelectedVariantState(variant); + }, []); + + return { selectedVariant, setSelectedVariant } as const; +} diff --git a/frontend/src/i18n/locales/en/tasks.json b/frontend/src/i18n/locales/en/tasks.json index 5092fecc..ce25a9d8 100644 --- a/frontend/src/i18n/locales/en/tasks.json +++ b/frontend/src/i18n/locales/en/tasks.json @@ -148,7 +148,10 @@ "unqueuing": "Unqueuing…", "edit": "Edit", "queuing": "Queuing…", - "queueForNextTurn": "Queue for next turn" + "queueForNextTurn": "Queue for next turn", + "queue": "Queue", + "cancelQueue": "Cancel Queue", + "queuedMessage": "Message queued - will execute when current run finishes" }, "todos": { "title_one": "Todos ({{count}})", diff --git a/frontend/src/i18n/locales/es/tasks.json b/frontend/src/i18n/locales/es/tasks.json index edb46492..7d6f7f66 100644 --- a/frontend/src/i18n/locales/es/tasks.json +++ b/frontend/src/i18n/locales/es/tasks.json @@ -180,7 +180,10 @@ "resolveConflicts": "Resolve conflicts", "send": "Send", "stop": "Stop", - "unqueuing": "Unqueuing…" + "unqueuing": "Unqueuing…", + "queue": "Encolar", + "cancelQueue": "Cancelar cola", + "queuedMessage": "Mensaje en cola - se ejecutará cuando finalice la ejecución actual" }, "git": { "branch": { diff --git a/frontend/src/i18n/locales/ja/tasks.json b/frontend/src/i18n/locales/ja/tasks.json index 6c13fbfd..fd2484f2 100644 --- a/frontend/src/i18n/locales/ja/tasks.json +++ b/frontend/src/i18n/locales/ja/tasks.json @@ -180,7 +180,10 @@ "resolveConflicts": "Resolve conflicts", "send": "Send", "stop": "Stop", - "unqueuing": "Unqueuing…" + "unqueuing": "Unqueuing…", + "queue": "キューに追加", + "cancelQueue": "キューをキャンセル", + "queuedMessage": "メッセージがキューに追加されました - 現在の実行が完了すると実行されます" }, "git": { "branch": { diff --git a/frontend/src/i18n/locales/ko/tasks.json b/frontend/src/i18n/locales/ko/tasks.json index 35f0639e..23f92593 100644 --- a/frontend/src/i18n/locales/ko/tasks.json +++ b/frontend/src/i18n/locales/ko/tasks.json @@ -180,7 +180,10 @@ "resolveConflicts": "Resolve conflicts", "send": "Send", "stop": "Stop", - "unqueuing": "Unqueuing…" + "unqueuing": "Unqueuing…", + "queue": "대기열에 추가", + "cancelQueue": "대기열 취소", + "queuedMessage": "메시지가 대기열에 추가됨 - 현재 실행이 완료되면 실행됩니다" }, "git": { "branch": { diff --git a/frontend/src/keyboard/hooks.ts b/frontend/src/keyboard/hooks.ts index 67ef25be..a9600be7 100644 --- a/frontend/src/keyboard/hooks.ts +++ b/frontend/src/keyboard/hooks.ts @@ -99,14 +99,6 @@ export const useKeyApproveRequest = createSemanticHook(Action.APPROVE_REQUEST); */ export const useKeyDenyApproval = createSemanticHook(Action.DENY_APPROVAL); -/** - * Cycle variant action - typically Shift+Tab - * - * @example - * useKeyCycleVariant(() => cycleToNextVariant(), { scope: Scope.FOLLOW_UP }); - */ -export const useKeyCycleVariant = createSemanticHook(Action.CYCLE_VARIANT); - /** * Submit follow-up action - typically Cmd+Enter * Intelligently sends or queues based on current state (running vs idle) diff --git a/frontend/src/keyboard/registry.ts b/frontend/src/keyboard/registry.ts index 34e0e136..d400fa52 100644 --- a/frontend/src/keyboard/registry.ts +++ b/frontend/src/keyboard/registry.ts @@ -25,7 +25,6 @@ export enum Action { DELETE_TASK = 'delete_task', APPROVE_REQUEST = 'approve_request', DENY_APPROVAL = 'deny_approval', - CYCLE_VARIANT = 'cycle_variant', SUBMIT_FOLLOW_UP = 'submit_follow_up', SUBMIT_TASK = 'submit_task', SUBMIT_TASK_ALT = 'submit_task_alt', @@ -191,13 +190,6 @@ export const keyBindings: KeyBinding[] = [ }, // Follow-up actions - { - action: Action.CYCLE_VARIANT, - keys: 'shift+tab', - scopes: [Scope.FOLLOW_UP], - description: 'Cycle between agent configurations', - group: 'Follow-up', - }, { action: Action.SUBMIT_FOLLOW_UP, keys: 'meta+enter', diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f64335a8..49f19a0a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,7 +5,6 @@ import { ApiResponse, BranchStatus, Config, - CommitInfo, CreateFollowUpAttempt, EditorType, CreateGitHubPrRequest, @@ -32,13 +31,10 @@ import { UpdateTask, UpdateTag, UserSystemInfo, - UpdateRetryFollowUpDraftRequest, McpServerQuery, UpdateMcpServersBody, GetMcpServerResponse, ImageResponse, - DraftResponse, - UpdateFollowUpDraftRequest, GitOperationError, ApprovalResponse, RebaseTaskAttemptRequest, @@ -73,13 +69,12 @@ import { OpenEditorResponse, OpenEditorRequest, CreatePrError, + Scratch, + ScratchType, + CreateScratch, + UpdateScratch, PushError, -} from 'shared/types'; - -// Re-export types for convenience -export type { - UpdateFollowUpDraftRequest, - UpdateRetryFollowUpDraftRequest, + QueueStatus, } from 'shared/types'; class ApiError extends Error { @@ -465,63 +460,6 @@ export const attemptsApi = { return handleApiResponse(response); }, - getDraft: async ( - attemptId: string, - type: 'follow_up' | 'retry' - ): Promise => { - const response = await makeRequest( - `/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}` - ); - return handleApiResponse(response); - }, - - saveDraft: async ( - attemptId: string, - type: 'follow_up' | 'retry', - data: UpdateFollowUpDraftRequest | UpdateRetryFollowUpDraftRequest - ): Promise => { - const response = await makeRequest( - `/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}`, - { - method: 'PUT', - body: JSON.stringify(data), - } - ); - return handleApiResponse(response); - }, - - deleteDraft: async ( - attemptId: string, - type: 'follow_up' | 'retry' - ): Promise => { - const response = await makeRequest( - `/api/task-attempts/${attemptId}/draft?type=${encodeURIComponent(type)}`, - { method: 'DELETE' } - ); - return handleApiResponse(response); - }, - - setDraftQueue: async ( - attemptId: string, - queued: boolean, - expectedQueued?: boolean, - expectedVersion?: number, - type: 'follow_up' | 'retry' = 'follow_up' - ): Promise => { - const response = await makeRequest( - `/api/task-attempts/${attemptId}/draft/queue?type=${encodeURIComponent(type)}`, - { - method: 'POST', - body: JSON.stringify({ - queued, - expected_queued: expectedQueued, - expected_version: expectedVersion, - }), - } - ); - return handleApiResponse(response); - }, - openEditor: async ( attemptId: string, data: OpenEditorRequest @@ -659,14 +597,6 @@ export const attemptsApi = { // Extra helpers export const commitsApi = { - getInfo: async (attemptId: string, sha: string): Promise => { - const response = await makeRequest( - `/api/task-attempts/${attemptId}/commit-info?sha=${encodeURIComponent( - sha - )}` - ); - return handleApiResponse(response); - }, compareToHead: async ( attemptId: string, sha: string @@ -880,6 +810,38 @@ export const imagesApi = { return handleApiResponse(response); }, + /** + * Upload an image for a task attempt and immediately copy it to the container. + * Returns the image with a file_path that can be used in markdown. + */ + uploadForAttempt: async ( + attemptId: string, + file: File + ): Promise => { + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch( + `/api/task-attempts/${attemptId}/images/upload`, + { + method: 'POST', + body: formData, + credentials: 'include', + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new ApiError( + `Failed to upload image: ${errorText}`, + response.status, + response + ); + } + + return handleApiResponse(response); + }, + delete: async (imageId: string): Promise => { const response = await makeRequest(`/api/images/${imageId}`, { method: 'DELETE', @@ -1052,3 +1014,86 @@ export const organizationsApi = { return handleApiResponse(response); }, }; + +// Scratch API +export const scratchApi = { + create: async ( + scratchType: ScratchType, + id: string, + data: CreateScratch + ): Promise => { + const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, { + method: 'POST', + body: JSON.stringify(data), + }); + return handleApiResponse(response); + }, + + get: async (scratchType: ScratchType, id: string): Promise => { + const response = await makeRequest(`/api/scratch/${scratchType}/${id}`); + return handleApiResponse(response); + }, + + update: async ( + scratchType: ScratchType, + id: string, + data: UpdateScratch + ): Promise => { + const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return handleApiResponse(response); + }, + + delete: async (scratchType: ScratchType, id: string): Promise => { + const response = await makeRequest(`/api/scratch/${scratchType}/${id}`, { + method: 'DELETE', + }); + return handleApiResponse(response); + }, + + getStreamUrl: (scratchType: ScratchType, id: string): string => + `/api/scratch/${scratchType}/${id}/stream/ws`, +}; + +// Queue API for task attempt follow-up messages +export const queueApi = { + /** + * Queue a follow-up message to be executed when current execution finishes + */ + queue: async ( + attemptId: string, + data: { message: string; variant: string | null } + ): Promise => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/queue`, + { + method: 'POST', + body: JSON.stringify(data), + } + ); + return handleApiResponse(response); + }, + + /** + * Cancel a queued follow-up message + */ + cancel: async (attemptId: string): Promise => { + const response = await makeRequest( + `/api/task-attempts/${attemptId}/queue`, + { + method: 'DELETE', + } + ); + return handleApiResponse(response); + }, + + /** + * Get the current queue status for a task attempt + */ + getStatus: async (attemptId: string): Promise => { + const response = await makeRequest(`/api/task-attempts/${attemptId}/queue`); + return handleApiResponse(response); + }, +}; diff --git a/frontend/src/lib/searchTagsAndFiles.ts b/frontend/src/lib/searchTagsAndFiles.ts new file mode 100644 index 00000000..d270413f --- /dev/null +++ b/frontend/src/lib/searchTagsAndFiles.ts @@ -0,0 +1,40 @@ +import { projectsApi, tagsApi } from '@/lib/api'; +import type { SearchResult, Tag } from 'shared/types'; + +interface FileSearchResult extends SearchResult { + name: string; +} + +export interface SearchResultItem { + type: 'tag' | 'file'; + tag?: Tag; + file?: FileSearchResult; +} + +export async function searchTagsAndFiles( + query: string, + projectId?: string +): Promise { + const results: SearchResultItem[] = []; + + // Fetch all tags and filter client-side + const tags = await tagsApi.list(); + const filteredTags = tags.filter((tag) => + tag.tag_name.toLowerCase().includes(query.toLowerCase()) + ); + results.push(...filteredTags.map((tag) => ({ type: 'tag' as const, tag }))); + + // Fetch files (if projectId is available and query has content) + if (projectId && query.length > 0) { + const fileResults = await projectsApi.searchFiles(projectId, query); + const fileSearchResults: FileSearchResult[] = fileResults.map((item) => ({ + ...item, + name: item.path.split('/').pop() || item.path, + })); + results.push( + ...fileSearchResults.map((file) => ({ type: 'file' as const, file })) + ); + } + + return results; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 9ad0df42..edc2f369 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -4,3 +4,11 @@ import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatFileSize(bytes: bigint | null | undefined): string { + if (!bytes) return ''; + const num = Number(bytes); + if (num < 1024) return `${num} B`; + if (num < 1024 * 1024) return `${(num / 1024).toFixed(1)} KB`; + return `${(num / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/frontend/src/pages/ProjectTasks.tsx b/frontend/src/pages/ProjectTasks.tsx index ae2c6388..6dc36f7d 100644 --- a/frontend/src/pages/ProjectTasks.tsx +++ b/frontend/src/pages/ProjectTasks.tsx @@ -992,7 +992,7 @@ export function ProjectTasks() {
    -
    +
    {followUp}
    diff --git a/frontend/src/styles/diff-style-overrides.css b/frontend/src/styles/diff-style-overrides.css index fdc85c64..55624337 100644 --- a/frontend/src/styles/diff-style-overrides.css +++ b/frontend/src/styles/diff-style-overrides.css @@ -531,7 +531,7 @@ Current colors taken from GitHub's CSS */ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs { - color: #24292e; + color: var(--syntax-punctuation); background: #ffffff; } @@ -543,7 +543,7 @@ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-type, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-variable.language_ { /* prettylights-syntax-keyword */ - color: #d73a49; + color: var(--syntax-keyword); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title, @@ -551,7 +551,7 @@ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.class_.inherited__, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-title.function_ { /* prettylights-syntax-entity */ - color: #6f42c1; + color: var(--syntax-function); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-attr, @@ -565,27 +565,27 @@ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-class, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-id { /* prettylights-syntax-constant */ - color: #005cc5; + color: var(--syntax-constant); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-regexp, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-string, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-meta .hljs-string { /* prettylights-syntax-string */ - color: #032f62; + color: var(--syntax-string); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-built_in, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-symbol { /* prettylights-syntax-variable */ - color: #e36209; + color: var(--syntax-variable); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-comment, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-code, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-formula { /* prettylights-syntax-comment */ - color: #6a737d; + color: var(--syntax-comment); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-name, @@ -593,17 +593,17 @@ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-tag, .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-selector-pseudo { /* prettylights-syntax-entity-tag */ - color: #22863a; + color: var(--syntax-tag); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-subst { /* prettylights-syntax-storage-modifier-import */ - color: #24292e; + color: var(--syntax-punctuation); } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-section { /* prettylights-syntax-markup-heading */ - color: #005cc5; + color: var(--syntax-constant); font-weight: bold; } @@ -614,25 +614,25 @@ .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-emphasis { /* prettylights-syntax-markup-italic */ - color: #24292e; + color: var(--syntax-punctuation); font-style: italic; } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-strong { /* prettylights-syntax-markup-bold */ - color: #24292e; + color: var(--syntax-punctuation); font-weight: bold; } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-addition { /* prettylights-syntax-markup-inserted */ - color: #22863a; + color: var(--syntax-tag); background-color: #f0fff4; } .diff-tailwindcss-wrapper .diff-line-syntax-raw .hljs-deletion { /* prettylights-syntax-markup-deleted */ - color: #b31d28; + color: var(--syntax-deleted); background-color: #ffeef0; } @@ -669,7 +669,7 @@ Current colors taken from GitHub's CSS */ .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs { - color: #c9d1d9; + color: var(--syntax-punctuation); background: #0d1117; } @@ -692,7 +692,7 @@ .diff-line-syntax-raw .hljs-variable.language_ { /* prettylights-syntax-keyword */ - color: #ff7b72; + color: var(--syntax-keyword); } .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-title, @@ -706,7 +706,7 @@ .diff-line-syntax-raw .hljs-title.function_ { /* prettylights-syntax-entity */ - color: #d2a8ff; + color: var(--syntax-function); } .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-attr, @@ -734,7 +734,7 @@ .diff-line-syntax-raw .hljs-selector-id { /* prettylights-syntax-constant */ - color: #79c0ff; + color: var(--syntax-constant); } .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-regexp, @@ -744,7 +744,7 @@ .hljs-meta .hljs-string { /* prettylights-syntax-string */ - color: #a5d6ff; + color: var(--syntax-string); } .diff-tailwindcss-wrapper[data-theme='dark'] @@ -754,7 +754,7 @@ .diff-line-syntax-raw .hljs-symbol { /* prettylights-syntax-variable */ - color: #ffa657; + color: var(--syntax-variable); } .diff-tailwindcss-wrapper[data-theme='dark'] @@ -765,7 +765,7 @@ .diff-line-syntax-raw .hljs-formula { /* prettylights-syntax-comment */ - color: #8b949e; + color: var(--syntax-comment); } .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-name, @@ -777,12 +777,12 @@ .diff-line-syntax-raw .hljs-selector-pseudo { /* prettylights-syntax-entity-tag */ - color: #7ee787; + color: var(--syntax-tag); } .diff-tailwindcss-wrapper[data-theme='dark'] .diff-line-syntax-raw .hljs-subst { /* prettylights-syntax-storage-modifier-import */ - color: #c9d1d9; + color: var(--syntax-punctuation); } .diff-tailwindcss-wrapper[data-theme='dark'] @@ -804,7 +804,7 @@ .diff-line-syntax-raw .hljs-emphasis { /* prettylights-syntax-markup-italic */ - color: #c9d1d9; + color: var(--syntax-punctuation); font-style: italic; } @@ -812,7 +812,7 @@ .diff-line-syntax-raw .hljs-strong { /* prettylights-syntax-markup-bold */ - color: #c9d1d9; + color: var(--syntax-punctuation); font-weight: bold; } @@ -828,7 +828,7 @@ .diff-line-syntax-raw .hljs-deletion { /* prettylights-syntax-markup-deleted */ - color: #ffdcd7; + color: var(--syntax-deleted); background-color: #67060c; } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 2d7f52f9..2c040160 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -41,6 +41,17 @@ --_console-foreground: 222.2 84% 4.9%; --_console-success: 138 69% 45%; --_console-error: 5 100% 69%; + + /* Syntax highlighting (light - GitHub) */ + --_syntax-keyword: #d73a49; + --_syntax-function: #6f42c1; + --_syntax-constant: #005cc5; + --_syntax-string: #032f62; + --_syntax-variable: #e36209; + --_syntax-comment: #6a737d; + --_syntax-tag: #22863a; + --_syntax-punctuation: #24292e; + --_syntax-deleted: #b31d28; } /* Dark defaults (used if no theme class but user prefers dark) */ @@ -77,6 +88,17 @@ --_console-foreground: 210 40% 98%; --_console-success: 138.5 76.5% 47.7%; --_console-error: 0 84.2% 60.2%; + + /* Syntax highlighting (dark - GitHub Dark) */ + --_syntax-keyword: #ff7b72; + --_syntax-function: #d2a8ff; + --_syntax-constant: #79c0ff; + --_syntax-string: #a5d6ff; + --_syntax-variable: #ffa657; + --_syntax-comment: #8b949e; + --_syntax-tag: #7ee787; + --_syntax-punctuation: #c9d1d9; + --_syntax-deleted: #ffdcd7; } } @@ -158,6 +180,17 @@ var(--_console-success) ); --console-error: var(--vscode-terminal-ansiRed, var(--_console-error)); + + /* Syntax highlighting */ + --syntax-keyword: var(--_syntax-keyword); + --syntax-function: var(--_syntax-function); + --syntax-constant: var(--_syntax-constant); + --syntax-string: var(--_syntax-string); + --syntax-variable: var(--_syntax-variable); + --syntax-comment: var(--_syntax-comment); + --syntax-tag: var(--_syntax-tag); + --syntax-punctuation: var(--_syntax-punctuation); + --syntax-deleted: var(--_syntax-deleted); } } @@ -264,29 +297,3 @@ @apply underline; } } - -/* BASIC STYLES (mainly for rendering markdown) */ - -@layer base { - .wysiwyg { - h1 { - @apply text-lg leading-tight font-medium; - } - - h2 { - @apply leading-tight font-medium; - } - - ul { - @apply list-disc list-outside space-y-1 ps-6; - } - - ol { - @apply list-decimal list-outside space-y-1 ps-6; - } - - li { - @apply leading-tight; - } - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7ee3b3a..e24553bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,9 +158,6 @@ importers: lucide-react: specifier: ^0.539.0 version: 0.539.0(react@18.3.1) - markdown-to-jsx: - specifier: ^7.7.13 - version: 7.7.13(react@18.3.1) posthog-js: specifier: ^1.276.0 version: 1.283.0 @@ -2772,12 +2769,6 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} - markdown-to-jsx@7.7.13: - resolution: {integrity: sha512-DiueEq2bttFcSxUs85GJcQVrOr0+VVsPfj9AEUPqmExJ3f8P/iQNvZHltV4tm1XVhu1kl0vWBZWT3l99izRMaA==} - engines: {node: '>= 10'} - peerDependencies: - react: '>= 0.14.0' - markdown-to-jsx@8.0.0: resolution: {integrity: sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA==} engines: {node: '>= 10'} @@ -6256,10 +6247,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 - markdown-to-jsx@7.7.13(react@18.3.1): - dependencies: - react: 18.3.1 - markdown-to-jsx@8.0.0(react@18.3.1): optionalDependencies: react: 18.3.1 diff --git a/shared/types.ts b/shared/types.ts index ff259c5d..f36c1e04 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -60,6 +60,34 @@ export type UpdateTask = { title: string | null, description: string | null, sta export type SharedTask = { id: string, remote_project_id: string, title: string, description: string | null, status: TaskStatus, assignee_user_id: string | null, assignee_first_name: string | null, assignee_last_name: string | null, assignee_username: string | null, version: bigint, last_event_seq: bigint | null, created_at: Date, updated_at: Date, }; +export type DraftFollowUpData = { message: string, variant: string | null, }; + +export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData }; + +export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP" } + +export type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, }; + +export type CreateScratch = { payload: ScratchPayload, }; + +export type UpdateScratch = { payload: ScratchPayload, }; + +export type QueuedMessage = { +/** + * The task attempt this message is queued for + */ +task_attempt_id: string, +/** + * The follow-up data (message + variant) + */ +data: DraftFollowUpData, +/** + * Timestamp when the message was queued + */ +queued_at: string, }; + +export type QueueStatus = { "status": "empty" } | { "status": "queued", message: QueuedMessage, }; + export type Image = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; export type CreateImage = { file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, }; @@ -144,13 +172,7 @@ export type CheckAgentAvailabilityQuery = { executor: BaseCodingAgent, }; export type AvailabilityInfo = { "type": "LOGIN_DETECTED", last_auth_timestamp: bigint, } | { "type": "INSTALLATION_FOUND" } | { "type": "NOT_FOUND" }; -export type CreateFollowUpAttempt = { prompt: string, variant: string | null, image_ids: Array | null, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; - -export type DraftResponse = { task_attempt_id: string, draft_type: DraftType, retry_process_id: string | null, prompt: string, queued: boolean, variant: string | null, image_ids: Array | null, version: bigint, }; - -export type UpdateFollowUpDraftRequest = { prompt: string | null, variant: string | null | null, image_ids: Array | null, version: bigint | null, }; - -export type UpdateRetryFollowUpDraftRequest = { retry_process_id: string, prompt: string | null, variant: string | null | null, image_ids: Array | null, version: bigint | null, }; +export type CreateFollowUpAttempt = { prompt: string, variant: string | null, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; export type ChangeTargetBranchRequest = { new_target_branch: string, }; @@ -160,7 +182,7 @@ export type RenameBranchRequest = { new_branch_name: string, }; export type RenameBranchResponse = { branch: string, }; -export type CommitCompareResult = { head_oid: string, target_oid: string, ahead_from_head: number, behind_from_head: number, is_linear: boolean, }; +export type CommitCompareResult = { subject: string, head_oid: string, target_oid: string, ahead_from_head: number, behind_from_head: number, is_linear: boolean, }; export type OpenEditorRequest = { editor_type: string | null, file_path: string | null, }; @@ -178,6 +200,8 @@ export type CreateGitHubPrRequest = { title: string, body: string | null, target export type ImageResponse = { id: string, file_path: string, original_name: string, mime_type: string | null, size_bytes: bigint, hash: string, created_at: string, updated_at: string, }; +export type ImageMetadata = { exists: boolean, file_name: string | null, path: string | null, size_bytes: bigint | null, format: string | null, proxy_url: string | null, }; + export type Config = { config_version: string, theme: ThemeMode, executor_profile: ExecutorProfileId, disclaimer_acknowledged: boolean, onboarding_acknowledged: boolean, notifications: NotificationConfig, editor: EditorConfig, github: GitHubConfig, analytics_enabled: boolean, workspace_dir: string | null, last_app_version: string | null, show_release_notes: boolean, language: UiLanguage, git_branch_prefix: string, showcases: ShowcaseState, }; export type NotificationConfig = { sound_enabled: boolean, push_enabled: boolean, sound_file: SoundFile, }; @@ -304,8 +328,6 @@ export type PushError = { "type": "force_push_required" }; export type CreatePrError = { "type": "github_cli_not_installed" } | { "type": "github_cli_not_logged_in" } | { "type": "git_cli_not_logged_in" } | { "type": "git_cli_not_installed" } | { "type": "target_branch_not_found", branch: string, }; -export type CommitInfo = { sha: string, subject: string, }; - export type BranchStatus = { commits_behind: number | null, commits_ahead: number | null, has_uncommitted_changes: boolean | null, head_oid: string | null, uncommitted_count: number | null, untracked_count: number | null, target_branch_name: string, remote_commits_behind: number | null, remote_commits_ahead: number | null, merges: Array, /** * True if a `git rebase` is currently in progress in this worktree @@ -354,10 +376,6 @@ export type MergeStatus = "open" | "merged" | "closed" | "unknown"; export type PullRequestInfo = { number: bigint, url: string, status: MergeStatus, merged_at: string | null, merge_commit_sha: string | null, }; -export type Draft = { id: string, task_attempt_id: string, draft_type: DraftType, retry_process_id: string | null, prompt: string, queued: boolean, sending: boolean, variant: string | null, image_ids: Array | null, created_at: string, updated_at: string, version: bigint, }; - -export type DraftType = "follow_up" | "retry"; - export type CommandExitStatus = { "type": "exit_code", code: number, } | { "type": "success", success: boolean, }; export type CommandRunResult = { exit_status: CommandExitStatus | null, output: string | null, };