From f4d1dfd3a7d76be97a7d733aa4bd34bb7903cf19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 14 Feb 2026 20:13:11 +0000 Subject: [PATCH] feat: better context files (#222) feat: context files --- .../internal/orchestrator/message_meta.go | 12 +- .../orchestrator/message_meta_test.go | 47 +- .../internal/orchestrator/task_operations.go | 2 +- .../task/handlers/message_handlers.go | 6 +- apps/backend/pkg/api/v1/task.go | 6 + apps/pnpm-lock.yaml | 388 +++++++++ apps/web/app/globals.css | 131 +++ apps/web/components/task-create-dialog.tsx | 3 + .../task/chat/chat-input-container.tsx | 533 +++--------- .../task/chat/chat-input-toolbar.tsx | 166 ++++ .../components/task/chat/comment-block.tsx | 177 ---- .../task/chat/context-items/comment-item.tsx | 39 + .../task/chat/context-items/context-chip.tsx | 131 +++ .../context-items/context-item-renderer.tsx | 32 + .../task/chat/context-items/context-zone.tsx | 41 + .../task/chat/context-items/file-item.tsx | 30 + .../task/chat/context-items/image-item.tsx | 56 ++ .../task/chat/context-items/index.ts | 10 + .../chat/context-items/lazy-file-preview.tsx | 72 ++ .../chat/context-items/lazy-plan-preview.tsx | 65 ++ .../chat/context-items/plan-comment-item.tsx | 32 + .../task/chat/context-items/plan-item.tsx | 20 + .../task/chat/context-items/prompt-item.tsx | 20 + .../chat/context-items/prompt-preview.tsx | 37 + .../components/task/chat/context-popover.tsx | 248 ++++++ .../chat/document-reference-indicator.tsx | 29 - .../components/task/chat/editor-context.tsx | 13 + .../task/chat/image-attachment-preview.tsx | 26 +- .../web/components/task/chat/mention-menu.tsx | 35 +- .../task/chat/messages/chat-message.tsx | 21 +- apps/web/components/task/chat/popup-menu.tsx | 21 +- .../task/chat/slash-command-menu.tsx | 7 +- .../web/components/task/chat/tiptap-input.tsx | 785 ++++++++++++++++++ .../task/chat/tiptap-mention-extension.tsx | 168 ++++ .../task/chat/tiptap-suggestion.tsx | 218 +++++ .../task/chat/virtualized-message-list.tsx | 51 ++ .../task/dockview-desktop-layout.tsx | 1 + .../web/components/task/file-editor-panel.tsx | 36 +- apps/web/components/task/task-chat-panel.tsx | 281 +++++-- apps/web/hooks/use-inline-mention.ts | 146 ++-- apps/web/hooks/use-message-handler.ts | 52 +- apps/web/hooks/use-resizable-input.ts | 68 +- apps/web/lib/local-storage.ts | 109 +++ apps/web/lib/state/context-files-store.ts | 110 +++ apps/web/lib/state/dockview-store.ts | 26 +- apps/web/lib/types/context.ts | 58 ++ apps/web/lib/utils/file-path.ts | 11 + apps/web/lib/ws/handlers/tasks.ts | 20 +- apps/web/package.json | 14 + 49 files changed, 3809 insertions(+), 801 deletions(-) create mode 100644 apps/web/components/task/chat/chat-input-toolbar.tsx delete mode 100644 apps/web/components/task/chat/comment-block.tsx create mode 100644 apps/web/components/task/chat/context-items/comment-item.tsx create mode 100644 apps/web/components/task/chat/context-items/context-chip.tsx create mode 100644 apps/web/components/task/chat/context-items/context-item-renderer.tsx create mode 100644 apps/web/components/task/chat/context-items/context-zone.tsx create mode 100644 apps/web/components/task/chat/context-items/file-item.tsx create mode 100644 apps/web/components/task/chat/context-items/image-item.tsx create mode 100644 apps/web/components/task/chat/context-items/index.ts create mode 100644 apps/web/components/task/chat/context-items/lazy-file-preview.tsx create mode 100644 apps/web/components/task/chat/context-items/lazy-plan-preview.tsx create mode 100644 apps/web/components/task/chat/context-items/plan-comment-item.tsx create mode 100644 apps/web/components/task/chat/context-items/plan-item.tsx create mode 100644 apps/web/components/task/chat/context-items/prompt-item.tsx create mode 100644 apps/web/components/task/chat/context-items/prompt-preview.tsx create mode 100644 apps/web/components/task/chat/context-popover.tsx delete mode 100644 apps/web/components/task/chat/document-reference-indicator.tsx create mode 100644 apps/web/components/task/chat/editor-context.tsx create mode 100644 apps/web/components/task/chat/tiptap-input.tsx create mode 100644 apps/web/components/task/chat/tiptap-mention-extension.tsx create mode 100644 apps/web/components/task/chat/tiptap-suggestion.tsx create mode 100644 apps/web/lib/state/context-files-store.ts create mode 100644 apps/web/lib/types/context.ts create mode 100644 apps/web/lib/utils/file-path.ts diff --git a/apps/backend/internal/orchestrator/message_meta.go b/apps/backend/internal/orchestrator/message_meta.go index 1921068ba1..5d7d313566 100644 --- a/apps/backend/internal/orchestrator/message_meta.go +++ b/apps/backend/internal/orchestrator/message_meta.go @@ -8,6 +8,7 @@ type UserMessageMeta struct { PlanMode bool HasReviewComments bool Attachments []v1.MessageAttachment + ContextFiles []v1.ContextFileMeta } // NewUserMessageMeta creates a UserMessageMeta builder. @@ -33,10 +34,16 @@ func (m *UserMessageMeta) WithAttachments(attachments []v1.MessageAttachment) *U return m } +// WithContextFiles sets the context files attached to the message. +func (m *UserMessageMeta) WithContextFiles(files []v1.ContextFileMeta) *UserMessageMeta { + m.ContextFiles = files + return m +} + // ToMap returns the metadata as a map suitable for message creation. // Returns nil if no metadata fields are set. func (m *UserMessageMeta) ToMap() map[string]interface{} { - if !m.PlanMode && !m.HasReviewComments && len(m.Attachments) == 0 { + if !m.PlanMode && !m.HasReviewComments && len(m.Attachments) == 0 && len(m.ContextFiles) == 0 { return nil } meta := make(map[string]interface{}) @@ -49,5 +56,8 @@ func (m *UserMessageMeta) ToMap() map[string]interface{} { if len(m.Attachments) > 0 { meta["attachments"] = m.Attachments } + if len(m.ContextFiles) > 0 { + meta["context_files"] = m.ContextFiles + } return meta } diff --git a/apps/backend/internal/orchestrator/message_meta_test.go b/apps/backend/internal/orchestrator/message_meta_test.go index a48c0acc77..76747f6f25 100644 --- a/apps/backend/internal/orchestrator/message_meta_test.go +++ b/apps/backend/internal/orchestrator/message_meta_test.go @@ -61,18 +61,39 @@ func TestUserMessageMeta_ToMap_AttachmentsOnly(t *testing.T) { } } -func TestUserMessageMeta_ToMap_AllFields(t *testing.T) { - attachments := []v1.MessageAttachment{{Type: "image", Data: "data", MimeType: "image/jpeg"}} - meta := NewUserMessageMeta(). - WithPlanMode(true). - WithReviewComments(true). - WithAttachments(attachments) +func TestUserMessageMeta_ToMap_ContextFilesOnly(t *testing.T) { + files := []v1.ContextFileMeta{{Path: "src/main.go", Name: "main.go"}} + meta := NewUserMessageMeta().WithContextFiles(files) result := meta.ToMap() if result == nil { t.Fatal("expected non-nil map") } - if len(result) != 3 { - t.Errorf("expected 3 keys, got %d", len(result)) + cf, ok := result["context_files"] + if !ok { + t.Fatal("expected context_files key") + } + if len(cf.([]v1.ContextFileMeta)) != 1 { + t.Errorf("expected 1 context file, got %d", len(cf.([]v1.ContextFileMeta))) + } + if _, ok := result["plan_mode"]; ok { + t.Error("unexpected plan_mode key") + } +} + +func TestUserMessageMeta_ToMap_AllFields(t *testing.T) { + attachments := []v1.MessageAttachment{{Type: "image", Data: "data", MimeType: "image/jpeg"}} + contextFiles := []v1.ContextFileMeta{{Path: "README.md", Name: "README.md"}} + meta := NewUserMessageMeta(). + WithPlanMode(true). + WithReviewComments(true). + WithAttachments(attachments). + WithContextFiles(contextFiles) + result := meta.ToMap() + if result == nil { + t.Fatal("expected non-nil map") + } + if len(result) != 4 { + t.Errorf("expected 4 keys, got %d", len(result)) } if result["plan_mode"] != true { t.Error("expected plan_mode=true") @@ -80,6 +101,12 @@ func TestUserMessageMeta_ToMap_AllFields(t *testing.T) { if result["has_review_comments"] != true { t.Error("expected has_review_comments=true") } + if _, ok := result["attachments"]; !ok { + t.Error("expected attachments key") + } + if _, ok := result["context_files"]; !ok { + t.Error("expected context_files key") + } } func TestUserMessageMeta_ToMap_FalseValues(t *testing.T) { @@ -106,4 +133,8 @@ func TestUserMessageMeta_Chaining(t *testing.T) { if returned != meta { t.Error("WithAttachments should return the same pointer for chaining") } + returned = meta.WithContextFiles(nil) + if returned != meta { + t.Error("WithContextFiles should return the same pointer for chaining") + } } diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index b5a9bfbabe..5490621422 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -191,7 +191,7 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag } if execution.SessionID != "" { - s.recordInitialMessage(ctx, taskID, execution.SessionID, effectivePrompt, false) + s.updateTaskSessionState(ctx, taskID, execution.SessionID, models.TaskSessionStateRunning, "", true) } return execution, nil diff --git a/apps/backend/internal/task/handlers/message_handlers.go b/apps/backend/internal/task/handlers/message_handlers.go index 2b7a161184..c6cb8a75b6 100644 --- a/apps/backend/internal/task/handlers/message_handlers.go +++ b/apps/backend/internal/task/handlers/message_handlers.go @@ -125,6 +125,7 @@ type wsAddMessageRequest struct { PlanMode bool `json:"plan_mode,omitempty"` HasReviewComments bool `json:"has_review_comments,omitempty"` Attachments []v1.MessageAttachment `json:"attachments,omitempty"` + ContextFiles []v1.ContextFileMeta `json:"context_files,omitempty"` } func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -185,11 +186,12 @@ func (h *MessageHandlers) wsAddMessage(ctx context.Context, msg *ws.Message) (*w } } - // Build metadata with attachments, plan mode, and review comments + // Build metadata with attachments, plan mode, review comments, and context files meta := orchestrator.NewUserMessageMeta(). WithPlanMode(req.PlanMode). WithReviewComments(req.HasReviewComments). - WithAttachments(req.Attachments) + WithAttachments(req.Attachments). + WithContextFiles(req.ContextFiles) metadata := meta.ToMap() message, err := h.messageController.CreateMessage(ctx, dto.CreateMessageRequest{ diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index bc18104d49..46d776468d 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -126,6 +126,12 @@ type MessageAttachment struct { MimeType string `json:"mime_type"` // MIME type (e.g., "image/png") } +// ContextFileMeta represents a context file reference attached to a message +type ContextFileMeta struct { + Path string `json:"path"` + Name string `json:"name"` +} + // Message represents a message in a task session (user or agent) type Message struct { ID string `json:"id"` diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 4db86b77f5..bb6e56e773 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -269,6 +269,45 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.18 version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tiptap/core': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-code-block-lowlight': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0) + '@tiptap/extension-document': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-hard-break': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-history': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-mention': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-paragraph': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/extension-placeholder': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)) + '@tiptap/extension-text': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) + '@tiptap/pm': + specifier: ^3.19.0 + version: 3.19.0 + '@tiptap/react': + specifier: ^3.19.0 + version: 3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tiptap/suggestion': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@uiw/codemirror-theme-github': specifier: ^4.25.4 version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10) @@ -320,6 +359,9 @@ importers: input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + lowlight: + specifier: ^3.3.0 + version: 3.3.0 mermaid: specifier: ^11.12.2 version: 11.12.2 @@ -2294,6 +2336,9 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@rollup/rollup-android-arm-eabi@4.55.1': resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} cpu: [arm] @@ -2575,6 +2620,106 @@ packages: '@tanstack/virtual-core@3.13.18': resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@tiptap/core@3.19.0': + resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==} + peerDependencies: + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-bubble-menu@3.19.0': + resolution: {integrity: sha512-klNVIYGCdznhFkrRokzGd6cwzoi8J7E5KbuOfZBwFwhMKZhlz/gJfKmYg9TJopeUhrr2Z9yHgWTk8dh/YIJCdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-code-block-lowlight@3.19.0': + resolution: {integrity: sha512-P8O8i1J+XozEVA7bF/Ijwf/r1rVqrh1DBQ7dXxBcrUvLpIGyVjtxX228jBF/kD4kf2xOlphvjDhV2fLa8XOVsg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extension-code-block': ^3.19.0 + '@tiptap/pm': ^3.19.0 + highlight.js: ^11 + lowlight: ^2 || ^3 + + '@tiptap/extension-code-block@3.19.0': + resolution: {integrity: sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-code@3.19.0': + resolution: {integrity: sha512-2kqqQIXBXj2Or+4qeY3WoE7msK+XaHKL6EKOcKlOP2BW8eYqNTPzNSL+PfBDQ3snA7ljZQkTs/j4GYDj90vR1A==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-document@3.19.0': + resolution: {integrity: sha512-AOf0kHKSFO0ymjVgYSYDncRXTITdTcrj1tqxVazrmO60KNl1Rc2dAggDvIVTEBy5NvceF0scc7q3sE/5ZtVV7A==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-floating-menu@3.19.0': + resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/extension-hard-break@3.19.0': + resolution: {integrity: sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-history@3.19.0': + resolution: {integrity: sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==} + peerDependencies: + '@tiptap/extensions': ^3.19.0 + + '@tiptap/extension-mention@3.19.0': + resolution: {integrity: sha512-iBWX6mUouvDe9F75C2fJnFzvBFYVF8fcOa7UvzqWHRSCt8WxqSIp6C1B9Y0npP4TbIZySHzPV4NQQJhtmWwKww==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/suggestion': ^3.19.0 + + '@tiptap/extension-paragraph@3.19.0': + resolution: {integrity: sha512-xWa6gj82l5+AzdYyrSk9P4ynySaDzg/SlR1FarXE5yPXibYzpS95IWaVR0m2Qaz7Rrk+IiYOTGxGRxcHLOelNg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extension-placeholder@3.19.0': + resolution: {integrity: sha512-i15OfgyI4IDCYAcYSKUMnuZkYuUInfanjf9zquH8J2BETiomf/jZldVCp/QycMJ8DOXZ38fXDc99wOygnSNySg==} + peerDependencies: + '@tiptap/extensions': ^3.19.0 + + '@tiptap/extension-text@3.19.0': + resolution: {integrity: sha512-K95+SnbZy0h6hNFtfy23n8t/nOcTFEf69In9TSFVVmwn/Nwlke+IfiESAkqbt1/7sKJeegRXYO7WzFEmFl9Q/g==} + peerDependencies: + '@tiptap/core': ^3.19.0 + + '@tiptap/extensions@3.19.0': + resolution: {integrity: sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + + '@tiptap/pm@3.19.0': + resolution: {integrity: sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==} + + '@tiptap/react@3.19.0': + resolution: {integrity: sha512-GQQMUUXMpNd8tRjc1jDK3tDRXFugJO7C928EqmeBcBzTKDrFIJ3QUoZKEPxUNb6HWhZ2WL7q00fiMzsv4DNSmg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/suggestion@3.19.0': + resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -2705,15 +2850,24 @@ packages: '@types/katex@0.16.8': resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} '@types/lodash@4.17.23': resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -2740,6 +2894,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validate-npm-package-name@4.0.2': resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} @@ -3698,6 +3855,10 @@ packages: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -4181,6 +4342,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hono@4.11.3: resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} @@ -4629,6 +4794,9 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + local-pkg@0.5.1: resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} engines: {node: '>=14'} @@ -4663,6 +4831,9 @@ packages: loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4672,6 +4843,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -4738,6 +4913,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -5196,6 +5374,9 @@ packages: prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -5217,12 +5398,21 @@ packages: prosemirror-keymap@1.2.3: resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.2.5: + resolution: {integrity: sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==} + prosemirror-model@1.25.4: resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} prosemirror-safari-ime-span@1.0.2: resolution: {integrity: sha512-QJqD8s1zE/CuK56kDsUhndh5hiHh/gFnAuPOA9ytva2s85/ZEt2tNWeALTJN48DtWghSKOmiBsvVn2OlnJ5H2w==} + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + prosemirror-schema-list@1.5.1: resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} @@ -5232,6 +5422,13 @@ packages: prosemirror-tables@1.8.5: resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + prosemirror-transform@1.11.0: resolution: {integrity: sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw==} @@ -5256,6 +5453,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5871,6 +6072,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + ufo@1.6.2: resolution: {integrity: sha512-heMioaxBcG9+Znsda5Q8sQbWnLJSl98AFDXTO80wELWEzX3hordXsTdxrIfMQoO9IY1MEnoGoPjpoKpMj+Yx0Q==} @@ -8440,6 +8644,8 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@remirror/core-constants@3.0.0': {} + '@rollup/rollup-android-arm-eabi@4.55.1': optional: true @@ -8657,6 +8863,119 @@ snapshots: '@tanstack/virtual-core@3.13.18': {} + '@tiptap/core@3.19.0(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-bubble-menu@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + optional: true + + '@tiptap/extension-code-block-lowlight@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-code-block': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + highlight.js: 11.11.1 + lowlight: 3.3.0 + + '@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/extension-code@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-document@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@floating-ui/dom': 1.7.4 + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + optional: true + + '@tiptap/extension-hard-break@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-history@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-mention@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-paragraph@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extension-placeholder@3.19.0(@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + + '@tiptap/extension-text@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + + '@tiptap/extensions@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + + '@tiptap/pm@3.19.0': + dependencies: + prosemirror-changeset: 2.3.1 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.0 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.2.5 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5) + prosemirror-transform: 1.11.0 + prosemirror-view: 1.41.5 + + '@tiptap/react@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-floating-menu': 3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -8815,16 +9134,25 @@ snapshots: '@types/katex@0.16.8': {} + '@types/linkify-it@5.0.0': {} + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.23 '@types/lodash@4.17.23': {} + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@20.19.28': @@ -8848,6 +9176,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@types/validate-npm-package-name@4.0.2': {} '@types/whatwg-mimetype@3.0.2': {} @@ -9866,6 +10196,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + entities@7.0.1: {} env-paths@2.2.1: {} @@ -10606,6 +10938,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@11.11.1: {} + hono@4.11.3: {} html-url-attributes@3.0.1: {} @@ -10984,6 +11318,10 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + local-pkg@0.5.1: dependencies: mlly: 1.8.0 @@ -11016,6 +11354,12 @@ snapshots: dependencies: get-func-name: 2.0.2 + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -11026,6 +11370,15 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} marked@16.4.2: {} @@ -11208,6 +11561,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdurl@2.0.0: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -11813,6 +12168,10 @@ snapshots: dependencies: prosemirror-transform: 1.11.0 + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -11856,6 +12215,19 @@ snapshots: prosemirror-state: 1.4.4 w3c-keyname: 2.2.8 + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.2.5: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + prosemirror-model@1.25.4: dependencies: orderedmap: 2.1.1 @@ -11865,6 +12237,10 @@ snapshots: prosemirror-state: 1.4.4 prosemirror-view: 1.41.5 + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-schema-list@1.5.1: dependencies: prosemirror-model: 1.25.4 @@ -11885,6 +12261,14 @@ snapshots: prosemirror-transform: 1.11.0 prosemirror-view: 1.41.5 + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.5 + prosemirror-transform@1.11.0: dependencies: prosemirror-model: 1.25.4 @@ -11906,6 +12290,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + punycode.js@2.3.1: {} + punycode@2.3.1: {} qs@6.14.1: @@ -12780,6 +13166,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + ufo@1.6.2: {} unbox-primitive@1.1.0: diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 54dab4f2b9..25e1c5aa6b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -739,6 +739,137 @@ user-select: none; } +/* TipTap editor styles */ +.tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--muted-foreground); + pointer-events: none; + height: 0; +} + +.tiptap p { + margin: 0; +} + +/* Inline code */ +.tiptap code { + background-color: var(--muted); + border-radius: 0.25rem; + padding: 0.125rem 0.25rem; + font-size: 0.75rem; + font-family: var(--font-mono, ui-monospace, monospace); +} + +/* Code blocks */ +.tiptap pre { + background-color: color-mix(in oklch, var(--card) 80%, black 20%); + border: 1px solid var(--border); + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + margin: 0.25rem 0; + overflow-x: auto; + position: relative; +} + +.tiptap pre code { + background: none; + padding: 0; + border-radius: 0; + font-size: 0.75rem; + line-height: 1.4; + font-family: var(--font-mono, ui-monospace, monospace); +} + +/* Language selector on code blocks */ +.tiptap pre .code-block-language { + position: absolute; + top: 0.25rem; + right: 0.25rem; + font-size: 0.625rem; + font-family: var(--font-sans, ui-sans-serif, sans-serif); + color: var(--muted-foreground); + background: var(--muted); + border: 1px solid var(--border); + border-radius: 0.25rem; + padding: 0.0625rem 0.25rem; + cursor: pointer; + outline: none; + opacity: 0; + transition: opacity 0.15s; +} + +.tiptap pre:hover .code-block-language, +.tiptap pre:focus-within .code-block-language { + opacity: 1; +} + +/* Lowlight / highlight.js syntax colors */ +.tiptap pre code .hljs-comment, +.tiptap pre code .hljs-quote { + color: var(--muted-foreground); + font-style: italic; +} + +.tiptap pre code .hljs-keyword, +.tiptap pre code .hljs-selector-tag, +.tiptap pre code .hljs-built_in { + color: oklch(0.65 0.2 300); +} + +.tiptap pre code .hljs-string, +.tiptap pre code .hljs-attr, +.tiptap pre code .hljs-addition { + color: oklch(0.7 0.15 150); +} + +.tiptap pre code .hljs-number, +.tiptap pre code .hljs-literal { + color: oklch(0.7 0.15 60); +} + +.tiptap pre code .hljs-title, +.tiptap pre code .hljs-section, +.tiptap pre code .hljs-type { + color: oklch(0.75 0.15 80); +} + +.tiptap pre code .hljs-name, +.tiptap pre code .hljs-tag { + color: oklch(0.65 0.2 20); +} + +.tiptap pre code .hljs-variable, +.tiptap pre code .hljs-template-variable { + color: oklch(0.65 0.2 20); +} + +.tiptap pre code .hljs-regexp, +.tiptap pre code .hljs-link { + color: oklch(0.7 0.15 30); +} + +.tiptap pre code .hljs-symbol, +.tiptap pre code .hljs-bullet { + color: oklch(0.65 0.2 330); +} + +.tiptap pre code .hljs-meta { + color: var(--muted-foreground); +} + +.tiptap pre code .hljs-deletion { + color: oklch(0.65 0.2 20); +} + +.tiptap pre code .hljs-emphasis { + font-style: italic; +} + +.tiptap pre code .hljs-strong { + font-weight: bold; +} + .debug { border: 1px solid red !important; } diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 828e5fccae..5faceb1f91 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -48,6 +48,7 @@ import { linkToSession } from '@/lib/links'; import { getExecutorIcon } from '@/lib/executor-icons'; import { useDockviewStore } from '@/lib/state/dockview-store'; +import { useContextFilesStore } from '@/lib/state/context-files-store'; interface TaskCreateDialogProps { open: boolean; @@ -959,6 +960,8 @@ export function TaskCreateDialog({ referencePanel: 'chat', }); setPlanMode(newSessionId, true); + // Add plan as context so it's included in messages + useContextFilesStore.getState().addFile(newSessionId, { path: 'plan:context', name: 'Plan' }); router.push(linkToSession(newSessionId)); } else { onOpenChange(false); diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index 7121315c07..eb558df5e0 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -1,48 +1,42 @@ 'use client'; -import { useRef, useCallback, useState, useEffect, memo, forwardRef, useImperativeHandle, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useRef, useCallback, useState, useEffect, useMemo, forwardRef, useImperativeHandle } from 'react'; import { - IconArrowUp, IconAlertTriangle, - IconFileTextSpark, IconPlus, - IconPlayerPauseFilled, - IconMessageDots, - IconX, } from '@tabler/icons-react'; -import { GridSpinner } from '@/components/grid-spinner'; import { Button } from '@kandev/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { cn } from '@/lib/utils'; -import { SHORTCUTS } from '@/lib/keyboard/constants'; -import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; -import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; -import { SessionsDropdown } from '@/components/task/sessions-dropdown'; import { TaskCreateDialog } from '@/components/task-create-dialog'; -import { ModelSelector } from '@/components/task/model-selector'; -import { RichTextInput, type RichTextInputHandle } from './rich-text-input'; -import { MentionMenu } from './mention-menu'; -import { SlashCommandMenu } from './slash-command-menu'; +import { TipTapInput, type TipTapInputHandle } from './tiptap-input'; import { ClarificationInputOverlay } from './clarification-input-overlay'; -import { CommentBlocksContainer } from './comment-block'; import { ChatInputFocusHint } from './chat-input-focus-hint'; import { ResizeHandle } from './resize-handle'; import { TodoSummary } from './todo-summary'; -import { DocumentReferenceIndicator } from './document-reference-indicator'; import { QueuedMessageIndicator, type QueuedMessageIndicatorHandle } from './queued-message-indicator'; +import { ChatInputToolbar } from './chat-input-toolbar'; +import { ContextZone } from './context-items/context-zone'; +import type { ContextItem, ImageContextItem } from '@/lib/types/context'; +import type { ContextFile } from '@/lib/state/context-files-store'; import { useResizableInput } from '@/hooks/use-resizable-input'; import { - ImageAttachmentPreview, processImageFile, + formatBytes, MAX_IMAGES, MAX_TOTAL_SIZE, type ImageAttachment, } from './image-attachment-preview'; -import { useInlineMention } from '@/hooks/use-inline-mention'; -import { useInlineSlash } from '@/hooks/use-inline-slash'; +import { + getChatDraftText, + setChatDraftText, + getChatDraftAttachments, + setChatDraftAttachments, + setChatDraftContent, + restoreAttachmentPreview, +} from '@/lib/local-storage'; import type { Message } from '@/lib/types/http'; import type { DiffComment } from '@/lib/diff/types'; -import type { ActiveDocument } from '@/lib/state/slices/ui/types'; // Re-export ImageAttachment type for consumers export type { ImageAttachment } from './image-attachment-preview'; @@ -54,119 +48,9 @@ export type MessageAttachment = { mime_type: string; // MIME type }; -// Memoized toolbar to prevent re-renders on input value changes -type ChatInputToolbarProps = { - planModeEnabled: boolean; - onPlanModeChange: (enabled: boolean) => void; - sessionId: string | null; - taskId: string | null; - taskTitle?: string; - taskDescription: string; - isAgentBusy: boolean; - isDisabled: boolean; - isSending: boolean; - onCancel: () => void; - onSubmit: () => void; - submitKey?: 'enter' | 'cmd_enter'; -}; - -const ChatInputToolbar = memo(function ChatInputToolbar({ - planModeEnabled, - onPlanModeChange, - sessionId, - taskId, - taskTitle, - taskDescription, - isAgentBusy, - isDisabled, - isSending, - onCancel, - onSubmit, - submitKey = 'cmd_enter', -}: ChatInputToolbarProps) { - const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; - - return ( -
- {/* Left: Plan, Thinking, Context */} -
- {/* Plan mode toggle */} - - - - - Toggle plan mode - - -
- - {/* Spacer */} -
- - {/* Right: Sessions, Model, Submit */} -
- - - {/* Token usage display */} - - - - - {/* Submit/Stop button */} -
- - {isAgentBusy ? ( - - ) : ( - - )} - -
-
-
- ); -}); - export type ChatInputContainerHandle = { focusInput: () => void; - getTextareaElement: () => HTMLTextAreaElement | null; + getTextareaElement: () => HTMLElement | null; getValue: () => string; getSelectionStart: () => number; insertText: (text: string, from: number, to: number) => void; @@ -178,7 +62,7 @@ type TodoItem = { }; type ChatInputContainerProps = { - onSubmit: (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[]) => void; + onSubmit: (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: ContextFile[]) => void; sessionId: string | null; taskId: string | null; taskTitle?: string; @@ -194,14 +78,8 @@ type ChatInputContainerProps = { onClarificationResolved?: () => void; showRequestChangesTooltip?: boolean; onRequestChangesTooltipDismiss?: () => void; - /** Pending review comments grouped by file */ + /** Pending review comments grouped by file — kept for submit collection */ pendingCommentsByFile?: Record; - /** Callback to remove comments for a file */ - onRemoveCommentFile?: (filePath: string) => void; - /** Callback to remove a specific comment */ - onRemoveComment?: (sessionId: string, filePath: string, commentId: string) => void; - /** Callback when a comment is clicked (for jump-to-line) */ - onCommentClick?: (comment: DiffComment) => void; /** Chat submit key preference */ submitKey?: 'enter' | 'cmd_enter'; /** Whether the current agent has commands (affects placeholder) */ @@ -210,18 +88,18 @@ type ChatInputContainerProps = { isFailed?: boolean; /** Whether a message is queued */ isQueued?: boolean; - /** Callback to start editing the queued message (from keyboard navigation) */ - onStartQueueEdit?: () => void; - /** User message history for up/down arrow navigation */ - userMessageHistory?: string[]; - /** Plan/document comments that will be included with the next message */ - documentCommentCount?: number; - /** Callback to clear plan/document comments */ - onClearDocumentComments?: () => void; + /** Context items to display as chips above input */ + contextItems?: ContextItem[]; + /** Whether plan is selected as context in the popover */ + planContextEnabled?: boolean; + /** Context files for popover */ + contextFiles?: ContextFile[]; + /** Toggle a context file in/out */ + onToggleContextFile?: (file: ContextFile) => void; + /** Add a context file (for @mention) */ + onAddContextFile?: (file: ContextFile) => void; /** Todo items from processed messages */ todoItems?: TodoItem[]; - /** Active document reference */ - activeDocument?: ActiveDocument | null; /** Queued message content */ queuedMessage?: string | null; /** Cancel queued message */ @@ -256,19 +134,16 @@ export const ChatInputContainer = forwardRef + sessionId ? getChatDraftText(sessionId) : '' + ); const [isInputFocused, setIsInputFocused] = useState(false); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState(() => + sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : [] + ); const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); - const inputRef = useRef(null); + const [contextPopoverOpen, setContextPopoverOpen] = useState(false); + const inputRef = useRef(null); // Message history navigation state - const [historyIndex, setHistoryIndex] = useState(-1); // -1 means not navigating history - const [historyBuffer, setHistoryBuffer] = useState(''); // Store current input when navigating history + const [historyIndex, setHistoryIndex] = useState(-1); + + // Hydrate draft when switching sessions (component stays mounted) + const prevSessionIdRef = useRef(sessionId); + useEffect(() => { + if (sessionId === prevSessionIdRef.current) return; + prevSessionIdRef.current = sessionId; + /* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */ + if (sessionId) { + setValue(getChatDraftText(sessionId)); + setAttachments(getChatDraftAttachments(sessionId).map(restoreAttachmentPreview)); + } else { + setValue(''); + setAttachments([]); + } + /* eslint-enable react-hooks/set-state-in-effect */ + }, [sessionId]); // Resizable input - const { height, containerRef, resizeHandleProps } = useResizableInput(); + const { height, resetHeight, containerRef, resizeHandleProps } = useResizableInput(sessionId ?? undefined); // Expose imperative handle for parent useImperativeHandle( @@ -314,7 +209,7 @@ export const ChatInputContainer = forwardRef { valueRef.current = value; @@ -324,43 +219,24 @@ export const ChatInputContainer = forwardRef { attachmentsRef.current = attachments; - }, [attachments]); - - // Handle image paste - const handlePaste = useCallback(async (e: ClipboardEvent) => { - const items = e.clipboardData?.items; - if (!items) return; - - // Check for image items - const imageItems: DataTransferItem[] = []; - for (const item of items) { - if (item.type.startsWith('image/')) { - imageItems.push(item); - } + // Persist attachments when they change (skip during session switch — handled by hydrate effect) + if (sessionId && prevSessionIdRef.current === sessionId) { + setChatDraftAttachments(sessionId, attachments); } + }, [attachments, sessionId]); - if (imageItems.length === 0) return; - - // Prevent default paste behavior for images - e.preventDefault(); - - // Check if we've hit the max images limit + // Handle image paste (called by TipTapInput's handlePaste editorProp) + const handleImagePaste = useCallback(async (files: File[]) => { if (attachments.length >= MAX_IMAGES) { console.warn(`Maximum ${MAX_IMAGES} images allowed`); return; } - // Calculate current total size const currentTotalSize = attachments.reduce((sum, att) => sum + att.size, 0); - // Process each image - for (const item of imageItems) { - if (attachments.length + imageItems.indexOf(item) >= MAX_IMAGES) break; + for (const file of files) { + if (attachments.length >= MAX_IMAGES) break; - const file = item.getAsFile(); - if (!file) continue; - - // Check total size limit if (currentTotalSize + file.size > MAX_TOTAL_SIZE) { console.warn('Total attachment size limit exceeded'); break; @@ -373,26 +249,12 @@ export const ChatInputContainer = forwardRef { - const textarea = inputRef.current?.getTextareaElement(); - if (!textarea) return; - - textarea.addEventListener('paste', handlePaste); - return () => { - textarea.removeEventListener('paste', handlePaste); - }; - }, [handlePaste]); - // Remove attachment handler const handleRemoveAttachment = useCallback((id: string) => { setAttachments(prev => prev.filter(att => att.id !== id)); }, []); - // Mention menu hook - const mention = useInlineMention(inputRef, value, setValue, sessionId); - - // Handler for agent slash commands - sends /{commandName} as a message + // Handler for agent slash commands const handleAgentCommand = useCallback( (commandName: string) => { onSubmit(`/${commandName}`); @@ -400,121 +262,37 @@ export const ChatInputContainer = forwardRef { - menuStateRef.current = { mentionOpen: mention.isOpen, slashOpen: slash.isOpen }; - }, [mention.isOpen, slash.isOpen]); - // Ref for pending comments const pendingCommentsRef = useRef(pendingCommentsByFile); useEffect(() => { pendingCommentsRef.current = pendingCommentsByFile; }, [pendingCommentsByFile]); - // Combined change handler + // Change handler const handleChange = useCallback( (newValue: string) => { - mention.handleChange(newValue); - slash.handleChange(newValue); + setValue(newValue); + if (sessionId) setChatDraftText(sessionId, newValue); - // Reset history navigation when user modifies content from history (but not when editing queued message) if (historyIndex >= 0) { - // User is modifying a historical message - keep the content but exit history mode setHistoryIndex(-1); - setHistoryBuffer(''); } - // Dismiss the request changes tooltip when user starts typing if (showRequestChangesTooltip && onRequestChangesTooltipDismiss) { onRequestChangesTooltipDismiss(); } }, - [mention, slash, showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex] + [showRequestChangesTooltip, onRequestChangesTooltipDismiss, historyIndex, sessionId] ); - // Combined keydown handler - const handleKeyDown = useCallback( - (event: ReactKeyboardEvent) => { - // Let menus handle navigation first - if (mention.isOpen) { - mention.handleKeyDown(event); - if (event.defaultPrevented) return; - } - if (slash.isOpen) { - slash.handleKeyDown(event); - if (event.defaultPrevented) return; - } - - // Handle up/down arrow for history navigation - const textarea = event.currentTarget; - const cursorPosition = textarea.selectionStart; - const textLength = textarea.value.length; - - // Up arrow: navigate to previous message or start editing queued message - if (event.key === 'ArrowUp' && cursorPosition === 0) { - event.preventDefault(); - - // If there's a queued message and we're not navigating history, trigger edit mode on QueuedMessageIndicator - if (isQueued && historyIndex === -1 && onStartQueueEdit) { - onStartQueueEdit(); - return; - } - - // Navigate to previous message in history - if (userMessageHistory.length > 0) { - const newIndex = historyIndex === -1 - ? userMessageHistory.length - 1 - : Math.max(0, historyIndex - 1); - - if (historyIndex === -1) { - setHistoryBuffer(value); - } - - setValue(userMessageHistory[newIndex] || ''); - setHistoryIndex(newIndex); - } - } - // Down arrow: navigate to next message in history - else if (event.key === 'ArrowDown' && cursorPosition === textLength) { - event.preventDefault(); - - if (historyIndex >= 0) { - const newIndex = historyIndex + 1; - - if (newIndex >= userMessageHistory.length) { - // Restore the buffer (what user was typing before navigating) - setValue(historyBuffer); - setHistoryIndex(-1); - } else { - setValue(userMessageHistory[newIndex] || ''); - setHistoryIndex(newIndex); - } - } - } - }, - [mention, slash, userMessageHistory, historyIndex, historyBuffer, value, isQueued, onStartQueueEdit] - ); - - // Stable submit handler using refs - doesn't change on value/menu state changes + // Stable submit handler using refs const handleSubmit = useCallback(() => { - // Don't submit if already sending if (isSending) return; - // Don't submit if a menu is open - if (menuStateRef.current.mentionOpen || menuStateRef.current.slashOpen) return; - const trimmed = valueRef.current.trim(); const comments = pendingCommentsRef.current; const currentAttachments = attachmentsRef.current; - // Collect all pending comments const allComments: DiffComment[] = []; if (comments) { for (const filePath of Object.keys(comments)) { @@ -522,35 +300,54 @@ export const ChatInputContainer = forwardRef ({ type: 'image' as const, data: att.data, mime_type: att.mimeType, })); - // Submit message - handler will queue it if agent is busy + // Extract inline @mentions from the editor before clearing + const inlineMentions = inputRef.current?.getMentions() ?? []; + onSubmit( trimmed, allComments.length > 0 ? allComments : undefined, - messageAttachments.length > 0 ? messageAttachments : undefined + messageAttachments.length > 0 ? messageAttachments : undefined, + inlineMentions.length > 0 ? inlineMentions : undefined ); + // Clear editor imperatively (suppresses stale onUpdate) + inputRef.current?.clear(); setValue(''); - setAttachments([]); // Clear attachments after submit + setAttachments([]); setHistoryIndex(-1); - setHistoryBuffer(''); - }, [onSubmit, isSending]); + resetHeight(); + // Clear persisted draft after sending + if (sessionId) { + setChatDraftText(sessionId, ''); + setChatDraftContent(sessionId, null); + setChatDraftAttachments(sessionId, []); + } + }, [onSubmit, isSending, sessionId, resetHeight]); - // Disable input when starting, sending, or session ended (failed/cancelled) - // Note: We allow typing when agent is busy so users can queue messages + // Merge images into context items + const allItems = useMemo((): ContextItem[] => { + const imageItems: ImageContextItem[] = attachments.map(att => ({ + kind: 'image' as const, + id: `image:${att.id}`, + label: `Image (${formatBytes(att.size)})`, + attachment: att, + onRemove: () => handleRemoveAttachment(att.id), + })); + return [...contextItems, ...imageItems]; + }, [contextItems, attachments, handleRemoveAttachment]); + + // Derived state const isDisabled = isStarting || isSending || isFailed; const hasPendingClarification = pendingClarification && onClarificationResolved; const hasPendingComments = pendingCommentsByFile && Object.keys(pendingCommentsByFile).length > 0; - // Dynamic placeholder const inputPlaceholder = placeholder || (isAgentBusy @@ -559,19 +356,17 @@ export const ChatInputContainer = forwardRef 0; - const hasContextItems = + + // Determine if context zone should show + const hasContextZone = hasQueuedMessage || hasTodos || - !!activeDocument || - hasPendingComments || - documentCommentCount > 0 || - attachments.length > 0 || + allItems.length > 0 || hasPendingClarification; if (isFailed) { @@ -631,34 +426,13 @@ export const ChatInputContainer = forwardRef - {/* Popup menus */} - - - {/* CONTEXT ZONE: scrollable, above editor */} - {hasContextItems && ( -
-
- {/* Queued message */} - {hasQueuedMessage && ( + {hasContextZone && ( + - )} - - {/* Todo summary */} - {hasTodos && } - - {/* Document reference */} - {activeDocument && ( - - )} - - {/* Pending comment blocks */} - {hasPendingComments && sessionId && onRemoveCommentFile && onRemoveComment && ( - - )} - - {/* Plan comments indicator */} - {documentCommentCount > 0 && ( -
- - - {documentCommentCount} plan comment{documentCommentCount !== 1 ? 's' : ''} will be included - - {onClearDocumentComments && ( - - )} -
- )} - - {/* Image attachments preview */} - - - {/* Clarification (renders above editor, textarea stays visible but disabled) */} - {hasPendingClarification && ( + ) : undefined + } + todoSlot={hasTodos ? : undefined} + clarificationSlot={ + hasPendingClarification ? (
- )} -
-
+ ) : undefined + } + /> )} {/* EDITOR ZONE: flex-1, fills remaining space */} @@ -733,11 +462,10 @@ export const ChatInputContainer = forwardRef
- setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} + sessionId={sessionId} + taskId={taskId} + onAddContextFile={onAddContextFile} + onToggleContextFile={onToggleContextFile} + planContextEnabled={planContextEnabled} + onAgentCommand={handleAgentCommand} + onImagePaste={handleImagePaste} />
@@ -753,7 +488,7 @@ export const ChatInputContainer = forwardRef - {/* Integrated toolbar - memoized to prevent re-renders on input changes */} + {/* Integrated toolbar */}
diff --git a/apps/web/components/task/chat/chat-input-toolbar.tsx b/apps/web/components/task/chat/chat-input-toolbar.tsx new file mode 100644 index 0000000000..26aedd033f --- /dev/null +++ b/apps/web/components/task/chat/chat-input-toolbar.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { memo } from 'react'; +import { + IconArrowUp, + IconFileTextSpark, + IconPlayerPauseFilled, + IconAt, +} from '@tabler/icons-react'; +import { GridSpinner } from '@/components/grid-spinner'; +import { Button } from '@kandev/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; +import { cn } from '@/lib/utils'; +import { SHORTCUTS } from '@/lib/keyboard/constants'; +import { KeyboardShortcutTooltip } from '@/components/keyboard-shortcut-tooltip'; +import { TokenUsageDisplay } from '@/components/task/chat/token-usage-display'; +import { SessionsDropdown } from '@/components/task/sessions-dropdown'; +import { ModelSelector } from '@/components/task/model-selector'; +import { ContextPopover } from './context-popover'; +import type { ContextFile } from '@/lib/state/context-files-store'; + +export type ChatInputToolbarProps = { + planModeEnabled: boolean; + onPlanModeChange: (enabled: boolean) => void; + sessionId: string | null; + taskId: string | null; + taskTitle?: string; + taskDescription: string; + isAgentBusy: boolean; + isDisabled: boolean; + isSending: boolean; + onCancel: () => void; + onSubmit: () => void; + submitKey?: 'enter' | 'cmd_enter'; + contextCount?: number; + contextPopoverOpen?: boolean; + onContextPopoverOpenChange?: (open: boolean) => void; + /** Whether plan is selected as context in the popover (independent of plan panel) */ + planContextEnabled?: boolean; + contextFiles?: ContextFile[]; + onToggleFile?: (file: ContextFile) => void; +}; + +export const ChatInputToolbar = memo(function ChatInputToolbar({ + planModeEnabled, + onPlanModeChange, + sessionId, + taskId, + taskTitle, + taskDescription, + isAgentBusy, + isDisabled, + isSending, + onCancel, + onSubmit, + submitKey = 'cmd_enter', + contextCount = 0, + contextPopoverOpen = false, + onContextPopoverOpenChange, + planContextEnabled = false, + contextFiles = [], + onToggleFile, +}: ChatInputToolbarProps) { + const submitShortcut = submitKey === 'enter' ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT; + + return ( +
+ {/* Left: Plan, Thinking, Context */} +
+ {/* Plan mode toggle */} + + + + + Toggle plan mode + + + {/* Context @ button */} + {})} + trigger={ + + } + sessionId={sessionId} + planContextEnabled={planContextEnabled} + contextFiles={contextFiles} + onToggleFile={onToggleFile ?? (() => {})} + /> +
+ + {/* Spacer */} +
+ + {/* Right: Sessions, Model, Submit */} +
+ + + {/* Token usage display */} + + + + + {/* Submit/Stop button */} +
+ + {isAgentBusy ? ( + + ) : ( + + )} + +
+
+
+ ); +}); diff --git a/apps/web/components/task/chat/comment-block.tsx b/apps/web/components/task/chat/comment-block.tsx deleted file mode 100644 index efdee1458c..0000000000 --- a/apps/web/components/task/chat/comment-block.tsx +++ /dev/null @@ -1,177 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Button } from '@kandev/ui/button'; -import { Badge } from '@kandev/ui/badge'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@kandev/ui/collapsible'; -import { IconChevronDown, IconChevronRight, IconX, IconMessage } from '@tabler/icons-react'; -import { cn } from '@kandev/ui/lib/utils'; -import type { DiffComment } from '@/lib/diff/types'; -import { CommentDisplay } from '@/components/diff/comment-display'; - -interface CommentBlockProps { - /** File path for the comments */ - filePath: string; - /** Comments for this file */ - comments: DiffComment[]; - /** Callback to remove all comments for this file */ - onRemove: () => void; - /** Callback to remove a specific comment */ - onRemoveComment: (commentId: string) => void; - /** Callback when a comment is clicked (for jump-to-line) */ - onCommentClick?: (comment: DiffComment) => void; - /** Additional class name */ - className?: string; -} - -/** - * Rich block component for embedding review comments in chat input. - * Shows file name with comment count badge, expandable to see details. - */ -export function CommentBlock({ - filePath, - comments, - onRemove, - onRemoveComment, - onCommentClick, - className, -}: CommentBlockProps) { - const [isOpen, setIsOpen] = useState(false); - - // Get just the filename for display - const fileName = filePath.split('/').pop() || filePath; - - if (comments.length === 0) { - return null; - } - - return ( - -
- {/* Header */} -
- - - - - - - - {fileName} - - - - {comments.length} - - - -
- - {/* Expanded content */} - -
- {comments.map((comment) => ( -
onCommentClick?.(comment)} - className={cn( - 'rounded transition-colors', - onCommentClick && 'cursor-pointer hover:bg-muted/50' - )} - > - onRemoveComment(comment.id)} - /> -
- ))} -
-
-
-
- ); -} - -interface CommentBlocksContainerProps { - /** Comments grouped by file path */ - commentsByFile: Record; - /** Session ID for removing comments */ - sessionId: string; - /** Callback to remove comments for a file */ - onRemoveFile: (filePath: string) => void; - /** Callback to remove a specific comment */ - onRemoveComment: (sessionId: string, filePath: string, commentId: string) => void; - /** Callback when a comment is clicked (for jump-to-line) */ - onCommentClick?: (comment: DiffComment) => void; - /** Additional class name */ - className?: string; -} - -/** - * Container for multiple CommentBlock components - */ -export function CommentBlocksContainer({ - commentsByFile, - sessionId, - onRemoveFile, - onRemoveComment, - onCommentClick, - className, -}: CommentBlocksContainerProps) { - const files = Object.keys(commentsByFile); - - if (files.length === 0) { - return null; - } - - return ( -
- {files.map((filePath) => ( - onRemoveFile(filePath)} - onRemoveComment={(commentId) => onRemoveComment(sessionId, filePath, commentId)} - onCommentClick={onCommentClick} - /> - ))} -
- ); -} diff --git a/apps/web/components/task/chat/context-items/comment-item.tsx b/apps/web/components/task/chat/context-items/comment-item.tsx new file mode 100644 index 0000000000..54e3459386 --- /dev/null +++ b/apps/web/components/task/chat/context-items/comment-item.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { memo } from 'react'; +import type { CommentContextItem } from '@/lib/types/context'; +import { getFileName } from '@/lib/utils/file-path'; +import { ContextChip } from './context-chip'; +import { CommentDisplay } from '@/components/diff/comment-display'; + +export const CommentItem = memo(function CommentItem({ item }: { item: CommentContextItem }) { + const fileName = getFileName(item.filePath); + + const preview = ( +
+
+ {fileName} +
+
+ {item.comments.map((comment) => ( + item.onRemoveComment(comment.id)} + /> + ))} +
+
+ ); + + return ( + + ); +}); diff --git a/apps/web/components/task/chat/context-items/context-chip.tsx b/apps/web/components/task/chat/context-items/context-chip.tsx new file mode 100644 index 0000000000..b5e1e93b20 --- /dev/null +++ b/apps/web/components/task/chat/context-items/context-chip.tsx @@ -0,0 +1,131 @@ +'use client'; + +import { memo, useRef, useState, type ReactNode } from 'react'; +import { + IconListCheck, + IconFile, + IconMessageDots, + IconPhoto, + IconAt, + IconX, + IconPinFilled, +} from '@tabler/icons-react'; +import type { TablerIcon } from '@tabler/icons-react'; +import { HoverCard, HoverCardTrigger, HoverCardContent } from '@kandev/ui/hover-card'; +import type { ContextItemKind } from '@/lib/types/context'; + +const ICON_BY_KIND: Record = { + plan: IconListCheck, + file: IconFile, + comment: IconMessageDots, + 'plan-comment': IconMessageDots, + image: IconPhoto, + prompt: IconAt, +}; + +type ContextChipProps = { + kind: ContextItemKind; + label: string; + pinned?: boolean; + preview?: ReactNode; + /** Data URL to render as a tiny thumbnail instead of the default icon */ + thumbnail?: string; + onClick?: () => void; + onUnpin?: () => void; + onRemove?: () => void; +}; + +export const ContextChip = memo(function ContextChip({ + kind, + label, + pinned, + preview, + thumbnail, + onClick, + onUnpin, + onRemove, +}: ContextChipProps) { + const Icon = ICON_BY_KIND[kind]; + + const chip = ( +
+ {thumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail + + ) : ( + + )} + {label} + {pinned && onUnpin && ( + + )} + {onRemove && ( + + )} +
+ ); + + if (!preview) return chip; + + return ( + + {chip} + + ); +}); + +function ControlledHoverChip({ + preview, + children, +}: { + preview: ReactNode; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const suppressRef = useRef(false); + + return ( + { + if (next && suppressRef.current) return; + setOpen(next); + }} + openDelay={300} + > + { + suppressRef.current = true; + setOpen(false); + setTimeout(() => { suppressRef.current = false; }, 300); + }} + > + {children} + + + {preview} + + + ); +} diff --git a/apps/web/components/task/chat/context-items/context-item-renderer.tsx b/apps/web/components/task/chat/context-items/context-item-renderer.tsx new file mode 100644 index 0000000000..1e4757ee36 --- /dev/null +++ b/apps/web/components/task/chat/context-items/context-item-renderer.tsx @@ -0,0 +1,32 @@ +'use client'; + +import type { ContextItem } from '@/lib/types/context'; +import { PlanItem } from './plan-item'; +import { FileItem } from './file-item'; +import { PromptItem } from './prompt-item'; +import { CommentItem } from './comment-item'; +import { PlanCommentItem } from './plan-comment-item'; +import { ImageItem } from './image-item'; + +export function ContextItemRenderer({ + item, + sessionId, +}: { + item: ContextItem; + sessionId?: string | null; +}) { + switch (item.kind) { + case 'plan': + return ; + case 'file': + return ; + case 'prompt': + return ; + case 'comment': + return ; + case 'plan-comment': + return ; + case 'image': + return ; + } +} diff --git a/apps/web/components/task/chat/context-items/context-zone.tsx b/apps/web/components/task/chat/context-items/context-zone.tsx new file mode 100644 index 0000000000..127e2bcaf0 --- /dev/null +++ b/apps/web/components/task/chat/context-items/context-zone.tsx @@ -0,0 +1,41 @@ +'use client'; + +import type { ReactNode } from 'react'; +import type { ContextItem } from '@/lib/types/context'; +import { ContextItemRenderer } from './context-item-renderer'; + +type ContextZoneProps = { + items: ContextItem[]; + sessionId?: string | null; + queueSlot?: ReactNode; + todoSlot?: ReactNode; + clarificationSlot?: ReactNode; +}; + +export function ContextZone({ + items, + sessionId, + queueSlot, + todoSlot, + clarificationSlot, +}: ContextZoneProps) { + const hasContent = !!queueSlot || !!todoSlot || items.length > 0 || !!clarificationSlot; + if (!hasContent) return null; + + return ( +
+
+ {queueSlot} + {todoSlot} + {items.length > 0 && ( +
+ {items.map((item) => ( + + ))} +
+ )} + {clarificationSlot} +
+
+ ); +} diff --git a/apps/web/components/task/chat/context-items/file-item.tsx b/apps/web/components/task/chat/context-items/file-item.tsx new file mode 100644 index 0000000000..02089e5242 --- /dev/null +++ b/apps/web/components/task/chat/context-items/file-item.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { memo, useCallback } from 'react'; +import type { FileContextItem } from '@/lib/types/context'; +import { ContextChip } from './context-chip'; +import { LazyFilePreview } from './lazy-file-preview'; + +export const FileItem = memo(function FileItem({ + item, + sessionId, +}: { + item: FileContextItem; + sessionId?: string | null; +}) { + const handleClick = useCallback(() => { + item.onOpen(item.path); + }, [item]); + + return ( + } + onClick={handleClick} + onUnpin={item.onUnpin} + onRemove={item.onRemove} + /> + ); +}); diff --git a/apps/web/components/task/chat/context-items/image-item.tsx b/apps/web/components/task/chat/context-items/image-item.tsx new file mode 100644 index 0000000000..7e165bab83 --- /dev/null +++ b/apps/web/components/task/chat/context-items/image-item.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { memo, useState, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, +} from '@kandev/ui/dialog'; +import type { ImageContextItem } from '@/lib/types/context'; +import { ContextChip } from './context-chip'; + +export const ImageItem = memo(function ImageItem({ item }: { item: ImageContextItem }) { + const [dialogOpen, setDialogOpen] = useState(false); + + const handleClick = useCallback(() => { + setDialogOpen(true); + }, []); + + const preview = ( +
+ {/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */} + Preview +
+ {item.attachment.width} × {item.attachment.height} +
+
+ ); + + return ( + <> + + + + Image preview + {/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */} + Full size preview + + + + ); +}); diff --git a/apps/web/components/task/chat/context-items/index.ts b/apps/web/components/task/chat/context-items/index.ts new file mode 100644 index 0000000000..d8095f59e8 --- /dev/null +++ b/apps/web/components/task/chat/context-items/index.ts @@ -0,0 +1,10 @@ +export { ContextChip } from './context-chip'; +export { ContextItemRenderer } from './context-item-renderer'; +export { ContextZone } from './context-zone'; +export { PlanItem } from './plan-item'; +export { FileItem } from './file-item'; +export { PromptItem } from './prompt-item'; +export { CommentItem } from './comment-item'; +export { PlanCommentItem } from './plan-comment-item'; +export { ImageItem } from './image-item'; +export { LazyFilePreview } from './lazy-file-preview'; diff --git a/apps/web/components/task/chat/context-items/lazy-file-preview.tsx b/apps/web/components/task/chat/context-items/lazy-file-preview.tsx new file mode 100644 index 0000000000..16c3c2171f --- /dev/null +++ b/apps/web/components/task/chat/context-items/lazy-file-preview.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { requestFileContent } from '@/lib/ws/workspace-files'; + +type LazyFilePreviewProps = { + path: string; + sessionId: string | null; +}; + +export function LazyFilePreview({ path, sessionId }: LazyFilePreviewProps) { + const [content, setContent] = useState(null); + const [error, setError] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!sessionId) { + setLoading(false); + setError(true); + return; + } + + let cancelled = false; + + async function fetch() { + try { + const client = getWebSocketClient(); + if (!client) { + setError(true); + return; + } + const response = await requestFileContent(client, sessionId!, path); + if (!cancelled) { + setContent(response.content ?? null); + } + } catch { + if (!cancelled) setError(true); + } finally { + if (!cancelled) setLoading(false); + } + } + + fetch(); + return () => { cancelled = true; }; + }, [path, sessionId]); + + if (loading) { + return ( +
+
{path}
+
+
+
+ ); + } + + if (error || content === null) { + return
{path}
; + } + + const preview = content.length > 2000 ? content.slice(0, 2000) + '...' : content; + + return ( +
+
{path}
+
+        {preview}
+      
+
+ ); +} diff --git a/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx b/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx new file mode 100644 index 0000000000..0c6a876a12 --- /dev/null +++ b/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useEffect } from 'react'; +import { useAppStore, useAppStoreApi } from '@/components/state-provider'; +import { getTaskPlan } from '@/lib/api/domains/plan-api'; + +type LazyPlanPreviewProps = { + taskId: string | null; +}; + +export function LazyPlanPreview({ taskId }: LazyPlanPreviewProps) { + const plan = useAppStore((state) => + taskId ? state.taskPlans.byTaskId[taskId] ?? null : null + ); + const loaded = useAppStore((state) => + taskId ? state.taskPlans.loadedByTaskId[taskId] ?? false : false + ); + const loading = useAppStore((state) => + taskId ? state.taskPlans.loadingByTaskId[taskId] ?? false : false + ); + + const storeApi = useAppStoreApi(); + + useEffect(() => { + if (!taskId || loaded || loading) return; + + const { setTaskPlanLoading } = storeApi.getState(); + setTaskPlanLoading(taskId, true); + + getTaskPlan(taskId).then((result) => { + storeApi.getState().setTaskPlan(taskId, result); + }).catch(() => { + storeApi.getState().setTaskPlanLoading(taskId, false); + }); + }, [taskId, loaded, loading, storeApi]); + + if (!taskId) { + return
No task selected
; + } + + if (loading || !loaded) { + return ( +
+
Plan
+
+
+
+ ); + } + + if (!plan?.content) { + return
Plan is empty
; + } + + const preview = plan.content.length > 2000 ? plan.content.slice(0, 2000) + '...' : plan.content; + + return ( +
+
Plan
+
+        {preview}
+      
+
+ ); +} diff --git a/apps/web/components/task/chat/context-items/plan-comment-item.tsx b/apps/web/components/task/chat/context-items/plan-comment-item.tsx new file mode 100644 index 0000000000..63f80bd2ec --- /dev/null +++ b/apps/web/components/task/chat/context-items/plan-comment-item.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { memo } from 'react'; +import type { PlanCommentContextItem } from '@/lib/types/context'; +import { ContextChip } from './context-chip'; + +export const PlanCommentItem = memo(function PlanCommentItem({ item }: { item: PlanCommentContextItem }) { + const preview = ( +
+ {item.comments.map((comment) => ( +
+ {comment.selectedText && ( +
+ “{comment.selectedText}” +
+ )} +
{comment.comment}
+
+ ))} +
+ ); + + return ( + + ); +}); diff --git a/apps/web/components/task/chat/context-items/plan-item.tsx b/apps/web/components/task/chat/context-items/plan-item.tsx new file mode 100644 index 0000000000..a55e8541e4 --- /dev/null +++ b/apps/web/components/task/chat/context-items/plan-item.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { memo } from 'react'; +import type { PlanContextItem } from '@/lib/types/context'; +import { ContextChip } from './context-chip'; +import { LazyPlanPreview } from './lazy-plan-preview'; + +export const PlanItem = memo(function PlanItem({ item }: { item: PlanContextItem }) { + return ( + } + onClick={item.onOpen} + onUnpin={item.onUnpin} + onRemove={item.onRemove} + /> + ); +}); diff --git a/apps/web/components/task/chat/context-items/prompt-item.tsx b/apps/web/components/task/chat/context-items/prompt-item.tsx new file mode 100644 index 0000000000..ab3af2aa8f --- /dev/null +++ b/apps/web/components/task/chat/context-items/prompt-item.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { memo } from 'react'; +import type { PromptContextItem } from '@/lib/types/context'; +import { ContextChip } from './context-chip'; +import { PromptPreview } from './prompt-preview'; + +export const PromptItem = memo(function PromptItem({ item }: { item: PromptContextItem }) { + return ( + } + onClick={item.onClick} + onUnpin={item.onUnpin} + onRemove={item.onRemove} + /> + ); +}); diff --git a/apps/web/components/task/chat/context-items/prompt-preview.tsx b/apps/web/components/task/chat/context-items/prompt-preview.tsx new file mode 100644 index 0000000000..c6aa11647b --- /dev/null +++ b/apps/web/components/task/chat/context-items/prompt-preview.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useAppStore } from '@/components/state-provider'; + +type PromptPreviewProps = { + content: string | null; +}; + +export function PromptPreview({ content }: PromptPreviewProps) { + if (!content) { + return
Custom prompt
; + } + + const truncated = content.length > 2000 ? content.slice(0, 2000) + '...' : content; + + return ( +
+
Prompt
+
+        {truncated}
+      
+
+ ); +} + +type PromptPreviewFromStoreProps = { + promptId: string; +}; + +export function PromptPreviewFromStore({ promptId }: PromptPreviewFromStoreProps) { + const content = useAppStore((state) => { + const prompt = state.prompts.items.find((p) => p.id === promptId); + return prompt?.content ?? null; + }); + + return ; +} diff --git a/apps/web/components/task/chat/context-popover.tsx b/apps/web/components/task/chat/context-popover.tsx new file mode 100644 index 0000000000..1ebdf18e7e --- /dev/null +++ b/apps/web/components/task/chat/context-popover.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { useState, useCallback, useEffect, useRef } from 'react'; +import { IconListCheck, IconFile, IconFolder, IconSearch, IconAt } from '@tabler/icons-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; +import { Checkbox } from '@kandev/ui/checkbox'; +import { Input } from '@kandev/ui/input'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { searchWorkspaceFiles } from '@/lib/ws/workspace-files'; +import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts'; +import { isDirectory, getFileName } from '@/lib/utils/file-path'; +import type { ContextFile } from '@/lib/state/context-files-store'; + +type ContextPopoverProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: React.ReactNode; + sessionId: string | null; + /** Whether plan is included as context (from context files store, not plan panel) */ + planContextEnabled: boolean; + contextFiles: ContextFile[]; + onToggleFile: (file: ContextFile) => void; +}; + +const FILE_SEARCH_DEBOUNCE = 300; + +const PLAN_CONTEXT_FILE: ContextFile = { path: 'plan:context', name: 'Plan', pinned: true }; + +export function ContextPopover({ + open, + onOpenChange, + trigger, + sessionId, + planContextEnabled, + contextFiles, + onToggleFile, +}: ContextPopoverProps) { + const [query, setQuery] = useState(''); + const [fileResults, setFileResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const searchTimeoutRef = useRef | null>(null); + const inputRef = useRef(null); + + const { prompts } = useCustomPrompts(); + + // Focus input when popover opens + /* eslint-disable react-hooks/set-state-in-effect -- resetting state on open/close is intentional */ + useEffect(() => { + if (open) { + requestAnimationFrame(() => inputRef.current?.focus()); + } else { + setQuery(''); + setFileResults([]); + } + }, [open]); + /* eslint-enable react-hooks/set-state-in-effect */ + + // Debounced file search + /* eslint-disable react-hooks/set-state-in-effect -- loading state sync is intentional for UX */ + useEffect(() => { + if (!open || !sessionId) return; + + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + + const delay = query === '' ? 0 : FILE_SEARCH_DEBOUNCE; + setIsLoading(true); + + let cancelled = false; + searchTimeoutRef.current = setTimeout(async () => { + try { + const client = getWebSocketClient(); + if (!client) { + if (!cancelled) { setFileResults([]); setIsLoading(false); } + return; + } + const response = await searchWorkspaceFiles(client, sessionId, query || '', 20); + if (!cancelled) setFileResults(response.files || []); + } catch { + if (!cancelled) setFileResults([]); + } + if (!cancelled) setIsLoading(false); + }, delay); + + return () => { + cancelled = true; + if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current); + }; + }, [open, sessionId, query]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const handleToggleFile = useCallback( + (filePath: string) => { + const name = getFileName(filePath); + onToggleFile({ path: filePath, name, pinned: true }); + }, + [onToggleFile] + ); + + const isFileSelected = useCallback( + (path: string) => contextFiles.some((f) => f.path === path), + [contextFiles] + ); + + // Filter prompts by query + const filteredPrompts = query + ? prompts.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) + : prompts; + + return ( + + {trigger} + e.preventDefault()} + > + {/* Header */} +
+

Context

+

· Select files and prompts to include

+
+ + {/* Search */} +
+
+ + setQuery(e.target.value)} + placeholder="Search files and prompts..." + className="h-7 pl-7 text-xs" + /> +
+
+ + {/* Items */} +
+ {/* Plan item - always first, only when no search query */} + {!query && ( +
onToggleFile(PLAN_CONTEXT_FILE)} + > + onToggleFile(PLAN_CONTEXT_FILE)} + className="h-3.5 w-3.5" + /> + + Plan + {planContextEnabled && ( + + ACTIVE + + )} +
+ )} + + {/* Prompts section */} + {filteredPrompts.length > 0 && ( + <> + {!query && ( +
+

Prompts

+
+ )} + {filteredPrompts.map((prompt) => ( +
{ + // Insert prompt content by simulating a mention select - for now just copy to clipboard or similar + // Prompts in context popover: clicking inserts the prompt name as a context file reference + // This toggles the prompt as a context file + onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true }); + }} + > + f.path === `prompt:${prompt.id}`)} + onCheckedChange={() => onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true })} + className="h-3.5 w-3.5" + /> + +
+ {prompt.name} + {prompt.content && ( + + {prompt.content.length > 60 ? prompt.content.slice(0, 60) + '...' : prompt.content} + + )} +
+
+ ))} + + )} + + {/* Files section header */} + {!query && fileResults.length > 0 && ( +
+

Files

+
+ )} + + {/* File results */} + {isLoading ? ( +
Loading...
+ ) : fileResults.length === 0 && query && filteredPrompts.length === 0 ? ( +
No results found
+ ) : ( + fileResults.map((filePath) => { + const isDir = isDirectory(filePath); + const name = getFileName(filePath); + const parent = filePath.slice(0, filePath.length - name.length); + return ( +
handleToggleFile(filePath)} + > + handleToggleFile(filePath)} + className="h-3.5 w-3.5" + /> + {isDir ? ( + + ) : ( + + )} +
+ {name} + {parent && ( + {parent} + )} +
+
+ ); + }) + )} +
+
+
+ ); +} diff --git a/apps/web/components/task/chat/document-reference-indicator.tsx b/apps/web/components/task/chat/document-reference-indicator.tsx deleted file mode 100644 index 0bf0a43e25..0000000000 --- a/apps/web/components/task/chat/document-reference-indicator.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { memo } from 'react'; -import { IconFileText, IconListCheck } from '@tabler/icons-react'; -import type { ActiveDocument } from '@/lib/state/slices/ui/types'; - -type DocumentReferenceIndicatorProps = { - activeDocument: ActiveDocument; -}; - -export const DocumentReferenceIndicator = memo(function DocumentReferenceIndicator({ - activeDocument, -}: DocumentReferenceIndicatorProps) { - const isPlan = activeDocument.type === 'plan'; - const label = isPlan - ? 'Plan will be included in the prompt' - : `${activeDocument.name} will be included in the prompt`; - - return ( -
- {isPlan ? ( - - ) : ( - - )} - {label} -
- ); -}); diff --git a/apps/web/components/task/chat/editor-context.tsx b/apps/web/components/task/chat/editor-context.tsx new file mode 100644 index 0000000000..5787b30150 --- /dev/null +++ b/apps/web/components/task/chat/editor-context.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +type EditorContextValue = { + sessionId: string | null; + taskId: string | null; +}; + +const EditorContext = createContext({ sessionId: null, taskId: null }); + +export const EditorContextProvider = EditorContext.Provider; +export const useEditorContext = () => useContext(EditorContext); diff --git a/apps/web/components/task/chat/image-attachment-preview.tsx b/apps/web/components/task/chat/image-attachment-preview.tsx index 428b818531..a4406ff45c 100644 --- a/apps/web/components/task/chat/image-attachment-preview.tsx +++ b/apps/web/components/task/chat/image-attachment-preview.tsx @@ -10,6 +10,8 @@ export type ImageAttachment = { mimeType: string; // "image/png", "image/jpeg", "image/gif", "image/webp" preview: string; // Data URL for preview display size: number; // File size in bytes + width: number; // Image width in pixels + height: number; // Image height in pixels }; // Supported image types @@ -20,7 +22,7 @@ export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB per image export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total export const MAX_IMAGES = 10; -function formatBytes(bytes: number): string { +export function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; @@ -113,13 +115,21 @@ export function processImageFile(file: File): Promise { const base64 = dataUrl.split(',')[1]; const mimeType = dataUrl.split(';')[0].split(':')[1]; - resolve({ - id: crypto.randomUUID(), - data: base64, - mimeType, - preview: dataUrl, - size: file.size, - }); + // Read image dimensions + const img = new Image(); + img.onload = () => { + resolve({ + id: crypto.randomUUID(), + data: base64, + mimeType, + preview: dataUrl, + size: file.size, + width: img.naturalWidth, + height: img.naturalHeight, + }); + }; + img.onerror = () => resolve(null); + img.src = dataUrl; }; reader.onerror = () => { console.error('Failed to read image file'); diff --git a/apps/web/components/task/chat/mention-menu.tsx b/apps/web/components/task/chat/mention-menu.tsx index f04a583a84..b070c326c9 100644 --- a/apps/web/components/task/chat/mention-menu.tsx +++ b/apps/web/components/task/chat/mention-menu.tsx @@ -1,13 +1,15 @@ 'use client'; -import { IconAt, IconFile, IconFolder } from '@tabler/icons-react'; +import { IconAt, IconFile, IconFolder, IconListCheck } from '@tabler/icons-react'; import type { MentionItem } from '@/hooks/use-inline-mention'; +import { getFileName, isDirectory } from '@/lib/utils/file-path'; import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu'; type MentionMenuProps = { isOpen: boolean; isLoading: boolean; - position: { x: number; y: number } | null; + position?: { x: number; y: number } | null; + clientRect?: (() => DOMRect | null) | null; items: MentionItem[]; query: string; selectedIndex: number; @@ -16,36 +18,25 @@ type MentionMenuProps = { setSelectedIndex: (index: number) => void; }; -// Extract filename and parent path from a full path -function parseFilePath(filePath: string): { name: string; parent: string } { - const parts = filePath.split('/'); - const name = parts.pop() || filePath; - const parent = parts.length > 0 ? parts.join('/') + '/' : ''; - return { name, parent }; -} - -// Check if path looks like a directory (ends with / or has no extension) -function isDirectory(filePath: string): boolean { - if (filePath.endsWith('/')) return true; - const name = filePath.split('/').pop() || ''; - return !name.includes('.'); -} - // Get the appropriate icon for an item function getItemIcon(item: MentionItem) { - if (item.type === 'prompt') { + if (item.kind === 'prompt') { return ; } + if (item.kind === 'plan') { + return ; + } const isDir = isDirectory(item.label); return isDir ? : ; } // Get the label and description for an item function getItemDisplay(item: MentionItem): { label: string; description?: string } { - if (item.type === 'prompt') { + if (item.kind === 'prompt' || item.kind === 'plan') { return { label: item.label, description: item.description }; } - const { name, parent } = parseFilePath(item.label); + const name = getFileName(item.label); + const parent = item.label.slice(0, item.label.length - name.length); return { label: name, description: parent || undefined }; } @@ -53,6 +44,7 @@ export function MentionMenu({ isOpen, isLoading, position, + clientRect, items, query, selectedIndex, @@ -71,7 +63,8 @@ export function MentionMenu({ return ( ; plan_mode?: boolean; has_review_comments?: boolean } | undefined; + // Extract image attachments, context files, and context flags from metadata + const metadata = comment.metadata as { + attachments?: Array<{ type: string; data: string; mime_type: string }>; + plan_mode?: boolean; + has_review_comments?: boolean; + context_files?: Array<{ path: string; name: string }>; + } | undefined; const imageAttachments = (metadata?.attachments || []).filter(att => att.type === 'image'); + const contextFiles = metadata?.context_files || []; const hasPlanMode = !!metadata?.plan_mode; const hasReviewComments = !!metadata?.has_review_comments; const hasContent = comment.content && comment.content.trim() !== ''; @@ -150,8 +156,8 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
{/* Context badges */} - {(hasPlanMode || hasReviewComments) && ( -
+ {(hasPlanMode || hasReviewComments || contextFiles.length > 0) && ( +
{hasPlanMode && ( Plan mode @@ -162,6 +168,11 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className Review comments )} + {contextFiles.map((f) => ( + + {f.name} + + ))}
)}
diff --git a/apps/web/components/task/chat/popup-menu.tsx b/apps/web/components/task/chat/popup-menu.tsx index ab3e38a87b..fbf9c8ec79 100644 --- a/apps/web/components/task/chat/popup-menu.tsx +++ b/apps/web/components/task/chat/popup-menu.tsx @@ -10,6 +10,8 @@ const MENU_MAX_WIDTH = 700; export type PopupMenuProps = { isOpen: boolean; position: { x: number; y: number } | null; + /** Alternative to position: a function returning a DOMRect (from TipTap suggestion) */ + clientRect?: (() => DOMRect | null) | null; title: string; selectedIndex: number; onClose: () => void; @@ -21,6 +23,7 @@ export type PopupMenuProps = { export function PopupMenu({ isOpen, position, + clientRect: clientRectFn, title, onClose, children, @@ -43,16 +46,26 @@ export function PopupMenu({ return () => document.removeEventListener('mousedown', handleClickOutside); }, [isOpen, onClose]); - if (!isOpen || !position) { + // Resolve position from clientRect function or direct position + const resolvedPosition = (() => { + if (position) return position; + if (clientRectFn) { + const rect = clientRectFn(); + if (rect) return { x: rect.left, y: rect.top }; + } + return null; + })(); + + if (!isOpen || !resolvedPosition) { return null; } // Calculate position (above cursor) with content-based width const menuStyle: React.CSSProperties = { position: 'fixed', - left: Math.max(8, position.x), - bottom: window.innerHeight - position.y + 8, - maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - position.x - 8), + left: Math.max(8, resolvedPosition.x), + bottom: window.innerHeight - resolvedPosition.y + 8, + maxWidth: Math.min(MENU_MAX_WIDTH, window.innerWidth - resolvedPosition.x - 8), maxHeight: MENU_HEIGHT, zIndex: 50, }; diff --git a/apps/web/components/task/chat/slash-command-menu.tsx b/apps/web/components/task/chat/slash-command-menu.tsx index 8d4292fa20..2195cdafc2 100644 --- a/apps/web/components/task/chat/slash-command-menu.tsx +++ b/apps/web/components/task/chat/slash-command-menu.tsx @@ -6,7 +6,8 @@ import { PopupMenu, PopupMenuItem, useMenuItemRefs } from './popup-menu'; type SlashCommandMenuProps = { isOpen: boolean; - position: { x: number; y: number } | null; + position?: { x: number; y: number } | null; + clientRect?: (() => DOMRect | null) | null; commands: SlashCommand[]; selectedIndex: number; onSelect: (command: SlashCommand) => void; @@ -17,6 +18,7 @@ type SlashCommandMenuProps = { export function SlashCommandMenu({ isOpen, position, + clientRect, commands, selectedIndex, onSelect, @@ -32,7 +34,8 @@ export function SlashCommandMenu({ return ( + + {/* @ts-expect-error -- NodeViewContent 'as' prop accepts any HTML tag but types only allow 'div' */} + + + ); +} + +// ── Handle ────────────────────────────────────────────────────────── + +export type TipTapInputHandle = { + focus: () => void; + blur: () => void; + getSelectionStart: () => number; + getValue: () => string; + setValue: (value: string) => void; + clear: () => void; + getTextareaElement: () => HTMLElement | null; + insertText: (text: string, from: number, to: number) => void; + getMentions: () => ContextFile[]; +}; + +// ── Props ─────────────────────────────────────────────────────────── + +type TipTapInputProps = { + value: string; + onChange: (value: string) => void; + onSubmit?: () => void; + placeholder?: string; + disabled?: boolean; + className?: string; + planModeEnabled?: boolean; + submitKey?: 'enter' | 'cmd_enter'; + onFocus?: () => void; + onBlur?: () => void; + // TipTap-specific + sessionId: string | null; + taskId?: string | null; + onAddContextFile?: (file: ContextFile) => void; + onToggleContextFile?: (file: ContextFile) => void; + planContextEnabled?: boolean; + onAgentCommand?: (commandName: string) => void; + onImagePaste?: (files: File[]) => void; +}; + +// ── Filter items ──────────────────────────────────────────────────── +function filterItems(items: MentionItem[], query: string): MentionItem[] { + if (!query) return items; + const lq = query.toLowerCase(); + return items + .map((item) => { + const label = item.label.toLowerCase(); + let score = 0; + if (label.startsWith(lq)) score = 100; + else if (label.split(/[\s\-_/]/).some((w) => w.startsWith(lq))) score = 50; + else if (label.includes(lq)) score = 25; + return { item, score }; + }) + .filter(({ score }) => score > 0) + .sort((a, b) => b.score - a.score) + .map(({ item }) => item); +} + +// ── Component ─────────────────────────────────────────────────────── + +export const TipTapInput = forwardRef( + function TipTapInput( + { + value, + onChange, + onSubmit, + placeholder = '', + disabled = false, + className, + planModeEnabled = false, + submitKey = 'cmd_enter', + onFocus, + onBlur, + sessionId, + taskId, + onAgentCommand, + onImagePaste, + }, + ref, + ) { + // ── Refs for stable callbacks ───────────────────────────────── + + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const submitKeyRef = useRef(submitKey); + submitKeyRef.current = submitKey; + const disabledRef = useRef(disabled); + disabledRef.current = disabled; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + const onImagePasteRef = useRef(onImagePaste); + onImagePasteRef.current = onImagePaste; + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; + + // ── Menu state ──────────────────────────────────────────────── + + const [mentionMenu, setMentionMenu] = useState>({ + isOpen: false, items: [], query: '', clientRect: null, command: null, + }); + const [slashMenu, setSlashMenu] = useState>({ + isOpen: false, items: [], query: '', clientRect: null, command: null, + }); + const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0); + const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); + + // Reset selected index when items change + useEffect(() => { setMentionSelectedIndex(0); }, [mentionMenu.items]); + useEffect(() => { setSlashSelectedIndex(0); }, [slashMenu.items]); + + // ── Keyboard handler refs (set by suggestion lifecycle) ─────── + + const mentionKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null); + const slashKeyDownRef = useRef<((event: KeyboardEvent) => boolean) | null>(null); + + const mentionSelectedIndexRef = useRef(mentionSelectedIndex); + mentionSelectedIndexRef.current = mentionSelectedIndex; + + const slashSelectedIndexRef = useRef(slashSelectedIndex); + slashSelectedIndexRef.current = slashSelectedIndex; + + // Wire up keyboard navigation for menus + mentionKeyDownRef.current = useCallback((event: KeyboardEvent) => { + if (!mentionMenu.isOpen) return false; + if (event.key === 'ArrowDown') { + setMentionSelectedIndex((i) => Math.min(i + 1, mentionMenu.items.length - 1)); + return true; + } + if (event.key === 'ArrowUp') { + setMentionSelectedIndex((i) => Math.max(i - 1, 0)); + return true; + } + if (event.key === 'Enter' || event.key === 'Tab') { + if (mentionMenu.items.length > 0 && mentionMenu.command) { + const idx = mentionSelectedIndexRef.current; + const item = mentionMenu.items[idx]; + if (item) mentionMenu.command(item); + return true; + } + } + return false; + }, [mentionMenu]); + + slashKeyDownRef.current = useCallback((event: KeyboardEvent) => { + if (!slashMenu.isOpen) return false; + if (event.key === 'ArrowDown') { + setSlashSelectedIndex((i) => Math.min(i + 1, slashMenu.items.length - 1)); + return true; + } + if (event.key === 'ArrowUp') { + setSlashSelectedIndex((i) => Math.max(i - 1, 0)); + return true; + } + if (event.key === 'Enter' || event.key === 'Tab') { + if (slashMenu.items.length > 0 && slashMenu.command) { + const idx = slashSelectedIndexRef.current; + const cmd = slashMenu.items[idx]; + if (cmd) slashMenu.command(cmd); + return true; + } + } + return false; + }, [slashMenu]); + + // ── Data sources ────────────────────────────────────────────── + + const { prompts } = useCustomPrompts(); + + const agentCommands = useAppStore((state) => + sessionId ? state.availableCommands.bySessionId[sessionId] : undefined + ); + + const slashCommands = useMemo((): SlashCommand[] => { + if (!agentCommands || agentCommands.length === 0) return []; + return agentCommands + .filter((cmd) => { + const desc = cmd.description || ''; + return !desc.includes('(bundled)'); + }) + .map((cmd) => ({ + id: `agent-${cmd.name}`, + label: `/${cmd.name}`, + description: cmd.description || `Run /${cmd.name} command`, + action: 'agent' as const, + agentCommandName: cmd.name, + })); + }, [agentCommands]); + + // ── Mention item search ─────────────────────────────────────── + + const promptsRef = useRef(prompts); + promptsRef.current = prompts; + const lastFileSearchRef = useRef<{ query: string; results: string[] }>({ query: '', results: [] }); + + const getMentionItems = useCallback(async (query: string): Promise => { + const allItems: MentionItem[] = []; + + // Plan item + const planItem: MentionItem = { + id: '__plan__', + kind: 'plan', + label: 'Plan', + description: 'Include the plan as context', + onSelect: () => {}, // unused in TipTap flow + }; + allItems.push(planItem); + + // Prompt items + for (const p of promptsRef.current) { + allItems.push({ + id: p.id, + kind: 'prompt', + label: p.name, + description: p.content.length > 100 ? p.content.slice(0, 100) + '...' : p.content, + onSelect: () => {}, + }); + } + + // File search + const sid = sessionIdRef.current; + if (sid) { + try { + const client = getWebSocketClient(); + if (client) { + const cacheKey = query || '__empty__'; + let files: string[]; + if (lastFileSearchRef.current.query === cacheKey) { + files = lastFileSearchRef.current.results; + } else { + const response = await searchWorkspaceFiles(client, sid, query || '', 20); + files = response.files || []; + lastFileSearchRef.current = { query: cacheKey, results: files }; + } + for (const filePath of files) { + allItems.push({ + id: filePath, + kind: 'file', + label: filePath, + description: 'File', + onSelect: () => {}, + }); + } + } + } catch { + // ignore + } + } + + return filterItems(allItems, query); + }, []); + + // ── Suggestion configs ──────────────────────────────────────── + + const mentionCallbacks = useMemo((): MentionSuggestionCallbacks => ({ + getItems: getMentionItems, + }), [getMentionItems]); + + const onAgentCommandRef = useRef(onAgentCommand); + onAgentCommandRef.current = onAgentCommand; + const slashCommandsRef = useRef(slashCommands); + slashCommandsRef.current = slashCommands; + + const slashCallbacks = useMemo((): SlashSuggestionCallbacks => ({ + getCommands: () => slashCommandsRef.current, + onAgentCommand: (name) => onAgentCommandRef.current?.(name), + }), []); + + const mentionSuggestion = useMemo( + () => createMentionSuggestion(mentionCallbacks, setMentionMenu, mentionKeyDownRef), + [mentionCallbacks], + ); + + const slashSuggestion = useMemo( + () => createSlashSuggestion(slashCallbacks, setSlashMenu, slashKeyDownRef), + [slashCallbacks], + ); + + // ── Submit keymap extension ─────────────────────────────────── + + const SubmitKeymap = useMemo(() => { + return Extension.create({ + name: 'submitKeymap', + addKeyboardShortcuts() { + return { + 'Enter': () => { + if (disabledRef.current) return true; + if (submitKeyRef.current === 'enter') { + onSubmitRef.current?.(); + return true; + } + return false; + }, + 'Mod-Enter': () => { + if (disabledRef.current) return true; + if (submitKeyRef.current === 'cmd_enter') { + onSubmitRef.current?.(); + return true; + } + return false; + }, + }; + }, + }); + }, []); + + // ── Editor ──────────────────────────────────────────────────── + + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + HardBreak, + History, + Code, + CodeBlockLowlight.extend({ + addNodeView() { + return ReactNodeViewRenderer(CodeBlockView); + }, + }).configure({ lowlight: lowlightInstance }), + Placeholder.configure({ placeholder }), + ContextMention.configure({ + suggestions: [mentionSuggestion, slashSuggestion], + }), + SubmitKeymap, + ], + editorProps: { + attributes: { + class: cn( + 'w-full h-full resize-none bg-transparent px-2 py-2 overflow-y-auto', + 'text-sm leading-relaxed', + 'placeholder:text-muted-foreground', + 'focus:outline-none', + 'disabled:cursor-not-allowed disabled:opacity-50', + planModeEnabled && 'border-primary/40', + className, + ), + }, + handlePaste: (view, event) => { + // 1. Image paste + const items = event.clipboardData?.items; + if (items) { + const imageFiles: File[] = []; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + if (imageFiles.length > 0) { + event.preventDefault(); + onImagePasteRef.current?.(imageFiles); + return true; + } + } + + // 2. Markdown code fence paste + const text = event.clipboardData?.getData('text/plain'); + if (text && text.includes('```')) { + const segments = parseCodeFences(text); + if (segments.some((s) => s.type === 'code')) { + event.preventDefault(); + const { schema } = view.state; + const nodes: import('@tiptap/pm/model').Node[] = []; + for (const seg of segments) { + if (seg.type === 'code') { + nodes.push( + schema.nodes.codeBlock.create( + seg.language ? { language: seg.language } : null, + seg.text ? schema.text(seg.text) : undefined, + ), + ); + } else { + const trimmed = seg.text.trim(); + if (!trimmed) continue; + for (const line of trimmed.split('\n')) { + nodes.push( + schema.nodes.paragraph.create( + null, + line ? schema.text(line) : undefined, + ), + ); + } + } + } + if (nodes.length > 0) { + const { from, to } = view.state.selection; + view.dispatch(view.state.tr.replaceWith(from, to, nodes)); + } + return true; + } + } + + return false; + }, + handleDOMEvents: { + focus: () => { + onFocus?.(); + return false; + }, + blur: () => { + onBlur?.(); + return false; + }, + }, + }, + onUpdate: ({ editor: e }) => { + if (isSyncingRef.current) return; + if (!initialSyncDoneRef.current) return; + const text = getMarkdownText(e); + onChangeRef.current(text); + // Persist rich content (mentions, code blocks, etc.) + const sid = sessionIdRef.current; + if (sid) setChatDraftContent(sid, e.getJSON()); + }, + editable: !disabled, + immediatelyRender: false, + }); + + // Guard to prevent circular updates when syncing external value → editor + const isSyncingRef = useRef(false); + // Tracks whether the first value→editor sync has completed. + // Until it has, onUpdate callbacks are suppressed so the editor's + // init-time empty update doesn't wipe the hydrated draft. + const initialSyncDoneRef = useRef(false); + + // Sync disabled state + useEffect(() => { + if (editor) { + editor.setEditable(!disabled); + } + }, [editor, disabled]); + + // Sync placeholder — the Placeholder extension reads its config once, + // so we update it when the prop changes. + useEffect(() => { + if (!editor) return; + editor.extensionManager.extensions.forEach((ext) => { + if (ext.name === 'placeholder') { + ext.options.placeholder = placeholder; + editor.view.dispatch(editor.state.tr); + } + }); + }, [editor, placeholder]); + + // Reset sync flag when session changes so the new session's content is restored + const prevSyncSessionRef = useRef(sessionId); + useEffect(() => { + if (sessionId === prevSyncSessionRef.current) return; + prevSyncSessionRef.current = sessionId; + initialSyncDoneRef.current = false; + }, [sessionId]); + + // Sync value prop changes (for history navigation, clearing after send) + // On initial sync, prefer stored JSON (preserves mentions, code blocks) over plain text. + useEffect(() => { + /* eslint-disable react-hooks/immutability -- initialSyncDoneRef is intentionally read+written as a one-shot gate */ + if (!editor) return; + + if (!initialSyncDoneRef.current) { + // Try to restore rich content from sessionStorage + const sid = sessionId; + if (sid) { + const savedContent = getChatDraftContent(sid); + if (savedContent) { + isSyncingRef.current = true; + editor.commands.setContent(savedContent as import('@tiptap/core').Content); + isSyncingRef.current = false; + initialSyncDoneRef.current = true; + // Sync the plain text value back to parent + onChangeRef.current(getMarkdownText(editor)); + return; + } + } + } + + // When value is empty, always clear — plain-text comparison may miss + // rich nodes (mentions, code blocks) that have no text representation. + if (value === '') { + if (!editor.isEmpty) { + isSyncingRef.current = true; + editor.commands.clearContent(); + isSyncingRef.current = false; + } + initialSyncDoneRef.current = true; + return; + } + + const currentText = getMarkdownText(editor); + if (currentText === value) { + initialSyncDoneRef.current = true; + return; + } + + isSyncingRef.current = true; + editor.commands.setContent(`

${escapeHtml(value)}

`); + isSyncingRef.current = false; + initialSyncDoneRef.current = true; + /* eslint-enable react-hooks/immutability */ + }, [editor, value, sessionId]); + + // ── Imperative handle ───────────────────────────────────────── + + useImperativeHandle(ref, () => ({ + focus: () => editor?.commands.focus(), + blur: () => editor?.commands.blur(), + getSelectionStart: () => editor?.state.selection.from ?? 0, + getValue: () => editor ? getMarkdownText(editor) : '', + setValue: (v: string) => { + if (!editor) return; + isSyncingRef.current = true; + if (v === '') { + editor.commands.clearContent(); + } else { + editor.commands.setContent(`

${escapeHtml(v)}

`); + } + isSyncingRef.current = false; + onChange(v); + }, + clear: () => { + if (!editor) return; + isSyncingRef.current = true; + editor.commands.clearContent(); + isSyncingRef.current = false; + onChange(''); + }, + getTextareaElement: () => editor?.view.dom ?? null, + insertText: (text: string, from: number, to: number) => { + if (!editor) return; + editor.chain().focus().insertContentAt({ from, to }, text).run(); + }, + getMentions: () => { + if (!editor) return []; + const mentions: ContextFile[] = []; + editor.state.doc.descendants((node) => { + if (node.type.name === 'contextMention') { + const { kind, path, label } = node.attrs; + if (kind === 'file') mentions.push({ path, name: label, pinned: false }); + else if (kind === 'prompt') mentions.push({ path, name: label, pinned: false }); + // Skip plan — handled via context store toggle + } + }); + return mentions; + }, + }), [editor, onChange]); + + // ── Menu handlers ───────────────────────────────────────────── + + const handleMentionSelect = useCallback((item: MentionItem) => { + mentionMenu.command?.(item); + }, [mentionMenu]); + + const handleMentionClose = useCallback(() => { + setMentionMenu({ isOpen: false, items: [], query: '', clientRect: null, command: null }); + }, []); + + const handleSlashSelect = useCallback((cmd: SlashCommand) => { + slashMenu.command?.(cmd); + }, [slashMenu]); + + const handleSlashClose = useCallback(() => { + setSlashMenu({ isOpen: false, items: [], query: '', clientRect: null, command: null }); + }, []); + + // ── Render ──────────────────────────────────────────────────── + + return ( + <> + + + + + + + ); + }, +); + +// ── Helpers ───────────────────────────────────────────────────────── + +/** + * Serialize TipTap editor content to markdown-like text. + * Preserves inline `code`, ```code blocks```, and @mention labels. + */ +function getMarkdownText(editor: { getJSON: () => { content?: JSONNode[] } }): string { + const doc = editor.getJSON(); + if (!doc.content) return ''; + return doc.content.map(serializeNode).join('\n'); +} + +type JSONNode = { + type?: string; + text?: string; + attrs?: Record; + marks?: Array<{ type: string }>; + content?: JSONNode[]; +}; + +function serializeNode(node: JSONNode): string { + switch (node.type) { + case 'paragraph': + return serializeInline(node.content ?? []); + case 'codeBlock': { + const lang = (node.attrs?.language as string) || ''; + const text = serializeInline(node.content ?? []); + return '```' + lang + '\n' + text + '\n```'; + } + case 'hardBreak': + return '\n'; + default: + // Unknown block — try to serialize children + if (node.content) return node.content.map(serializeNode).join('\n'); + return node.text ?? ''; + } +} + +function serializeInline(nodes: JSONNode[]): string { + return nodes.map((n) => { + if (n.type === 'hardBreak') return '\n'; + if (n.type === 'contextMention') { + return n.attrs?.label ? `@${n.attrs.label}` : ''; + } + const text = n.text ?? ''; + if (n.marks?.some((m) => m.type === 'code')) { + return '`' + text + '`'; + } + return text; + }).join(''); +} + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +type FenceSegment = { type: 'text'; text: string } | { type: 'code'; text: string; language: string | null }; + +/** Parse text containing markdown ``` fences into text/code segments. */ +function parseCodeFences(text: string): FenceSegment[] { + const lines = text.split('\n'); + const segments: FenceSegment[] = []; + let currentType: 'text' | 'code' = 'text'; + let currentLines: string[] = []; + let currentLang: string | null = null; + + for (const line of lines) { + if (line.trimStart().startsWith('```')) { + if (currentType === 'text') { + if (currentLines.length > 0) { + segments.push({ type: 'text', text: currentLines.join('\n') }); + } + // Extract language from opening fence (e.g. ```typescript) + currentLang = line.trimStart().slice(3).trim() || null; + currentLines = []; + currentType = 'code'; + } else { + segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang }); + currentLines = []; + currentLang = null; + currentType = 'text'; + } + } else { + currentLines.push(line); + } + } + + if (currentLines.length > 0) { + if (currentType === 'code') { + segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang }); + } else { + segments.push({ type: 'text', text: currentLines.join('\n') }); + } + } + + return segments; +} diff --git a/apps/web/components/task/chat/tiptap-mention-extension.tsx b/apps/web/components/task/chat/tiptap-mention-extension.tsx new file mode 100644 index 0000000000..ac978f04e8 --- /dev/null +++ b/apps/web/components/task/chat/tiptap-mention-extension.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { createElement, useCallback, useRef, useState } from 'react'; +import { mergeAttributes, Node } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper, type ReactNodeViewProps } from '@tiptap/react'; +import { Suggestion, type SuggestionOptions } from '@tiptap/suggestion'; +import { IconFile, IconFolder, IconAt, IconListCheck } from '@tabler/icons-react'; +import { HoverCard, HoverCardTrigger, HoverCardContent } from '@kandev/ui/hover-card'; +import { isDirectory } from '@/lib/utils/file-path'; +import { usePanelActions } from '@/hooks/use-panel-actions'; +import { useEditorContext } from './editor-context'; +import { LazyFilePreview } from './context-items/lazy-file-preview'; +import { LazyPlanPreview } from './context-items/lazy-plan-preview'; +import { PromptPreviewFromStore } from './context-items/prompt-preview'; + +export type MentionKind = 'file' | 'prompt' | 'plan'; + +export type ContextMentionOptions = { + /** Suggestion configs — one per trigger character (e.g. @ and /) */ + suggestions: Array>; +}; + +/** + * Custom mention node extension that renders as an inline chip. + * Stores kind/path as attributes so we know what was mentioned. + * Accepts multiple suggestion plugins (@ mentions and / commands). + */ +export const ContextMention = Node.create({ + name: 'contextMention', + group: 'inline', + inline: true, + selectable: false, + atom: true, + + addOptions() { + return { + suggestions: [], + }; + }, + + addAttributes() { + return { + id: { default: null }, + label: { default: null }, + kind: { default: 'file' as MentionKind }, + path: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-context-mention]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'span', + mergeAttributes( + { 'data-context-mention': '' }, + HTMLAttributes, + ), + HTMLAttributes.label || HTMLAttributes.id || '', + ]; + }, + + renderText({ node }) { + return `@${node.attrs.label ?? node.attrs.id}`; + }, + + addNodeView() { + return ReactNodeViewRenderer(MentionChipView); + }, + + addProseMirrorPlugins() { + return this.options.suggestions.map((suggestion) => + Suggestion({ + editor: this.editor, + ...suggestion, + }), + ); + }, +}); + +function MentionChipView({ node }: ReactNodeViewProps) { + const { id, label, kind, path } = node.attrs as { id: string; label: string; kind: MentionKind; path: string }; + const { sessionId, taskId } = useEditorContext(); + const { openFile, addPlan } = usePanelActions(); + const [hoverOpen, setHoverOpen] = useState(false); + const suppressRef = useRef(false); + + const clickable = kind === 'file' || kind === 'plan'; + + const handleClick = useCallback(() => { + suppressRef.current = true; + setHoverOpen(false); + setTimeout(() => { suppressRef.current = false; }, 300); + if (kind === 'file' && path) { + openFile(path); + } else if (kind === 'plan') { + addPlan(); + } + }, [kind, path, openFile, addPlan]); + + const icon = getMentionIcon(kind, path); + + const preview = useMentionPreview(kind, path, id, sessionId, taskId); + + const chip = ( + + {createElement(icon, { className: "h-3 w-3 shrink-0 text-muted-foreground" })} + {label} + + ); + + if (!preview) { + return {chip}; + } + + return ( + + { + if (next && suppressRef.current) return; + setHoverOpen(next); + }} + openDelay={300} + > + {chip} + + {preview} + + + + ); +} + +function useMentionPreview( + kind: MentionKind, + path: string, + id: string, + sessionId: string | null, + taskId: string | null, +): React.ReactNode { + if (kind === 'file') { + return ; + } + + if (kind === 'prompt') { + return ; + } + + if (kind === 'plan') { + return ; + } + + return null; +} + +function getMentionIcon(kind: MentionKind, path?: string) { + if (kind === 'prompt') return IconAt; + if (kind === 'plan') return IconListCheck; + if (path && isDirectory(path)) return IconFolder; + return IconFile; +} diff --git a/apps/web/components/task/chat/tiptap-suggestion.tsx b/apps/web/components/task/chat/tiptap-suggestion.tsx new file mode 100644 index 0000000000..16c58476fa --- /dev/null +++ b/apps/web/components/task/chat/tiptap-suggestion.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { PluginKey } from '@tiptap/pm/state'; +import type { SuggestionOptions, SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'; +import type { MentionItem } from '@/hooks/use-inline-mention'; +import type { SlashCommand } from '@/hooks/use-inline-slash'; + +import { getFileName } from '@/lib/utils/file-path'; +import type { MentionKind } from './tiptap-mention-extension'; + +// ── Shared types ──────────────────────────────────────────────────── + +export type MenuState = { + isOpen: boolean; + items: T[]; + query: string; + clientRect: (() => DOMRect | null) | null; + command: ((item: T) => void) | null; +}; + +const EMPTY_MENTION_STATE: MenuState = { + isOpen: false, + items: [], + query: '', + clientRect: null, + command: null, +}; + +const EMPTY_SLASH_STATE: MenuState = { + isOpen: false, + items: [], + query: '', + clientRect: null, + command: null, +}; + +// ── Mention suggestion ────────────────────────────────────────────── + +export type MentionSuggestionCallbacks = { + getItems: (query: string) => Promise; +}; + +export const MentionSuggestionPluginKey = new PluginKey('mentionSuggestion'); + +/** + * Creates a mention suggestion config for TipTap. + * `setMenuState` drives the React rendering of MentionMenu. + * `onKeyDownRef` is a mutable ref that the parent uses to delegate keystrokes. + */ +export function createMentionSuggestion( + callbacks: MentionSuggestionCallbacks, + setMenuState: (state: MenuState) => void, + onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>, +): Partial> { + return { + char: '@', + pluginKey: MentionSuggestionPluginKey, + allowSpaces: false, + + items: async ({ query }) => { + return callbacks.getItems(query); + }, + + command: ({ editor, range, props: mentionAttrs }) => { + editor + .chain() + .focus() + .insertContentAt(range, [ + { type: 'contextMention', attrs: mentionAttrs }, + { type: 'text', text: ' ' }, + ]) + .run(); + }, + + render: () => { + return { + onStart(props: SuggestionProps) { + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (item: MentionItem) => { + props.command(mentionItemToAttrs(item)); + }, + }); + }, + + onUpdate(props: SuggestionProps) { + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (item: MentionItem) => { + props.command(mentionItemToAttrs(item)); + }, + }); + }, + + onKeyDown(kd: SuggestionKeyDownProps) { + if (kd.event.key === 'Escape') { + setMenuState(EMPTY_MENTION_STATE); + return true; + } + return onKeyDownRef.current?.(kd.event) ?? false; + }, + + onExit() { + setMenuState(EMPTY_MENTION_STATE); + }, + }; + }, + }; +} + +function mentionItemToAttrs(item: MentionItem): { id: string; label: string; kind: MentionKind; path: string } { + const name = item.kind === 'file' + ? getFileName(item.label) + : item.label; + + return { + id: item.id, + label: name, + kind: item.kind, + path: item.kind === 'file' ? item.label : (item.kind === 'prompt' ? `prompt:${item.id}` : 'plan:context'), + }; +} + +// ── Slash command suggestion ──────────────────────────────────────── + +export type SlashSuggestionCallbacks = { + getCommands: () => SlashCommand[]; + onAgentCommand: (commandName: string) => void; +}; + +export const SlashSuggestionPluginKey = new PluginKey('slashSuggestion'); + +export function createSlashSuggestion( + callbacks: SlashSuggestionCallbacks, + setMenuState: (state: MenuState) => void, + onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>, +): Partial> { + return { + char: '/', + pluginKey: SlashSuggestionPluginKey, + allowSpaces: false, + + items: ({ query }) => { + const allCommands = callbacks.getCommands(); + if (!query) return allCommands; + + const lq = query.toLowerCase(); + return allCommands + .filter((cmd) => { + const name = cmd.agentCommandName?.toLowerCase(); + return name?.startsWith(lq) || cmd.label.toLowerCase().includes(lq); + }) + .sort((a, b) => { + const an = a.agentCommandName?.toLowerCase(); + const bn = b.agentCommandName?.toLowerCase(); + const aPre = an?.startsWith(lq) ?? false; + const bPre = bn?.startsWith(lq) ?? false; + if (aPre && !bPre) return -1; + if (!aPre && bPre) return 1; + return 0; + }); + }, + + command: ({ editor, range, props: cmd }) => { + editor.chain().focus().deleteRange(range).run(); + if (cmd.agentCommandName) { + callbacks.onAgentCommand(cmd.agentCommandName); + } + }, + + render: () => { + return { + onStart(props: SuggestionProps) { + if (props.items.length === 0) return; + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (cmd: SlashCommand) => props.command(cmd), + }); + }, + + onUpdate(props: SuggestionProps) { + if (props.items.length === 0) { + setMenuState(EMPTY_SLASH_STATE); + return; + } + setMenuState({ + isOpen: true, + items: props.items, + query: props.query, + clientRect: props.clientRect ?? null, + command: (cmd: SlashCommand) => props.command(cmd), + }); + }, + + onKeyDown(kd: SuggestionKeyDownProps) { + if (kd.event.key === 'Escape') { + setMenuState(EMPTY_SLASH_STATE); + return true; + } + return onKeyDownRef.current?.(kd.event) ?? false; + }, + + onExit() { + setMenuState(EMPTY_SLASH_STATE); + }, + }; + }, + }; +} diff --git a/apps/web/components/task/chat/virtualized-message-list.tsx b/apps/web/components/task/chat/virtualized-message-list.tsx index c5953d3231..1512ee5cf7 100644 --- a/apps/web/components/task/chat/virtualized-message-list.tsx +++ b/apps/web/components/task/chat/virtualized-message-list.tsx @@ -40,7 +40,11 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ }: VirtualizedMessageListProps) { const messagesContainerRef = useRef(null); const wasAtBottomRef = useRef(true); + const savedScrollTopRef = useRef(0); const previousHeightRef = useRef(0); + const previousWidthRef = useRef(0); + /** True while the panel is hidden (0 dimensions) to avoid stale scroll updates */ + const isHiddenRef = useRef(false); const isInitialLoading = messagesLoading && messages.length === 0; const isCreatedSession = sessionState === 'CREATED'; @@ -79,11 +83,15 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ const element = messagesContainerRef.current; if (!element) return; + // Ignore scroll events while panel is hidden (browser resets scrollTop to 0) + if (isHiddenRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = element; // Track if we're at the bottom for auto-scroll behavior (100px threshold) // User is considered "at bottom" if within 100px of the bottom wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; + savedScrollTopRef.current = scrollTop; // Trigger lazy load when scrolled near top if (scrollTop < 40 && loadingRef.current.hasMore && !loadingRef.current.isLoadingMore) { @@ -106,6 +114,10 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ return () => element.removeEventListener('scroll', handleScroll); }, [handleScroll]); + // Keep a ref so the ResizeObserver closure always has the current virtualizer + const virtualizerRef = useRef(virtualizer); + virtualizerRef.current = virtualizer; + // Maintain scroll position at bottom when container resizes (e.g., chat input grows) useEffect(() => { const element = messagesContainerRef.current; @@ -114,7 +126,45 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { const currentHeight = entry.contentRect.height; + const currentWidth = entry.contentRect.width; const previousHeight = previousHeightRef.current; + const previousWidth = previousWidthRef.current; + + // Panel being hidden by dockview — mark as hidden, don't update refs + if (currentWidth === 0 || currentHeight === 0) { + isHiddenRef.current = true; + return; + } + + // Panel becoming visible again — restore scroll position + if (isHiddenRef.current) { + isHiddenRef.current = false; + previousHeightRef.current = currentHeight; + previousWidthRef.current = currentWidth; + + // Invalidate stale measurements from when the panel was display:none, + // then use the virtualizer's scrollToIndex which persists across + // re-measurement cycles (raw scrollTop gets overwritten by the + // virtualizer as it re-measures items after becoming visible). + const virt = virtualizerRef.current; + virt.measure(); + requestAnimationFrame(() => { + if (wasAtBottomRef.current) { + const count = virt.options.count; + if (count > 0) { + virt.scrollToIndex(count - 1, { align: 'end' }); + } + } else { + element.scrollTop = savedScrollTopRef.current; + } + }); + return; + } + + // If width genuinely changed, invalidate cached row measurements (text rewraps) + if (previousWidth > 0 && currentWidth !== previousWidth) { + virtualizerRef.current.measure(); + } // If container got smaller (input grew) and we were at bottom, scroll to bottom if (previousHeight > 0 && currentHeight < previousHeight && wasAtBottomRef.current) { @@ -126,6 +176,7 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({ } previousHeightRef.current = currentHeight; + previousWidthRef.current = currentWidth; } }); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index e0e47b548d..e439860930 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -686,6 +686,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ leftHeaderActionsComponent={LeftHeaderActions} rightHeaderActionsComponent={RightHeaderActions} onReady={onReady} + defaultRenderer="always" className="h-full" />
diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index 6762e48da8..73cb42b418 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -1,27 +1,59 @@ 'use client'; -import { memo } from 'react'; +import { memo, useEffect, useRef } from 'react'; import type { IDockviewPanelProps } from 'dockview-react'; import { PanelRoot, PanelBody } from './panel-primitives'; import { FileEditorContent } from './file-editor-content'; import { FileImageViewer } from './file-image-viewer'; import { FileBinaryViewer } from './file-binary-viewer'; import { useAppStore } from '@/components/state-provider'; -import { useDockviewStore } from '@/lib/state/dockview-store'; +import { useDockviewStore, type FileEditorState } from '@/lib/state/dockview-store'; import { useFileEditors } from '@/hooks/use-file-editors'; import { getFileCategory } from '@/lib/utils/file-types'; +import { getWebSocketClient } from '@/lib/ws/connection'; +import { requestFileContent } from '@/lib/ws/workspace-files'; +import { calculateHash } from '@/lib/utils/file-diff'; export const FileEditorPanel = memo(function FileEditorPanel( props: IDockviewPanelProps<{ path: string }> ) { const path = props.params.path; const fileState = useDockviewStore((s) => s.openFiles.get(path)); + const setFileState = useDockviewStore((s) => s.setFileState); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSession = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId] ?? null : null ); const { savingFiles, handleFileChange, saveFile, deleteFile } = useFileEditors(); + // Self-load file content when state is missing (e.g. after hot reload clears the store) + const loadingRef = useRef(false); + useEffect(() => { + if (fileState || loadingRef.current || !activeSessionId) return; + loadingRef.current = true; + + const client = getWebSocketClient(); + if (!client) { loadingRef.current = false; return; } + + requestFileContent(client, activeSessionId, path) + .then(async (response) => { + const hash = await calculateHash(response.content); + const name = path.split('/').pop() || path; + const state: FileEditorState = { + path, + name, + content: response.content, + originalContent: response.content, + originalHash: hash, + isDirty: false, + isBinary: response.is_binary, + }; + setFileState(path, state); + }) + .catch(() => { /* will stay on loading state */ }) + .finally(() => { loadingRef.current = false; }); + }, [fileState, activeSessionId, path, setFileState]); + if (!fileState) { return ( diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 6c90dd46d3..e71c40291e 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -11,11 +11,13 @@ import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'; import { SHORTCUTS } from '@/lib/keyboard/constants'; import { useSessionMessages } from '@/hooks/domains/session/use-session-messages'; import { useSettingsData } from '@/hooks/domains/settings/use-settings-data'; +import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts'; import { useSessionState } from '@/hooks/domains/session/use-session-state'; import { useProcessedMessages } from '@/hooks/use-processed-messages'; import { useSessionModel } from '@/hooks/domains/session/use-session-model'; import { useMessageHandler } from '@/hooks/use-message-handler'; import { useQueue } from '@/hooks/domains/session/use-queue'; +import { useContextFilesStore } from '@/lib/state/context-files-store'; import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list'; import { ChatInputContainer, type ChatInputContainerHandle, type MessageAttachment } from '@/components/task/chat/chat-input-container'; import { type QueuedMessageIndicatorHandle } from '@/components/task/chat/queued-message-indicator'; @@ -24,10 +26,36 @@ import { useDiffCommentsStore, usePendingCommentsByFile, } from '@/lib/state/slices/diff-comments'; +import { getFileName } from '@/lib/utils/file-path'; +import type { ContextItem } from '@/lib/types/context'; import type { DiffComment } from '@/lib/diff/types'; import type { DocumentComment } from '@/lib/state/slices/ui/types'; const EMPTY_COMMENTS: DocumentComment[] = []; +const EMPTY_CONTEXT_FILES: import('@/lib/state/context-files-store').ContextFile[] = []; + +/** Sort: pinned first, then by kind order, then by label */ +const KIND_ORDER: Record = { + plan: 0, + file: 1, + prompt: 2, + comment: 3, + 'plan-comment': 4, + image: 5, +}; + +function contextItemSortFn(a: ContextItem, b: ContextItem): number { + // Pinned first + const aPinned = a.pinned ? 0 : 1; + const bPinned = b.pinned ? 0 : 1; + if (aPinned !== bPinned) return aPinned - bPinned; + // Then by kind + const aKind = KIND_ORDER[a.kind] ?? 99; + const bKind = KIND_ORDER[b.kind] ?? 99; + if (aKind !== bKind) return aKind - bKind; + // Then by label + return a.label.localeCompare(b.label); +} type TaskChatPanelProps = { onSend?: (message: string) => void; @@ -55,9 +83,12 @@ export const TaskChatPanel = memo(function TaskChatPanel({ const chatInputRef = useRef(null); const queuedMessageRef = useRef(null); - // Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths) + // Ensure agent profile data is loaded useSettingsData(true); + // Custom prompts for context + const { prompts } = useCustomPrompts(); + // Session state management const { resolvedSessionId, @@ -71,7 +102,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ isFailed, } = useSessionState(sessionId); - // Plan mode state - derived from active document being a plan in document panel + // Plan mode state const activeDocument = useAppStore((state) => resolvedSessionId ? state.documentPanel.activeDocumentBySessionId[resolvedSessionId] ?? null : null ); @@ -88,6 +119,21 @@ export const TaskChatPanel = memo(function TaskChatPanel({ const { addPlan } = usePanelActions(); + // Context files store + const contextFiles = useContextFilesStore((s) => + resolvedSessionId ? s.filesBySessionId[resolvedSessionId] ?? EMPTY_CONTEXT_FILES : EMPTY_CONTEXT_FILES + ); + const hydrateContextFiles = useContextFilesStore((s) => s.hydrateSession); + const addContextFile = useContextFilesStore((s) => s.addFile); + const toggleContextFile = useContextFilesStore((s) => s.toggleFile); + const removeContextFile = useContextFilesStore((s) => s.removeFile); + const unpinFile = useContextFilesStore((s) => s.unpinFile); + const clearEphemeral = useContextFilesStore((s) => s.clearEphemeral); + + useEffect(() => { + if (resolvedSessionId) hydrateContextFiles(resolvedSessionId); + }, [resolvedSessionId, hydrateContextFiles]); + const handlePlanModeChange = useCallback( (enabled: boolean) => { if (!resolvedSessionId || !taskId) return; @@ -95,29 +141,45 @@ export const TaskChatPanel = memo(function TaskChatPanel({ setActiveDocument(resolvedSessionId, { type: 'plan', taskId }); addPlan(); setPlanMode(resolvedSessionId, true); + addContextFile(resolvedSessionId, { path: 'plan:context', name: 'Plan', pinned: true }); } else { closeDocument(resolvedSessionId); setActiveDocument(resolvedSessionId, null); setPlanMode(resolvedSessionId, false); } }, - [resolvedSessionId, taskId, setActiveDocument, addPlan, closeDocument, setPlanMode] + [resolvedSessionId, taskId, setActiveDocument, addPlan, closeDocument, setPlanMode, addContextFile] ); - // Initialize plan mode from localStorage on mount (restore document panel state) + // Initialize plan mode from localStorage on mount useEffect(() => { if (resolvedSessionId) { const stored = getLocalStorage(`plan-mode-${resolvedSessionId}`, false); if (stored) { - setPlanMode(resolvedSessionId, stored); + setPlanMode(resolvedSessionId, true); + addContextFile(resolvedSessionId, { path: 'plan:context', name: 'Plan', pinned: true }); } } - }, [resolvedSessionId, setPlanMode]); + }, [resolvedSessionId, setPlanMode, addContextFile]); + + const handleToggleContextFile = useCallback( + (file: { path: string; name: string; pinned?: boolean }) => { + if (resolvedSessionId) toggleContextFile(resolvedSessionId, file); + }, + [resolvedSessionId, toggleContextFile] + ); + + const handleAddContextFile = useCallback( + (file: { path: string; name: string; pinned?: boolean }) => { + if (resolvedSessionId) addContextFile(resolvedSessionId, file); + }, + [resolvedSessionId, addContextFile] + ); // Fetch messages for this session const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId); - // Process messages (filtering, todos, etc.) + // Process messages const { allMessages, groupedItems, permissionsByToolCallId, childrenByParentToolCallId, todoItems, agentMessageCount, pendingClarification } = useProcessedMessages( messages, taskId, @@ -125,18 +187,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({ taskDescription ); - // Extract user message history for up/down arrow navigation - const userMessageHistory = useMemo(() => { - return allMessages - .filter(msg => msg.author_type === 'user') - .map(msg => msg.content) - .filter(content => content && content.trim().length > 0); - }, [allMessages]); - - // Track clarification resolved state to trigger re-render after submit + // Clarification state const [clarificationKey, setClarificationKey] = useState(0); const handleClarificationResolved = useCallback(() => { - // Force re-render which will update messages from backend setClarificationKey((k) => k + 1); }, []); @@ -168,24 +221,26 @@ export const TaskChatPanel = memo(function TaskChatPanel({ } }, [resolvedSessionId, setDocumentComments]); - // Message sending - const { handleSendMessage } = useMessageHandler( - resolvedSessionId, - taskId, - sessionModel, - activeModel, - planModeEnabled, - isAgentBusy, - activeDocument, - documentComments + // Derive plan context from context files store + const planContextEnabled = useMemo( + () => contextFiles.some((f) => f.path === 'plan:context'), + [contextFiles] ); + // Build prompts map for content lookup + const promptsMap = useMemo(() => { + const map = new Map(); + for (const p of prompts) { + map.set(p.id, { content: p.content }); + } + return map; + }, [prompts]); + // Diff comments management const pendingCommentsByFile = usePendingCommentsByFile(); const markCommentsSent = useDiffCommentsStore((state) => state.markCommentsSent); const removeComment = useDiffCommentsStore((state) => state.removeComment); - // Handle removing all comments for a file const handleRemoveCommentFile = useCallback( (filePath: string) => { if (!resolvedSessionId) return; @@ -197,21 +252,131 @@ export const TaskChatPanel = memo(function TaskChatPanel({ [resolvedSessionId, pendingCommentsByFile, removeComment] ); - // Handle removing a single comment const handleRemoveComment = useCallback( - (sessionId: string, filePath: string, commentId: string) => { - removeComment(sessionId, filePath, commentId); + (sid: string, filePath: string, commentId: string) => { + removeComment(sid, filePath, commentId); }, [removeComment] ); + // Build unified context items + const contextItems = useMemo((): ContextItem[] => { + const items: ContextItem[] = []; + const sid = resolvedSessionId; + + // Plan + if (planContextEnabled) { + const planFile = contextFiles.find(f => f.path === 'plan:context'); + items.push({ + kind: 'plan', + id: 'plan:context', + label: 'Plan', + taskId: taskId ?? undefined, + pinned: planFile?.pinned, + onRemove: sid ? () => removeContextFile(sid, 'plan:context') : undefined, + onUnpin: planFile?.pinned && sid ? () => unpinFile(sid, 'plan:context') : undefined, + onOpen: addPlan, + }); + } + + // Files & Prompts from context store + for (const f of contextFiles) { + if (f.path === 'plan:context') continue; + if (f.path.startsWith('prompt:')) { + const promptId = f.path.replace('prompt:', ''); + const prompt = promptsMap.get(promptId); + items.push({ + kind: 'prompt', + id: f.path, + label: f.name, + pinned: f.pinned, + onRemove: sid ? () => removeContextFile(sid, f.path) : undefined, + onUnpin: f.pinned && sid ? () => unpinFile(sid, f.path) : undefined, + promptContent: prompt?.content, + onClick: () => {/* navigate to settings/prompts if desired */}, + }); + } else { + items.push({ + kind: 'file', + id: f.path, + label: f.name, + path: f.path, + pinned: f.pinned, + onRemove: sid ? () => removeContextFile(sid, f.path) : undefined, + onUnpin: f.pinned && sid ? () => unpinFile(sid, f.path) : undefined, + onOpen: onOpenFile ?? (() => {}), + }); + } + } + + // Diff/editor comments (per file) + if (pendingCommentsByFile) { + for (const [filePath, comments] of Object.entries(pendingCommentsByFile)) { + if (comments.length === 0) continue; + const fileName = getFileName(filePath); + items.push({ + kind: 'comment', + id: `comment:${filePath}`, + label: `${fileName} (${comments.length})`, + filePath, + comments, + onRemove: () => handleRemoveCommentFile(filePath), + onRemoveComment: (cid) => { if (sid) handleRemoveComment(sid, filePath, cid); }, + onOpen: onOpenFileAtLine ? () => onOpenFileAtLine(filePath) : undefined, + }); + } + } + + // Plan comments + if (documentComments.length > 0) { + items.push({ + kind: 'plan-comment', + id: 'plan-comments', + label: `${documentComments.length} plan comment${documentComments.length !== 1 ? 's' : ''}`, + comments: documentComments, + onRemove: handleClearDocumentComments, + onOpen: addPlan, + }); + } + + return items.sort(contextItemSortFn); + }, [ + planContextEnabled, + contextFiles, + resolvedSessionId, + removeContextFile, + unpinFile, + addPlan, + promptsMap, + onOpenFile, + pendingCommentsByFile, + handleRemoveCommentFile, + handleRemoveComment, + onOpenFileAtLine, + documentComments, + handleClearDocumentComments, + taskId, + ]); + + // Message sending + const { handleSendMessage } = useMessageHandler( + resolvedSessionId, + taskId, + sessionModel, + activeModel, + planContextEnabled, + isAgentBusy, + activeDocument, + documentComments, + contextFiles, + prompts + ); + // Clear awaiting state when a new agent message arrives useEffect(() => { lastAgentMessageCountRef.current = agentMessageCount; }, [agentMessageCount]); - // Cancels the current agent turn without terminating the agent process, - // allowing the user to interrupt and send a new prompt. const handleCancelTurn = useCallback(async () => { if (!resolvedSessionId) return; const client = getWebSocketClient(); @@ -224,7 +389,6 @@ export const TaskChatPanel = memo(function TaskChatPanel({ } }, [resolvedSessionId]); - // Handle canceling queued message const handleCancelQueue = useCallback(async () => { try { await cancelQueue(); @@ -233,21 +397,14 @@ export const TaskChatPanel = memo(function TaskChatPanel({ } }, [cancelQueue]); - // Handle starting edit mode on queued message (from keyboard navigation) - const handleStartQueueEdit = useCallback(() => { - queuedMessageRef.current?.startEdit(); - }, []); - - // Handle edit complete - return focus to chat input const handleQueueEditComplete = useCallback(() => { chatInputRef.current?.focusInput(); }, []); - const handleSubmit = useCallback(async (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[]) => { + const handleSubmit = useCallback(async (message: string, reviewComments?: DiffComment[], attachments?: MessageAttachment[], inlineMentions?: import('@/lib/state/context-files-store').ContextFile[]) => { if (isSending) return; setIsSending(true); try { - // Build final message with review comments let finalMessage = message; if (reviewComments && reviewComments.length > 0) { const reviewMarkdown = formatReviewCommentsAsMarkdown(reviewComments); @@ -259,42 +416,43 @@ export const TaskChatPanel = memo(function TaskChatPanel({ if (onSend) { await onSend(finalMessage); } else { - await handleSendMessage(finalMessage, attachments, hasReviewComments); + await handleSendMessage(finalMessage, attachments, hasReviewComments, inlineMentions); } - // Mark comments as sent and clear pending + // Mark comments as sent if (reviewComments && reviewComments.length > 0) { markCommentsSent(reviewComments.map((c) => c.id)); } - // Clear plan/document comments after sending (they were included via useMessageHandler) + // Clear plan/document comments after sending if (documentComments.length > 0) { handleClearDocumentComments(); } + + // Clear ephemeral context items after send + if (resolvedSessionId) { + clearEphemeral(resolvedSessionId); + } } finally { setIsSending(false); } - }, [isSending, onSend, handleSendMessage, markCommentsSent, documentComments.length, handleClearDocumentComments]); + }, [isSending, onSend, handleSendMessage, markCommentsSent, documentComments.length, handleClearDocumentComments, resolvedSessionId, clearEphemeral]); - // Focus input with / shortcut (only when input is NOT focused) + // Focus input with / shortcut useKeyboardShortcut( SHORTCUTS.FOCUS_INPUT, useCallback((event: KeyboardEvent) => { - // Only handle if we're not already in an input/textarea const activeElement = document.activeElement; const isTyping = activeElement instanceof HTMLInputElement || - activeElement instanceof HTMLTextAreaElement; + activeElement instanceof HTMLTextAreaElement || + (activeElement instanceof HTMLElement && activeElement.isContentEditable); - if (isTyping) { - // Don't intercept - let the "/" be typed normally for slash commands - return; - } + if (isTyping) return; - // Not typing anywhere, so focus the chat input const inputHandle = chatInputRef.current; if (inputHandle) { - event.preventDefault(); // Prevent the "/" from being typed when we focus + event.preventDefault(); inputHandle.focusInput(); } }, []), @@ -350,19 +508,16 @@ export const TaskChatPanel = memo(function TaskChatPanel({ showRequestChangesTooltip={showRequestChangesTooltip} onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss} pendingCommentsByFile={pendingCommentsByFile} - onRemoveCommentFile={handleRemoveCommentFile} - onRemoveComment={handleRemoveComment} - onCommentClick={onOpenFileAtLine ? (comment) => onOpenFileAtLine(comment.filePath) : undefined} submitKey={chatSubmitKey} hasAgentCommands={hasAgentCommands} isFailed={isFailed} isQueued={isQueued} - onStartQueueEdit={handleStartQueueEdit} - userMessageHistory={userMessageHistory} - documentCommentCount={documentComments.length} - onClearDocumentComments={handleClearDocumentComments} + contextItems={contextItems} + planContextEnabled={planContextEnabled} + contextFiles={contextFiles} + onToggleContextFile={handleToggleContextFile} + onAddContextFile={handleAddContextFile} todoItems={todoItems} - activeDocument={activeDocument} queuedMessage={queuedMessage?.content} onCancelQueue={handleCancelQueue} updateQueueContent={updateQueueContent} diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index c233750744..7b05a636df 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -4,14 +4,16 @@ import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts'; import { getWebSocketClient } from '@/lib/ws/connection'; import { searchWorkspaceFiles } from '@/lib/ws/workspace-files'; +import { getFileName } from '@/lib/utils/file-path'; import type { RichTextInputHandle } from '@/components/task/chat/rich-text-input'; export type MentionItem = { id: string; - type: 'prompt' | 'file'; + kind: 'prompt' | 'file' | 'plan'; label: string; description?: string; - content?: string; // For prompts, the full content to insert + /** What happens on selection. Each kind provides its own. */ + onSelect: (input: RichTextInputHandle, value: string, triggerStart: number, onChange: (v: string) => void) => void; }; type Position = { @@ -60,11 +62,85 @@ function filterItems(items: MentionItem[], query: string): MentionItem[] { .map(({ item }) => item); } +/** Build a file mention item that adds file to context store instead of inserting text. */ +function makeFileItem( + filePath: string, + onFileSelect?: (path: string, name: string) => void, +): MentionItem { + return { + id: filePath, + kind: 'file', + label: filePath, + description: 'File', + onSelect: (input, value, triggerStart, onChange) => { + // Remove the @trigger text + const cursorPos = input.getSelectionStart(); + onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); + + // Add to context store + const name = getFileName(filePath); + onFileSelect?.(filePath, name); + + requestAnimationFrame(() => { + input.setSelectionRange(triggerStart, triggerStart); + input.focus(); + }); + }, + }; +} + +function makePlanItem(onPlanSelect: () => void): MentionItem { + return { + id: '__plan__', + kind: 'plan', + label: 'Plan', + description: 'Include the plan as context', + onSelect: (input, value, triggerStart, onChange) => { + const cursorPos = input.getSelectionStart(); + onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); + onPlanSelect(); + requestAnimationFrame(() => { + input.setSelectionRange(triggerStart, triggerStart); + input.focus(); + }); + }, + }; +} + +/** Build a prompt mention item that adds prompt to context store instead of inserting content. */ +function makePromptItem( + prompt: { id: string; name: string; content: string }, + onPromptSelect?: (id: string, name: string) => void, +): MentionItem { + return { + id: prompt.id, + kind: 'prompt', + label: prompt.name, + description: prompt.content.length > 100 ? prompt.content.slice(0, 100) + '...' : prompt.content, + onSelect: (input, value, triggerStart, onChange) => { + // Remove the @trigger text + const cursorPos = input.getSelectionStart(); + onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); + + // Add to context store + onPromptSelect?.(prompt.id, prompt.name); + + requestAnimationFrame(() => { + input.setSelectionRange(triggerStart, triggerStart); + input.focus(); + }); + }, + }; +} + export function useInlineMention( inputRef: React.RefObject, value: string, onChange: (value: string) => void, - sessionId?: string | null + sessionId?: string | null, + onPlanSelect?: () => void, + onFileSelect?: (path: string, name: string) => void, + onPromptSelect?: (id: string, name: string) => void, ) { const [isOpen, setIsOpen] = useState(false); const [position, setPosition] = useState(null); @@ -146,35 +222,31 @@ export function useInlineMention( }, [isOpen, query, searchFiles]); /* eslint-enable react-hooks/set-state-in-effect */ + // Build plan item + const planItem = useMemo((): MentionItem | null => { + if (!onPlanSelect) return null; + return makePlanItem(onPlanSelect); + }, [onPlanSelect]); + // Build prompt items const promptItems = useMemo((): MentionItem[] => { - return prompts.map((prompt) => ({ - id: prompt.id, - type: 'prompt' as const, - label: prompt.name, - description: prompt.content.length > 100 ? prompt.content.slice(0, 100) + '...' : prompt.content, - content: prompt.content, - })); - }, [prompts]); + return prompts.map((prompt) => makePromptItem(prompt, onPromptSelect)); + }, [prompts, onPromptSelect]); // Build file items from search results const fileItems = useMemo((): MentionItem[] => { - return fileResults.map((filePath) => ({ - id: filePath, - type: 'file' as const, - label: filePath, - description: 'File', - })); - }, [fileResults]); + return fileResults.map((filePath) => makeFileItem(filePath, onFileSelect)); + }, [fileResults, onFileSelect]); // Combine and filter items const filteredItems = useMemo(() => { - const filtered = filterItems(promptItems, query); - const filteredFiles = filterItems(fileItems, query); - - // Prompts first, then files - return [...filtered, ...filteredFiles]; - }, [promptItems, fileItems, query]); + // Plan first, then prompts, then files + const allItems: MentionItem[] = []; + if (planItem) allItems.push(planItem); + allItems.push(...promptItems); + allItems.push(...fileItems); + return filterItems(allItems, query); + }, [planItem, promptItems, fileItems, query]); // Reset selected index when items change /* eslint-disable react-hooks/set-state-in-effect -- resetting selection on items change is intentional */ @@ -246,34 +318,10 @@ export function useInlineMention( const input = inputRef.current; if (!input || triggerStart < 0) return; - let insertText: string; - let cursorOffset: number; - - if (item.type === 'prompt' && item.content) { - // For prompts, replace @mention with full prompt content + newline - insertText = item.content + '\n'; - cursorOffset = insertText.length; - } else { - // For files, replace @mention with @filename - insertText = `@${item.label} `; - cursorOffset = insertText.length; - } - - // Get current cursor position to determine end of replacement - const cursorPos = input.getSelectionStart(); - const newValue = value.substring(0, triggerStart) + insertText + value.substring(cursorPos); - - onChange(newValue); + item.onSelect(input, value, triggerStart, onChange); setIsOpen(false); setTriggerStart(-1); setQuery(''); - - // Restore cursor position - requestAnimationFrame(() => { - const newCursorPos = triggerStart + cursorOffset; - input.setSelectionRange(newCursorPos, newCursorPos); - input.focus(); - }); }, [inputRef, triggerStart, value, onChange] ); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index 6e90cc736e..2010ec3c4e 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -3,6 +3,8 @@ import { getWebSocketClient } from '@/lib/ws/connection'; import { useQueue } from './domains/session/use-queue'; import type { MessageAttachment } from '@/components/task/chat/chat-input-container'; import type { ActiveDocument, DocumentComment } from '@/lib/state/slices/ui/types'; +import type { ContextFile } from '@/lib/state/context-files-store'; +import type { CustomPrompt } from '@/lib/types/http'; function buildDocumentContext(activeDocument: ActiveDocument | null, comments?: DocumentComment[]): string { if (!activeDocument) return ''; @@ -24,6 +26,35 @@ function buildDocumentContext(activeDocument: ActiveDocument | null, comments?: return `\n\n\nACTIVE DOCUMENT: The user is editing "${activeDocument.name}" (${activeDocument.path}) side-by-side with this chat.\nRead this file to understand the context before responding.\n`; } +function buildContextFilesContext(contextFiles: ContextFile[], prompts: CustomPrompt[]): string { + const files = contextFiles.filter((f) => !f.path.startsWith('prompt:') && f.path !== 'plan:context'); + const promptFiles = contextFiles.filter((f) => f.path.startsWith('prompt:')); + + let context = ''; + + if (files.length > 0) { + const fileList = files.map((f) => `- ${f.path}`).join('\n'); + context += `\n\n\nCONTEXT FILES: The user has attached the following files as context. Read these files to understand what the user is referring to:\n${fileList}\n`; + } + + if (promptFiles.length > 0) { + const promptsById = new Map(prompts.map((p) => [p.id, p])); + const resolved = promptFiles + .map((f) => { + const id = f.path.replace('prompt:', ''); + const prompt = promptsById.get(id); + return prompt ? `### ${prompt.name}\n${prompt.content}` : null; + }) + .filter(Boolean); + + if (resolved.length > 0) { + context += `\n\n\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join('\n\n')}\n`; + } + } + + return context; +} + export function useMessageHandler( resolvedSessionId: string | null, taskId: string | null, @@ -32,12 +63,14 @@ export function useMessageHandler( planMode: boolean = false, isAgentBusy: boolean = false, activeDocument: ActiveDocument | null = null, - documentComments: DocumentComment[] = [] + documentComments: DocumentComment[] = [], + contextFiles: ContextFile[] = [], + prompts: CustomPrompt[] = [] ) { const { queue } = useQueue(resolvedSessionId); const handleSendMessage = useCallback( - async (message: string, attachments?: MessageAttachment[], hasReviewComments?: boolean) => { + async (message: string, attachments?: MessageAttachment[], hasReviewComments?: boolean, inlineMentions?: ContextFile[]) => { if (!taskId || !resolvedSessionId) { console.error('No active task session. Start an agent before sending a message.'); return; @@ -47,13 +80,23 @@ export function useMessageHandler( const trimmedMessage = message.trim(); + // Merge stored context files with inline @mentions from the editor + const allContextFiles = [...contextFiles, ...(inlineMentions || [])]; + // Append document context if active const documentContext = buildDocumentContext(activeDocument, documentComments); - const finalMessage = documentContext ? trimmedMessage + documentContext : trimmedMessage; + const contextFilesContext = buildContextFilesContext(allContextFiles, prompts); + const finalMessage = trimmedMessage + documentContext + contextFilesContext; // Include active model in the request if it differs from the session model const modelToSend = activeModel && activeModel !== sessionModel ? activeModel : undefined; + // Build context_files metadata for the WS payload (exclude prompts and plan:context) + const realFiles = allContextFiles.filter((f) => !f.path.startsWith('prompt:') && f.path !== 'plan:context'); + const contextFilesMeta = realFiles.length > 0 + ? realFiles.map((f) => ({ path: f.path, name: f.name })) + : undefined; + // Use longer timeout when sending attachments (images can be large) const hasAttachments = attachments && attachments.length > 0; const timeoutMs = hasAttachments ? 30000 : 10000; @@ -82,11 +125,12 @@ export function useMessageHandler( ...(planMode && { plan_mode: true }), ...(hasReviewComments && { has_review_comments: true }), ...(hasAttachments && { attachments }), + ...(contextFilesMeta && { context_files: contextFilesMeta }), }, timeoutMs ); }, - [resolvedSessionId, taskId, activeModel, sessionModel, planMode, isAgentBusy, queue, activeDocument, documentComments] + [resolvedSessionId, taskId, activeModel, sessionModel, planMode, isAgentBusy, queue, activeDocument, documentComments, contextFiles, prompts] ); return { handleSendMessage }; diff --git a/apps/web/hooks/use-resizable-input.ts b/apps/web/hooks/use-resizable-input.ts index 5c285dd1b8..76dfcb12cc 100644 --- a/apps/web/hooks/use-resizable-input.ts +++ b/apps/web/hooks/use-resizable-input.ts @@ -1,30 +1,49 @@ 'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { getChatInputHeight, setChatInputHeight } from '@/lib/local-storage'; const MIN_HEIGHT = 100; const DEFAULT_HEIGHT = 134; -const MAX_ABSOLUTE = 800; +const MAX_ABSOLUTE = 450; -export function useResizableInput() { - const [height, setHeight] = useState(DEFAULT_HEIGHT); +export function useResizableInput(sessionId: string | undefined) { + const [height, setHeight] = useState(() => { + const maxHeight = typeof window !== 'undefined' + ? Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE) + : MAX_ABSOLUTE; + if (sessionId) { + const saved = getChatInputHeight(sessionId); + return saved ? Math.min(saved, maxHeight) : DEFAULT_HEIGHT; + } + return DEFAULT_HEIGHT; + }); const containerRef = useRef(null); const isDragging = useRef(false); const startY = useRef(0); const startHeight = useRef(0); - - const getMaxHeight = useCallback(() => { - // Walk up to find the nearest panel-level ancestor (the full-height flex container) - let el = containerRef.current?.parentElement; - while (el) { - if (el.clientHeight > MAX_ABSOLUTE * 0.5) { - return Math.min(el.clientHeight * 0.8, MAX_ABSOLUTE); - } - el = el.parentElement; + // Restore height when session changes + const prevSessionRef = useRef(sessionId); + useEffect(() => { + if (sessionId === prevSessionRef.current) return; + prevSessionRef.current = sessionId; + /* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */ + if (sessionId) { + const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); + const saved = getChatInputHeight(sessionId); + setHeight(saved ? Math.min(saved, maxHeight) : DEFAULT_HEIGHT); + } else { + setHeight(DEFAULT_HEIGHT); } - // Fallback: use viewport height - return Math.min(window.innerHeight * 0.7, MAX_ABSOLUTE); - }, []); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [sessionId]); + + // Persist height after drag ends + const persistHeight = useCallback((h: number) => { + if (sessionId) { + setChatInputHeight(sessionId, h); + } + }, [sessionId]); const handleMouseDown = useCallback( (e: React.MouseEvent) => { @@ -38,16 +57,21 @@ export function useResizableInput() { [height] ); - const handleDoubleClick = useCallback(() => { + const resetHeight = useCallback(() => { setHeight(DEFAULT_HEIGHT); - }, []); + persistHeight(DEFAULT_HEIGHT); + }, [persistHeight]); + + const handleDoubleClick = useCallback(() => { + resetHeight(); + }, [resetHeight]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging.current) return; // Dragging UP (clientY decreases) increases height const delta = startY.current - e.clientY; - const maxHeight = getMaxHeight(); + const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); const newHeight = Math.min(Math.max(startHeight.current + delta, MIN_HEIGHT), maxHeight); setHeight(newHeight); }; @@ -57,6 +81,11 @@ export function useResizableInput() { isDragging.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; + // Persist the final height after drag + setHeight((h) => { + persistHeight(h); + return h; + }); }; document.addEventListener('mousemove', handleMouseMove); @@ -65,10 +94,11 @@ export function useResizableInput() { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - }, [getMaxHeight]); + }, [persistHeight]); return { height, + resetHeight, containerRef, resizeHandleProps: { onMouseDown: handleMouseDown, diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index ef02f9c549..e72d18516b 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -42,6 +42,15 @@ export function setLocalStorage(key: string, value: T): voi } } +export function removeSessionStorage(key: string): void { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.removeItem(key); + } catch { + // Ignore removal failures. + } +} + export function removeLocalStorage(key: string): void { if (typeof window === 'undefined') return; try { @@ -419,3 +428,103 @@ export function setActiveTabForSession(sessionId: string, tabId: string): void { // Ignore write failures } } + +// --- Chat draft persistence (sessionStorage, per task) --- + +const CHAT_DRAFT_TEXT_KEY = 'kandev.chatDraft.text'; +const CHAT_DRAFT_CONTENT_KEY = 'kandev.chatDraft.content'; +const CHAT_DRAFT_ATTACHMENTS_KEY = 'kandev.chatDraft.attachments'; +const CHAT_INPUT_HEIGHT_KEY = 'kandev.chatInput.height'; + +/** Stored attachment — same as ImageAttachment but without `preview` (reconstructed on load) */ +type StoredImageAttachment = { + id: string; + data: string; + mimeType: string; + size: number; + width: number; + height: number; +}; + +export function getChatDraftText(sessionId: string): string { + return getSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`, ''); +} + +export function setChatDraftText(sessionId: string, text: string): void { + if (text === '') { + removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); + } else { + setSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`, text); + } +} + +/** TipTap editor JSON — preserves rich content (mentions, code blocks, etc.) */ +export function getChatDraftContent(sessionId: string): unknown { + return getSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`, null); +} + +export function setChatDraftContent(sessionId: string, content: unknown): void { + if (!content) { + removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); + } else { + setSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`, content as JsonValue); + } +} + +export function getChatDraftAttachments(sessionId: string): StoredImageAttachment[] { + return getSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, []); +} + +export function setChatDraftAttachments(sessionId: string, attachments: Array<{ id: string; data: string; mimeType: string; size: number; width: number; height: number; preview?: string }>): void { + if (attachments.length === 0) { + removeSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`); + } else { + // Strip `preview` to halve storage cost — reconstructed on load + const stored: StoredImageAttachment[] = attachments.map(({ id, data, mimeType, size, width, height }) => ({ + id, data, mimeType, size, width, height, + })); + setSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`, stored); + } +} + +/** + * Reconstruct the `preview` data URL from stored attachment data. + */ +export function restoreAttachmentPreview(att: StoredImageAttachment): StoredImageAttachment & { preview: string } { + return { ...att, preview: `data:${att.mimeType};base64,${att.data}` }; +} + +export function getChatInputHeight(sessionId: string): number | null { + return getSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, null); +} + +export function setChatInputHeight(sessionId: string, height: number): void { + setSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`, height); +} + +// --- Task storage cleanup --- + +/** + * Remove all session-scoped storage for a deleted task. + * Call from task.deleted handler before the task is removed from state. + */ +export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void { + // Plan notification (localStorage, keyed per task inside a Record) + setPlanLastSeen(taskId, null); + + // Session-keyed storage — clean all sessions belonging to the task + for (const sessionId of sessionIds) { + removeSessionStorage(`${CHAT_DRAFT_TEXT_KEY}.${sessionId}`); + removeSessionStorage(`${CHAT_DRAFT_CONTENT_KEY}.${sessionId}`); + removeSessionStorage(`${CHAT_DRAFT_ATTACHMENTS_KEY}.${sessionId}`); + removeSessionStorage(`${CHAT_INPUT_HEIGHT_KEY}.${sessionId}`); + removeSessionStorage(`${FILES_PANEL_KEYS.TAB}.${sessionId}`); + removeSessionStorage(`${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`); + removeSessionStorage(`${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`); + removeSessionStorage(`${FILES_PANEL_KEYS.SCROLL}.${sessionId}`); + removeSessionStorage(`${DOCKVIEW_SESSION_LAYOUT_PREFIX}${sessionId}`); + removeSessionStorage(`${OPEN_FILES_KEY}.${sessionId}`); + removeSessionStorage(`${ACTIVE_TAB_KEY}.${sessionId}`); + removeSessionStorage(`kandev.contextFiles.${sessionId}`); + } +} diff --git a/apps/web/lib/state/context-files-store.ts b/apps/web/lib/state/context-files-store.ts new file mode 100644 index 0000000000..f97c11ce76 --- /dev/null +++ b/apps/web/lib/state/context-files-store.ts @@ -0,0 +1,110 @@ +import { create } from 'zustand'; +import { getSessionStorage, setSessionStorage } from '@/lib/local-storage'; + +export type ContextFile = { path: string; name: string; pinned?: boolean }; + +type ContextFilesStore = { + filesBySessionId: Record; + addFile: (sessionId: string, file: ContextFile) => void; + removeFile: (sessionId: string, path: string) => void; + toggleFile: (sessionId: string, file: ContextFile) => void; + /** Set pinned=false on an existing file. */ + unpinFile: (sessionId: string, path: string) => void; + /** Remove all files where pinned is not true. */ + clearEphemeral: (sessionId: string) => void; + /** Remove all files for a session (for cleanup). */ + clearSession: (sessionId: string) => void; + /** Load persisted files from sessionStorage into the store. Safe to call multiple times. */ + hydrateSession: (sessionId: string) => void; +}; + +const STORAGE_PREFIX = 'kandev.contextFiles.'; + +function persistFiles(sessionId: string, files: ContextFile[]) { + setSessionStorage(`${STORAGE_PREFIX}${sessionId}`, files.map(f => ({ path: f.path, name: f.name, pinned: f.pinned ?? false }))); +} + +function hydrateFiles(sessionId: string): ContextFile[] { + return getSessionStorage(`${STORAGE_PREFIX}${sessionId}`, []); +} + +export const useContextFilesStore = create((set, get) => ({ + filesBySessionId: {}, + + addFile: (sessionId, file) => { + set((state) => { + const existing = state.filesBySessionId[sessionId] ?? []; + const idx = existing.findIndex((f) => f.path === file.path); + if (idx >= 0) { + // Already exists — upgrade to pinned if requested, never downgrade + const current = existing[idx]; + if (file.pinned && !current.pinned) { + const updated = [...existing]; + updated[idx] = { ...current, pinned: true }; + persistFiles(sessionId, updated); + return { filesBySessionId: { ...state.filesBySessionId, [sessionId]: updated } }; + } + return state; + } + const updated = [...existing, file]; + persistFiles(sessionId, updated); + return { filesBySessionId: { ...state.filesBySessionId, [sessionId]: updated } }; + }); + }, + + removeFile: (sessionId, path) => { + set((state) => { + const existing = state.filesBySessionId[sessionId] ?? []; + const updated = existing.filter((f) => f.path !== path); + persistFiles(sessionId, updated); + return { filesBySessionId: { ...state.filesBySessionId, [sessionId]: updated } }; + }); + }, + + toggleFile: (sessionId, file) => { + const existing = get().filesBySessionId[sessionId] ?? []; + if (existing.some((f) => f.path === file.path)) { + get().removeFile(sessionId, file.path); + } else { + get().addFile(sessionId, file); + } + }, + + unpinFile: (sessionId, path) => { + set((state) => { + const existing = state.filesBySessionId[sessionId] ?? []; + const idx = existing.findIndex((f) => f.path === path); + if (idx < 0) return state; + const updated = [...existing]; + updated[idx] = { ...updated[idx], pinned: false }; + persistFiles(sessionId, updated); + return { filesBySessionId: { ...state.filesBySessionId, [sessionId]: updated } }; + }); + }, + + clearEphemeral: (sessionId) => { + set((state) => { + const existing = state.filesBySessionId[sessionId] ?? []; + const updated = existing.filter((f) => f.pinned === true); + persistFiles(sessionId, updated); + return { filesBySessionId: { ...state.filesBySessionId, [sessionId]: updated } }; + }); + }, + + clearSession: (sessionId) => { + set((state) => { + const { [sessionId]: _removed, ...rest } = state.filesBySessionId; + void _removed; + return { filesBySessionId: rest }; + }); + }, + + hydrateSession: (sessionId) => { + // Skip if already hydrated + if (get().filesBySessionId[sessionId]) return; + const hydrated = hydrateFiles(sessionId); + if (hydrated.length > 0) { + set((s) => ({ filesBySessionId: { ...s.filesBySessionId, [sessionId]: hydrated } })); + } + }, +})); diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index 220be1c310..d80f0cae88 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -150,7 +150,6 @@ export function applyLayoutFixups(api: DockviewApi): { const oldFiles = api.getPanel('all-files'); if (oldFiles) oldFiles.api.setTitle('Files'); - // Compute group IDs from restored panels (check both old and new IDs) const chatPanel = api.getPanel('chat'); const changesPanel = api.getPanel('changes') ?? oldChanges; const terminalPanel = api.panels.find( @@ -408,14 +407,25 @@ export const useDockviewStore = create((set, get) => ({ }, addPlanPanel: (groupId) => { - const { api, centerGroupId } = get(); + const { api } = get(); if (!api) return; - focusOrAddPanel(api, { - id: 'plan', - component: 'plan', - title: 'Plan', - position: { referenceGroup: groupId ?? centerGroupId }, - }); + if (groupId) { + // Header "+" menu: add as tab in the specified group + focusOrAddPanel(api, { + id: 'plan', + component: 'plan', + title: 'Plan', + position: { referenceGroup: groupId }, + }); + } else { + // Toolbar toggle: split right from the chat panel + focusOrAddPanel(api, { + id: 'plan', + component: 'plan', + title: 'Plan', + position: { referencePanel: 'chat', direction: 'right' }, + }); + } }, addTerminalPanel: (terminalId, groupId) => { diff --git a/apps/web/lib/types/context.ts b/apps/web/lib/types/context.ts new file mode 100644 index 0000000000..c710e63f67 --- /dev/null +++ b/apps/web/lib/types/context.ts @@ -0,0 +1,58 @@ +import type { DiffComment } from '@/lib/diff/types'; +import type { DocumentComment } from '@/lib/state/slices/ui/types'; +import type { ImageAttachment } from '@/components/task/chat/image-attachment-preview'; + +type ContextItemBase = { + id: string; + label: string; + pinned?: boolean; + onRemove?: () => void; + onUnpin?: () => void; +}; + +export type PlanContextItem = ContextItemBase & { + kind: 'plan'; + taskId?: string; + onOpen: () => void; +}; + +export type FileContextItem = ContextItemBase & { + kind: 'file'; + path: string; + onOpen: (path: string) => void; +}; + +export type PromptContextItem = ContextItemBase & { + kind: 'prompt'; + promptContent?: string; + onClick: () => void; +}; + +export type CommentContextItem = ContextItemBase & { + kind: 'comment'; + filePath: string; + comments: DiffComment[]; + onRemoveComment: (id: string) => void; + onOpen?: () => void; +}; + +export type PlanCommentContextItem = ContextItemBase & { + kind: 'plan-comment'; + comments: DocumentComment[]; + onOpen: () => void; +}; + +export type ImageContextItem = ContextItemBase & { + kind: 'image'; + attachment: ImageAttachment; +}; + +export type ContextItem = + | PlanContextItem + | FileContextItem + | PromptContextItem + | CommentContextItem + | PlanCommentContextItem + | ImageContextItem; + +export type ContextItemKind = ContextItem['kind']; diff --git a/apps/web/lib/utils/file-path.ts b/apps/web/lib/utils/file-path.ts new file mode 100644 index 0000000000..e3fbb25739 --- /dev/null +++ b/apps/web/lib/utils/file-path.ts @@ -0,0 +1,11 @@ +/** Check if a path looks like a directory (ends with / or has no extension). */ +export function isDirectory(filePath: string): boolean { + if (filePath.endsWith('/')) return true; + const name = filePath.split('/').pop() || ''; + return !name.includes('.'); +} + +/** Extract filename from a path. */ +export function getFileName(filePath: string): string { + return filePath.split('/').pop() || filePath; +} diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 8d7b367e71..f1af23ce14 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -2,6 +2,8 @@ import type { StoreApi } from 'zustand'; import type { AppState } from '@/lib/state/store'; import type { WsHandlers } from '@/lib/ws/handlers/types'; import type { KanbanState } from '@/lib/state/slices/kanban/types'; +import { cleanupTaskStorage } from '@/lib/local-storage'; +import { useContextFilesStore } from '@/lib/state/context-files-store'; type KanbanTask = KanbanState['tasks'][number]; @@ -65,14 +67,28 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { }); }, 'task.deleted': (message) => { + const deletedId = message.payload.task_id; + + // Clean up persisted storage before removing from state + const currentState = store.getState(); + const sessionIds = (currentState.taskSessionsByTask.itemsByTaskId[deletedId] ?? []).map((s) => s.id); + // Also include primarySessionId in case sessions weren't loaded into the store + const task = currentState.kanban.tasks.find((t) => t.id === deletedId); + if (task?.primarySessionId && !sessionIds.includes(task.primarySessionId)) { + sessionIds.push(task.primarySessionId); + } + cleanupTaskStorage(deletedId, sessionIds); + for (const sid of sessionIds) { + useContextFilesStore.getState().clearSession(sid); + } + store.setState((state) => { - const deletedId = message.payload.task_id; const isActive = state.tasks.activeTaskId === deletedId; return { ...state, kanban: { ...state.kanban, - tasks: state.kanban.tasks.filter((task) => task.id !== deletedId), + tasks: state.kanban.tasks.filter((t) => t.id !== deletedId), }, tasks: isActive ? { ...state.tasks, activeTaskId: null, activeSessionId: null } diff --git a/apps/web/package.json b/apps/web/package.json index f1969584d0..e207dbc701 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,19 @@ "@tabler/icons-react": "^3.36.1", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", + "@tiptap/core": "^3.19.0", + "@tiptap/extension-code": "^3.19.0", + "@tiptap/extension-code-block-lowlight": "^3.19.0", + "@tiptap/extension-document": "^3.19.0", + "@tiptap/extension-hard-break": "^3.19.0", + "@tiptap/extension-history": "^3.19.0", + "@tiptap/extension-mention": "^3.19.0", + "@tiptap/extension-paragraph": "^3.19.0", + "@tiptap/extension-placeholder": "^3.19.0", + "@tiptap/extension-text": "^3.19.0", + "@tiptap/pm": "^3.19.0", + "@tiptap/react": "^3.19.0", + "@tiptap/suggestion": "^3.19.0", "@uiw/codemirror-theme-github": "^4.25.4", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4", @@ -63,6 +76,7 @@ "embla-carousel-react": "^8.6.0", "immer": "^10.1.1", "input-otp": "^1.4.2", + "lowlight": "^3.3.0", "mermaid": "^11.12.2", "next": "16.1.1", "next-themes": "^0.4.6",