feat: better context files (#222)

feat: context files
This commit is contained in:
Carlos Florêncio
2026-02-14 20:13:11 +00:00
committed by GitHub
parent ee612a42c5
commit f4d1dfd3a7
49 changed files with 3809 additions and 801 deletions
@@ -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
}
@@ -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")
}
}
@@ -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
@@ -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{
+6
View File
@@ -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"`
+388
View File
@@ -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:
+131
View File
@@ -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;
}
@@ -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);
@@ -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 (
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
{/* Left: Plan, Thinking, Context */}
<div className="flex items-center gap-0.5">
{/* Plan mode toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40',
planModeEnabled && 'bg-primary/15 text-primary'
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconFileTextSpark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle plan mode</TooltipContent>
</Tooltip>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: Sessions, Model, Submit */}
<div className="flex items-center gap-0.5 shrink-0">
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
{/* Token usage display */}
<TokenUsageDisplay sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
{/* Submit/Stop button */}
<div className="ml-1">
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
{isAgentBusy ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
onClick={onCancel}
>
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
</Button>
) : (
<Button
type="button"
variant="default"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer"
disabled={isDisabled}
onClick={onSubmit}
>
{isSending ? (
<GridSpinner className="text-primary-foreground" />
) : (
<IconArrowUp className="h-4 w-4" />
)}
</Button>
)}
</KeyboardShortcutTooltip>
</div>
</div>
</div>
);
});
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<string, DiffComment[]>;
/** 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<ChatInputContainerHandle, ChatInput
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
pendingCommentsByFile,
onRemoveCommentFile,
onRemoveComment,
onCommentClick,
submitKey = 'cmd_enter',
hasAgentCommands = false,
isFailed = false,
isQueued = false,
onStartQueueEdit,
userMessageHistory = [],
documentCommentCount = 0,
onClearDocumentComments,
contextItems = [],
planContextEnabled = false,
contextFiles = [],
onToggleContextFile,
onAddContextFile,
todoItems = [],
activeDocument,
queuedMessage,
onCancelQueue,
updateQueueContent,
@@ -279,18 +154,38 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
ref
) {
// Keep input state local to avoid re-rendering parent on every keystroke
const [value, setValue] = useState('');
const [value, setValue] = useState(() =>
sessionId ? getChatDraftText(sessionId) : ''
);
const [isInputFocused, setIsInputFocused] = useState(false);
const [attachments, setAttachments] = useState<ImageAttachment[]>([]);
const [attachments, setAttachments] = useState<ImageAttachment[]>(() =>
sessionId ? getChatDraftAttachments(sessionId).map(restoreAttachmentPreview) : []
);
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
const inputRef = useRef<RichTextInputHandle>(null);
const [contextPopoverOpen, setContextPopoverOpen] = useState(false);
const inputRef = useRef<TipTapInputHandle>(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<ChatInputContainerHandle, ChatInput
}
}, [showRequestChangesTooltip]);
// Use ref for value to keep handleSubmit stable (doesn't change on every keystroke)
// Use ref for value to keep handleSubmit stable
const valueRef = useRef(value);
useEffect(() => {
valueRef.current = value;
@@ -324,43 +219,24 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
const attachmentsRef = useRef(attachments);
useEffect(() => {
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<ChatInputContainerHandle, ChatInput
}
}, [attachments]);
// Attach paste listener to the container
useEffect(() => {
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<ChatInputContainerHandle, ChatInput
[onSubmit]
);
// Slash command menu hook
const slash = useInlineSlash(inputRef, value, setValue, {
sessionId,
onAgentCommand: handleAgentCommand,
});
// Use refs for menu state to keep handleSubmit stable
const menuStateRef = useRef({ mentionOpen: mention.isOpen, slashOpen: slash.isOpen });
useEffect(() => {
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<HTMLTextAreaElement>) => {
// 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<ChatInputContainerHandle, ChatInput
}
}
// Allow submission if there's text OR comments OR attachments
if (!trimmed && allComments.length === 0 && currentAttachments.length === 0) return;
// Convert attachments to MessageAttachment format for backend
const messageAttachments: MessageAttachment[] = currentAttachments.map(att => ({
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<ChatInputContainerHandle, ChatInput
? 'Ask to make changes, @mention files, run /commands'
: 'Ask to make changes, @mention files');
// Show focus hint when input not focused, no clarification, and no comments
const showFocusHint = !isInputFocused && !hasPendingClarification && !hasPendingComments;
// Determine if we have any context items to show
// Queue slot
const hasQueuedMessage = isQueued && queuedMessage && onCancelQueue && updateQueueContent;
const hasTodos = todoItems.length > 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<ChatInputContainerHandle, ChatInput
{/* Focus hint */}
<ChatInputFocusHint visible={showFocusHint} />
{/* Popup menus */}
<MentionMenu
isOpen={mention.isOpen}
isLoading={mention.isLoading}
position={mention.position}
items={mention.items}
query={mention.query}
selectedIndex={mention.selectedIndex}
onSelect={mention.handleSelect}
onClose={mention.closeMenu}
setSelectedIndex={mention.setSelectedIndex}
/>
<SlashCommandMenu
isOpen={slash.isOpen}
position={slash.position}
commands={slash.commands}
selectedIndex={slash.selectedIndex}
onSelect={slash.handleSelect}
onClose={slash.closeMenu}
setSelectedIndex={slash.setSelectedIndex}
/>
{/* CONTEXT ZONE: scrollable, above editor */}
{hasContextItems && (
<div className="overflow-y-auto max-h-[40%] border-b border-border/50 shrink-0">
<div className="px-2 pt-2 pb-1 space-y-1.5">
{/* Queued message */}
{hasQueuedMessage && (
{hasContextZone && (
<ContextZone
items={allItems}
sessionId={sessionId}
queueSlot={
hasQueuedMessage ? (
<QueuedMessageIndicator
ref={queuedMessageRef}
content={queuedMessage}
@@ -667,65 +441,20 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
isVisible={true}
onEditComplete={onQueueEditComplete}
/>
)}
{/* Todo summary */}
{hasTodos && <TodoSummary todos={todoItems} />}
{/* Document reference */}
{activeDocument && (
<DocumentReferenceIndicator activeDocument={activeDocument} />
)}
{/* Pending comment blocks */}
{hasPendingComments && sessionId && onRemoveCommentFile && onRemoveComment && (
<CommentBlocksContainer
commentsByFile={pendingCommentsByFile}
sessionId={sessionId}
onRemoveFile={onRemoveCommentFile}
onRemoveComment={onRemoveComment}
onCommentClick={onCommentClick}
/>
)}
{/* Plan comments indicator */}
{documentCommentCount > 0 && (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-2 py-1.5">
<IconMessageDots className="h-3.5 w-3.5 text-purple-500 shrink-0" />
<span className="text-xs text-muted-foreground flex-1">
{documentCommentCount} plan comment{documentCommentCount !== 1 ? 's' : ''} will be included
</span>
{onClearDocumentComments && (
<Button
variant="ghost"
size="sm"
onClick={onClearDocumentComments}
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
>
<IconX className="h-3 w-3" />
</Button>
)}
</div>
)}
{/* Image attachments preview */}
<ImageAttachmentPreview
attachments={attachments}
onRemove={handleRemoveAttachment}
disabled={isDisabled}
/>
{/* Clarification (renders above editor, textarea stays visible but disabled) */}
{hasPendingClarification && (
) : undefined
}
todoSlot={hasTodos ? <TodoSummary todos={todoItems} /> : undefined}
clarificationSlot={
hasPendingClarification ? (
<div className="overflow-auto">
<ClarificationInputOverlay
message={pendingClarification}
onResolved={onClarificationResolved}
/>
</div>
)}
</div>
</div>
) : undefined
}
/>
)}
{/* EDITOR ZONE: flex-1, fills remaining space */}
@@ -733,11 +462,10 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
<Tooltip open={showRequestChangesTooltip}>
<TooltipTrigger asChild>
<div className="flex-1 min-h-0">
<RichTextInput
<TipTapInput
ref={inputRef}
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onSubmit={handleSubmit}
placeholder={inputPlaceholder}
disabled={isDisabled || !!hasPendingClarification}
@@ -745,6 +473,13 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
submitKey={submitKey}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
sessionId={sessionId}
taskId={taskId}
onAddContextFile={onAddContextFile}
onToggleContextFile={onToggleContextFile}
planContextEnabled={planContextEnabled}
onAgentCommand={handleAgentCommand}
onImagePaste={handleImagePaste}
/>
</div>
</TooltipTrigger>
@@ -753,7 +488,7 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
</TooltipContent>
</Tooltip>
{/* Integrated toolbar - memoized to prevent re-renders on input changes */}
{/* Integrated toolbar */}
<ChatInputToolbar
planModeEnabled={planModeEnabled}
onPlanModeChange={onPlanModeChange}
@@ -767,6 +502,12 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
onCancel={onCancel}
onSubmit={handleSubmit}
submitKey={submitKey}
contextCount={allItems.length}
contextPopoverOpen={contextPopoverOpen}
onContextPopoverOpenChange={setContextPopoverOpen}
planContextEnabled={planContextEnabled}
contextFiles={contextFiles}
onToggleFile={onToggleContextFile}
/>
</div>
</div>
@@ -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 (
<div className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border">
{/* Left: Plan, Thinking, Context */}
<div className="flex items-center gap-0.5">
{/* Plan mode toggle */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
'h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40',
planModeEnabled && 'bg-primary/15 text-primary'
)}
onClick={() => onPlanModeChange(!planModeEnabled)}
>
<IconFileTextSpark className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Toggle plan mode</TooltipContent>
</Tooltip>
{/* Context @ button */}
<ContextPopover
open={contextPopoverOpen}
onOpenChange={onContextPopoverOpenChange ?? (() => {})}
trigger={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
>
<IconAt className="h-4 w-4" />
{contextCount > 0 && (
<span className="absolute -top-1 -right-1 h-4 min-w-4 rounded-full bg-muted-foreground/80 text-[10px] text-background flex items-center justify-center px-0.5">
{contextCount}
</span>
)}
</Button>
}
sessionId={sessionId}
planContextEnabled={planContextEnabled}
contextFiles={contextFiles}
onToggleFile={onToggleFile ?? (() => {})}
/>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Right: Sessions, Model, Submit */}
<div className="flex items-center gap-0.5 shrink-0">
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
{/* Token usage display */}
<TokenUsageDisplay sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
{/* Submit/Stop button */}
<div className="ml-1">
<KeyboardShortcutTooltip shortcut={submitShortcut} enabled={!isAgentBusy && !isDisabled}>
{isAgentBusy ? (
<Button
type="button"
variant="secondary"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer bg-destructive/10 text-destructive hover:bg-destructive/20"
onClick={onCancel}
>
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
</Button>
) : (
<Button
type="button"
variant="default"
size="icon"
className="h-7 w-7 rounded-full cursor-pointer"
disabled={isDisabled}
onClick={onSubmit}
>
{isSending ? (
<GridSpinner className="text-primary-foreground" />
) : (
<IconArrowUp className="h-4 w-4" />
)}
</Button>
)}
</KeyboardShortcutTooltip>
</div>
</div>
</div>
);
});
@@ -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 (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div
className={cn(
'rounded-lg border border-border bg-muted/30',
className
)}
>
{/* Header */}
<div className="flex items-center gap-2 px-2 py-1.5">
<CollapsibleTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 cursor-pointer p-0"
>
{isOpen ? (
<IconChevronDown className="h-3.5 w-3.5" />
) : (
<IconChevronRight className="h-3.5 w-3.5" />
)}
</Button>
</CollapsibleTrigger>
<IconMessage className="h-3.5 w-3.5 text-blue-500" />
<span
className="min-w-0 flex-1 truncate text-xs font-medium"
title={filePath}
>
{fileName}
</span>
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px]"
>
{comments.length}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="h-5 w-5 cursor-pointer p-0 hover:text-destructive"
>
<IconX className="h-3 w-3" />
</Button>
</div>
{/* Expanded content */}
<CollapsibleContent>
<div className="space-y-1.5 border-t border-border/50 px-2 py-2">
{comments.map((comment) => (
<div
key={comment.id}
onClick={() => onCommentClick?.(comment)}
className={cn(
'rounded transition-colors',
onCommentClick && 'cursor-pointer hover:bg-muted/50'
)}
>
<CommentDisplay
comment={comment}
compact
onDelete={() => onRemoveComment(comment.id)}
/>
</div>
))}
</div>
</CollapsibleContent>
</div>
</Collapsible>
);
}
interface CommentBlocksContainerProps {
/** Comments grouped by file path */
commentsByFile: Record<string, DiffComment[]>;
/** 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 (
<div className={cn('space-y-2', className)}>
{files.map((filePath) => (
<CommentBlock
key={filePath}
filePath={filePath}
comments={commentsByFile[filePath]}
onRemove={() => onRemoveFile(filePath)}
onRemoveComment={(commentId) => onRemoveComment(sessionId, filePath, commentId)}
onCommentClick={onCommentClick}
/>
))}
</div>
);
}
@@ -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 = (
<div className="space-y-1.5">
<div className="text-xs font-medium text-muted-foreground truncate" title={item.filePath}>
{fileName}
</div>
<div className="space-y-1">
{item.comments.map((comment) => (
<CommentDisplay
key={comment.id}
comment={comment}
compact
onDelete={() => item.onRemoveComment(comment.id)}
/>
))}
</div>
</div>
);
return (
<ContextChip
kind="comment"
label={item.label}
preview={preview}
onClick={item.onOpen}
onRemove={item.onRemove}
/>
);
});
@@ -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<ContextItemKind, TablerIcon> = {
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 = (
<div
className={`group flex items-center gap-1 px-2 py-0.5 text-xs text-muted-foreground bg-muted/50 rounded border border-border/50 ${onClick ? 'cursor-pointer hover:bg-muted/80' : ''}`}
onClick={onClick}
>
{thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element -- base64 thumbnail
<img src={thumbnail} alt="" className="h-3 w-3 shrink-0 rounded-sm object-cover" />
) : (
<Icon className="h-3 w-3 shrink-0" />
)}
<span className="truncate max-w-[120px]">{label}</span>
{pinned && onUnpin && (
<button
onClick={(e) => {
e.stopPropagation();
onUnpin();
}}
className="ml-0.5 text-muted-foreground/70 hover:text-foreground cursor-pointer"
title="Unpin (will be removed after send)"
>
<IconPinFilled className="h-2.5 w-2.5" />
</button>
)}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="opacity-0 group-hover:opacity-100 ml-0.5 hover:text-foreground cursor-pointer"
>
<IconX className="h-2.5 w-2.5" />
</button>
)}
</div>
);
if (!preview) return chip;
return (
<ControlledHoverChip preview={preview}>
{chip}
</ControlledHoverChip>
);
});
function ControlledHoverChip({
preview,
children,
}: {
preview: ReactNode;
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
const suppressRef = useRef(false);
return (
<HoverCard
open={open}
onOpenChange={(next) => {
if (next && suppressRef.current) return;
setOpen(next);
}}
openDelay={300}
>
<HoverCardTrigger
asChild
onClick={() => {
suppressRef.current = true;
setOpen(false);
setTimeout(() => { suppressRef.current = false; }, 300);
}}
>
{children}
</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
{preview}
</HoverCardContent>
</HoverCard>
);
}
@@ -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 <PlanItem item={item} />;
case 'file':
return <FileItem item={item} sessionId={sessionId} />;
case 'prompt':
return <PromptItem item={item} />;
case 'comment':
return <CommentItem item={item} />;
case 'plan-comment':
return <PlanCommentItem item={item} />;
case 'image':
return <ImageItem item={item} />;
}
}
@@ -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 (
<div className="overflow-y-auto max-h-[40%] border-b border-border/50 shrink-0">
<div className="px-2 pt-2 pb-1 space-y-1.5">
{queueSlot}
{todoSlot}
{items.length > 0 && (
<div className="flex items-center gap-1 flex-wrap px-0 py-0.5">
{items.map((item) => (
<ContextItemRenderer key={item.id} item={item} sessionId={sessionId} />
))}
</div>
)}
{clarificationSlot}
</div>
</div>
);
}
@@ -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 (
<ContextChip
kind="file"
label={item.label}
pinned={item.pinned}
preview={<LazyFilePreview path={item.path} sessionId={sessionId ?? null} />}
onClick={handleClick}
onUnpin={item.onUnpin}
onRemove={item.onRemove}
/>
);
});
@@ -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 = (
<div className="space-y-1.5">
{/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */}
<img
src={item.attachment.preview}
alt="Preview"
className="max-w-full max-h-48 rounded object-contain"
/>
<div className="text-xs text-muted-foreground">
{item.attachment.width} &times; {item.attachment.height}
</div>
</div>
);
return (
<>
<ContextChip
kind="image"
label={item.label}
thumbnail={item.attachment.preview}
preview={preview}
onClick={handleClick}
onRemove={item.onRemove}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-3xl p-2">
<DialogTitle className="sr-only">Image preview</DialogTitle>
{/* eslint-disable-next-line @next/next/no-img-element -- base64 preview */}
<img
src={item.attachment.preview}
alt="Full size preview"
className="max-w-full max-h-[80vh] rounded object-contain mx-auto"
/>
</DialogContent>
</Dialog>
</>
);
});
@@ -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';
@@ -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<string | null>(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 (
<div className="space-y-1.5">
<div className="text-muted-foreground text-xs font-medium truncate">{path}</div>
<div className="h-3 w-3/4 bg-muted animate-pulse rounded" />
<div className="h-3 w-1/2 bg-muted animate-pulse rounded" />
</div>
);
}
if (error || content === null) {
return <div className="text-muted-foreground text-xs">{path}</div>;
}
const preview = content.length > 2000 ? content.slice(0, 2000) + '...' : content;
return (
<div className="space-y-1.5">
<div className="text-muted-foreground text-xs font-medium truncate">{path}</div>
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
{preview}
</pre>
</div>
);
}
@@ -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 <div className="text-xs text-muted-foreground">No task selected</div>;
}
if (loading || !loaded) {
return (
<div className="space-y-1.5">
<div className="text-muted-foreground text-xs font-medium">Plan</div>
<div className="h-3 w-3/4 bg-muted animate-pulse rounded" />
<div className="h-3 w-1/2 bg-muted animate-pulse rounded" />
</div>
);
}
if (!plan?.content) {
return <div className="text-xs text-muted-foreground">Plan is empty</div>;
}
const preview = plan.content.length > 2000 ? plan.content.slice(0, 2000) + '...' : plan.content;
return (
<div className="space-y-1.5">
<div className="text-muted-foreground text-xs font-medium">Plan</div>
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
{preview}
</pre>
</div>
);
}
@@ -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 = (
<div className="space-y-1.5">
{item.comments.map((comment) => (
<div key={comment.id} className="text-xs space-y-0.5">
{comment.selectedText && (
<div className="text-muted-foreground italic line-clamp-2">
&ldquo;{comment.selectedText}&rdquo;
</div>
)}
<div className="break-words">{comment.comment}</div>
</div>
))}
</div>
);
return (
<ContextChip
kind="plan-comment"
label={item.label}
preview={preview}
onClick={item.onOpen}
onRemove={item.onRemove}
/>
);
});
@@ -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 (
<ContextChip
kind="plan"
label={item.label}
pinned={item.pinned}
preview={<LazyPlanPreview taskId={item.taskId ?? null} />}
onClick={item.onOpen}
onUnpin={item.onUnpin}
onRemove={item.onRemove}
/>
);
});
@@ -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 (
<ContextChip
kind="prompt"
label={item.label}
pinned={item.pinned}
preview={<PromptPreview content={item.promptContent ?? null} />}
onClick={item.onClick}
onUnpin={item.onUnpin}
onRemove={item.onRemove}
/>
);
});
@@ -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 <div className="text-xs text-muted-foreground">Custom prompt</div>;
}
const truncated = content.length > 2000 ? content.slice(0, 2000) + '...' : content;
return (
<div className="space-y-1.5">
<div className="text-muted-foreground text-xs font-medium">Prompt</div>
<pre className="text-[10px] leading-tight font-mono whitespace-pre-wrap break-all">
{truncated}
</pre>
</div>
);
}
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 <PromptPreview content={content} />;
}
@@ -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<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
side="top"
align="start"
className="w-72 p-0 gap-1"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{/* Header */}
<div className="px-3 pt-3 pb-2 flex items-baseline gap-1.5">
<p className="text-xs font-medium">Context</p>
<p className="text-[10px] text-muted-foreground">· Select files and prompts to include</p>
</div>
{/* Search */}
<div className="px-3 pb-2">
<div className="relative">
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search files and prompts..."
className="h-7 pl-7 text-xs"
/>
</div>
</div>
{/* Items */}
<div className="max-h-60 overflow-y-auto border-t border-border">
{/* Plan item - always first, only when no search query */}
{!query && (
<div
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => onToggleFile(PLAN_CONTEXT_FILE)}
>
<Checkbox
checked={planContextEnabled}
onCheckedChange={() => onToggleFile(PLAN_CONTEXT_FILE)}
className="h-3.5 w-3.5"
/>
<IconListCheck className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-xs flex-1 truncate">Plan</span>
{planContextEnabled && (
<span className="text-[9px] font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded">
ACTIVE
</span>
)}
</div>
)}
{/* Prompts section */}
{filteredPrompts.length > 0 && (
<>
{!query && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Prompts</p>
</div>
)}
{filteredPrompts.map((prompt) => (
<div
key={prompt.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => {
// 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 });
}}
>
<Checkbox
checked={contextFiles.some((f) => f.path === `prompt:${prompt.id}`)}
onCheckedChange={() => onToggleFile({ path: `prompt:${prompt.id}`, name: prompt.name, pinned: true })}
className="h-3.5 w-3.5"
/>
<IconAt className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-xs truncate block">{prompt.name}</span>
{prompt.content && (
<span className="text-[10px] text-muted-foreground truncate block">
{prompt.content.length > 60 ? prompt.content.slice(0, 60) + '...' : prompt.content}
</span>
)}
</div>
</div>
))}
</>
)}
{/* Files section header */}
{!query && fileResults.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Files</p>
</div>
)}
{/* File results */}
{isLoading ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">Loading...</div>
) : fileResults.length === 0 && query && filteredPrompts.length === 0 ? (
<div className="px-3 py-3 text-center text-xs text-muted-foreground">No results found</div>
) : (
fileResults.map((filePath) => {
const isDir = isDirectory(filePath);
const name = getFileName(filePath);
const parent = filePath.slice(0, filePath.length - name.length);
return (
<div
key={filePath}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer"
onClick={() => handleToggleFile(filePath)}
>
<Checkbox
checked={isFileSelected(filePath)}
onCheckedChange={() => handleToggleFile(filePath)}
className="h-3.5 w-3.5"
/>
{isDir ? (
<IconFolder className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<IconFile className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<div className="flex-1 min-w-0">
<span className="text-xs truncate block">{name}</span>
{parent && (
<span className="text-[10px] text-muted-foreground truncate block">{parent}</span>
)}
</div>
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -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 (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-primary/80 bg-primary/5 rounded-lg border border-primary/10">
{isPlan ? (
<IconListCheck className="h-3.5 w-3.5 shrink-0" />
) : (
<IconFileText className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{label}</span>
</div>
);
});
@@ -0,0 +1,13 @@
'use client';
import { createContext, useContext } from 'react';
type EditorContextValue = {
sessionId: string | null;
taskId: string | null;
};
const EditorContext = createContext<EditorContextValue>({ sessionId: null, taskId: null });
export const EditorContextProvider = EditorContext.Provider;
export const useEditorContext = () => useContext(EditorContext);
@@ -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<ImageAttachment | null> {
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');
+14 -21
View File
@@ -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 <IconAt className="h-4 w-4" />;
}
if (item.kind === 'plan') {
return <IconListCheck className="h-4 w-4" />;
}
const isDir = isDirectory(item.label);
return isDir ? <IconFolder className="h-4 w-4" /> : <IconFile className="h-4 w-4" />;
}
// 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 (
<PopupMenu
isOpen={isOpen}
position={position}
position={position ?? null}
clientRect={clientRect}
title="Mention files, prompts"
selectedIndex={selectedIndex}
onClose={onClose}
@@ -4,7 +4,7 @@ import { memo, isValidElement, useState, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { IconWand, IconMessageDots } from '@tabler/icons-react';
import { IconWand, IconMessageDots, IconFile } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
@@ -138,9 +138,15 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
// User message: right-aligned bubble with markdown support
if (isUser) {
// Extract image attachments 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 } | 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
<div className="flex justify-end w-full overflow-hidden">
<div className="max-w-[85%] sm:max-w-[75%] md:max-w-2xl overflow-hidden group">
{/* Context badges */}
{(hasPlanMode || hasReviewComments) && (
<div className="flex justify-end gap-1.5 mb-1">
{(hasPlanMode || hasReviewComments || contextFiles.length > 0) && (
<div className="flex justify-end gap-1.5 mb-1 flex-wrap">
{hasPlanMode && (
<span className="inline-flex items-center gap-1 rounded-full bg-purple-500/20 px-2 py-0.5 text-[10px] text-purple-400">
<IconWand size={10} /> Plan mode
@@ -162,6 +168,11 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
<IconMessageDots size={10} /> Review comments
</span>
)}
{contextFiles.map((f) => (
<span key={f.path} className="inline-flex items-center gap-1 rounded-full bg-muted/50 px-2 py-0.5 text-[10px] text-muted-foreground">
<IconFile size={10} /> {f.name}
</span>
))}
</div>
)}
<div className="rounded-2xl bg-primary/30 px-4 py-2.5 text-xs overflow-hidden">
+17 -4
View File
@@ -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,
};
@@ -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 (
<PopupMenu
isOpen={isOpen}
position={position}
position={position ?? null}
clientRect={clientRect}
title="Commands"
selectedIndex={selectedIndex}
onClose={onClose}
@@ -0,0 +1,785 @@
'use client';
import {
forwardRef,
useRef,
useImperativeHandle,
useCallback,
useState,
useEffect,
useMemo,
} from 'react';
import { useEditor, EditorContent, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from '@tiptap/react';
import Document from '@tiptap/extension-document';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import HardBreak from '@tiptap/extension-hard-break';
import History from '@tiptap/extension-history';
import Placeholder from '@tiptap/extension-placeholder';
import Code from '@tiptap/extension-code';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { common, createLowlight } from 'lowlight';
import { Extension } from '@tiptap/core';
import { cn } from '@/lib/utils';
import { getChatDraftContent, setChatDraftContent } from '@/lib/local-storage';
import { useCustomPrompts } from '@/hooks/domains/settings/use-custom-prompts';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
import { searchWorkspaceFiles } from '@/lib/ws/workspace-files';
import { EditorContextProvider } from './editor-context';
import { MentionMenu } from './mention-menu';
import { SlashCommandMenu } from './slash-command-menu';
import { ContextMention } from './tiptap-mention-extension';
import {
createMentionSuggestion,
createSlashSuggestion,
type MenuState,
type MentionSuggestionCallbacks,
type SlashSuggestionCallbacks,
} from './tiptap-suggestion';
import type { MentionItem } from '@/hooks/use-inline-mention';
import type { SlashCommand } from '@/hooks/use-inline-slash';
import type { ContextFile } from '@/lib/state/context-files-store';
// ── Lowlight (syntax highlighting) ───────────────────────────────────
const lowlightInstance = createLowlight(common);
const CODE_LANGUAGES = [
{ value: '', label: 'Plain' },
{ value: 'javascript', label: 'JavaScript' },
{ value: 'typescript', label: 'TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
{ value: 'java', label: 'Java' },
{ value: 'cpp', label: 'C++' },
{ value: 'c', label: 'C' },
{ value: 'css', label: 'CSS' },
{ value: 'html', label: 'HTML' },
{ value: 'json', label: 'JSON' },
{ value: 'yaml', label: 'YAML' },
{ value: 'markdown', label: 'Markdown' },
{ value: 'bash', label: 'Bash' },
{ value: 'sql', label: 'SQL' },
{ value: 'xml', label: 'XML' },
];
function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
const language = (node.attrs.language as string) || '';
return (
<NodeViewWrapper as="pre">
<select
contentEditable={false}
className="code-block-language"
value={language}
onChange={(e) => updateAttributes({ language: e.target.value })}
>
{CODE_LANGUAGES.map((lang) => (
<option key={lang.value} value={lang.value}>
{lang.label}
</option>
))}
</select>
{/* @ts-expect-error -- NodeViewContent 'as' prop accepts any HTML tag but types only allow 'div' */}
<NodeViewContent as="code" className={language ? `language-${language} hljs` : ''} />
</NodeViewWrapper>
);
}
// ── 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<TipTapInputHandle, TipTapInputProps>(
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<MenuState<MentionItem>>({
isOpen: false, items: [], query: '', clientRect: null, command: null,
});
const [slashMenu, setSlashMenu] = useState<MenuState<SlashCommand>>({
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<MentionItem[]> => {
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(`<p>${escapeHtml(value)}</p>`);
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(`<p>${escapeHtml(v)}</p>`);
}
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 (
<>
<MentionMenu
isOpen={mentionMenu.isOpen}
isLoading={false}
clientRect={mentionMenu.clientRect}
items={mentionMenu.items}
query={mentionMenu.query}
selectedIndex={mentionSelectedIndex}
onSelect={handleMentionSelect}
onClose={handleMentionClose}
setSelectedIndex={setMentionSelectedIndex}
/>
<SlashCommandMenu
isOpen={slashMenu.isOpen}
clientRect={slashMenu.clientRect}
commands={slashMenu.items}
selectedIndex={slashSelectedIndex}
onSelect={handleSlashSelect}
onClose={handleSlashClose}
setSelectedIndex={setSlashSelectedIndex}
/>
<EditorContextProvider value={{ sessionId, taskId: taskId ?? null }}>
<EditorContent editor={editor} className="h-full [&_.tiptap]:h-full [&_.tiptap]:outline-none" />
</EditorContextProvider>
</>
);
},
);
// ── 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<string, unknown>;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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;
}
@@ -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<Partial<SuggestionOptions>>;
};
/**
* 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<ContextMentionOptions>({
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 = (
<span
contentEditable={false}
className={`inline-flex items-center gap-1 bg-muted/70 rounded px-1.5 py-0.5 text-xs align-baseline ${clickable ? 'cursor-pointer hover:bg-muted' : 'cursor-default'}`}
onClick={clickable ? handleClick : undefined}
>
{createElement(icon, { className: "h-3 w-3 shrink-0 text-muted-foreground" })}
<span className="truncate max-w-[140px]">{label}</span>
</span>
);
if (!preview) {
return <NodeViewWrapper as="span" className="inline">{chip}</NodeViewWrapper>;
}
return (
<NodeViewWrapper as="span" className="inline">
<HoverCard
open={hoverOpen}
onOpenChange={(next) => {
if (next && suppressRef.current) return;
setHoverOpen(next);
}}
openDelay={300}
>
<HoverCardTrigger asChild>{chip}</HoverCardTrigger>
<HoverCardContent side="top" align="start" className="w-80 max-h-80 overflow-y-auto">
{preview}
</HoverCardContent>
</HoverCard>
</NodeViewWrapper>
);
}
function useMentionPreview(
kind: MentionKind,
path: string,
id: string,
sessionId: string | null,
taskId: string | null,
): React.ReactNode {
if (kind === 'file') {
return <LazyFilePreview path={path} sessionId={sessionId} />;
}
if (kind === 'prompt') {
return <PromptPreviewFromStore promptId={id} />;
}
if (kind === 'plan') {
return <LazyPlanPreview taskId={taskId} />;
}
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;
}
@@ -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<T> = {
isOpen: boolean;
items: T[];
query: string;
clientRect: (() => DOMRect | null) | null;
command: ((item: T) => void) | null;
};
const EMPTY_MENTION_STATE: MenuState<MentionItem> = {
isOpen: false,
items: [],
query: '',
clientRect: null,
command: null,
};
const EMPTY_SLASH_STATE: MenuState<SlashCommand> = {
isOpen: false,
items: [],
query: '',
clientRect: null,
command: null,
};
// ── Mention suggestion ──────────────────────────────────────────────
export type MentionSuggestionCallbacks = {
getItems: (query: string) => Promise<MentionItem[]>;
};
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<MentionItem>) => void,
onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>,
): Partial<SuggestionOptions<MentionItem, { id: string; label: string; kind: MentionKind; path: string }>> {
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<MentionItem>) {
setMenuState({
isOpen: true,
items: props.items,
query: props.query,
clientRect: props.clientRect ?? null,
command: (item: MentionItem) => {
props.command(mentionItemToAttrs(item));
},
});
},
onUpdate(props: SuggestionProps<MentionItem>) {
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<SlashCommand>) => void,
onKeyDownRef: React.MutableRefObject<((event: KeyboardEvent) => boolean) | null>,
): Partial<SuggestionOptions<SlashCommand>> {
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<SlashCommand>) {
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<SlashCommand>) {
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);
},
};
},
};
}
@@ -40,7 +40,11 @@ export const VirtualizedMessageList = memo(function VirtualizedMessageList({
}: VirtualizedMessageListProps) {
const messagesContainerRef = useRef<HTMLDivElement>(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;
}
});
@@ -686,6 +686,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
leftHeaderActionsComponent={LeftHeaderActions}
rightHeaderActionsComponent={RightHeaderActions}
onReady={onReady}
defaultRenderer="always"
className="h-full"
/>
</div>
+34 -2
View File
@@ -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 (
<PanelRoot>
+218 -63
View File
@@ -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<string, number> = {
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<ChatInputContainerHandle>(null);
const queuedMessageRef = useRef<QueuedMessageIndicatorHandle>(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<string, { content: string }>();
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}
+97 -49
View File
@@ -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<RichTextInputHandle | null>,
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<Position | null>(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]
);
+48 -4
View File
@@ -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<kandev-system>\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</kandev-system>`;
}
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<kandev-system>\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</kandev-system>`;
}
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<kandev-system>\nCONTEXT PROMPTS: The user has included the following prompt instructions as context:\n${resolved.join('\n\n')}\n</kandev-system>`;
}
}
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 };
+49 -19
View File
@@ -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<HTMLDivElement>(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,
+109
View File
@@ -42,6 +42,15 @@ export function setLocalStorage<T extends JsonValue>(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<JsonValue | null>(`${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<StoredImageAttachment[]>(`${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<number | null>(`${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}`);
}
}
+110
View File
@@ -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<string, ContextFile[]>;
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<ContextFile[]>(`${STORAGE_PREFIX}${sessionId}`, []);
}
export const useContextFilesStore = create<ContextFilesStore>((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 } }));
}
},
}));
+18 -8
View File
@@ -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<DockviewStore>((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) => {
+58
View File
@@ -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'];
+11
View File
@@ -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;
}
+18 -2
View File
@@ -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<AppState>): 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 }
+14
View File
@@ -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",