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 */}
-
-
- onPlanModeChange(!planModeEnabled)}
- >
-
-
-
- Toggle plan mode
-
-
-
-
- {/* Spacer */}
-
-
- {/* Right: Sessions, Model, Submit */}
-
-
-
- {/* Token usage display */}
-
-
-
-
- {/* Submit/Stop button */}
-
-
- {isAgentBusy ? (
-
-
-
- ) : (
-
- {isSending ? (
-
- ) : (
-
- )}
-
- )}
-
-
-
-
- );
-});
-
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 */}
+
+
+ onPlanModeChange(!planModeEnabled)}
+ >
+
+
+
+ Toggle plan mode
+
+
+ {/* Context @ button */}
+ {})}
+ trigger={
+
+
+ {contextCount > 0 && (
+
+ {contextCount}
+
+ )}
+
+ }
+ sessionId={sessionId}
+ planContextEnabled={planContextEnabled}
+ contextFiles={contextFiles}
+ onToggleFile={onToggleFile ?? (() => {})}
+ />
+
+
+ {/* Spacer */}
+
+
+ {/* Right: Sessions, Model, Submit */}
+
+
+
+ {/* Token usage display */}
+
+
+
+
+ {/* Submit/Stop button */}
+
+
+ {isAgentBusy ? (
+
+
+
+ ) : (
+
+ {isSending ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+ );
+});
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 */}
-
-
-
- {isOpen ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
- {fileName}
-
-
-
- {comments.length}
-
-
- {
- e.stopPropagation();
- onRemove();
- }}
- className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
- >
-
-
-
-
- {/* 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 && (
+
{
+ e.stopPropagation();
+ onUnpin();
+ }}
+ className="ml-0.5 text-muted-foreground/70 hover:text-foreground cursor-pointer"
+ title="Unpin (will be removed after send)"
+ >
+
+
+ )}
+ {onRemove && (
+
{
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="opacity-0 group-hover:opacity-100 ml-0.5 hover:text-foreground cursor-pointer"
+ >
+
+
+ )}
+
+ );
+
+ 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 */}
+
+
+ {item.attachment.width} × {item.attachment.height}
+
+
+ );
+
+ return (
+ <>
+
+
+
+ Image preview
+ {/* eslint-disable-next-line @next/next/no-img-element -- base64 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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ if (!plan?.content) {
+ return Plan is empty
;
+ }
+
+ const preview = plan.content.length > 2000 ? plan.content.slice(0, 2000) + '...' : plan.content;
+
+ return (
+
+ );
+}
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 && (
+
+ )}
+ {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 && (
+
+ )}
+
+ {/* 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 (
+ updateAttributes({ language: e.target.value })}
+ >
+ {CODE_LANGUAGES.map((lang) => (
+
+ {lang.label}
+
+ ))}
+
+ {/* @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",