@@ -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{
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Generated
+388
@@ -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:
|
||||
|
||||
@@ -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} × {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">
|
||||
“{comment.selectedText}”
|
||||
</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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
type FenceSegment = { type: 'text'; text: string } | { type: 'code'; text: string; language: string | null };
|
||||
|
||||
/** Parse text containing markdown ``` fences into text/code segments. */
|
||||
function parseCodeFences(text: string): FenceSegment[] {
|
||||
const lines = text.split('\n');
|
||||
const segments: FenceSegment[] = [];
|
||||
let currentType: 'text' | 'code' = 'text';
|
||||
let currentLines: string[] = [];
|
||||
let currentLang: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trimStart().startsWith('```')) {
|
||||
if (currentType === 'text') {
|
||||
if (currentLines.length > 0) {
|
||||
segments.push({ type: 'text', text: currentLines.join('\n') });
|
||||
}
|
||||
// Extract language from opening fence (e.g. ```typescript)
|
||||
currentLang = line.trimStart().slice(3).trim() || null;
|
||||
currentLines = [];
|
||||
currentType = 'code';
|
||||
} else {
|
||||
segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang });
|
||||
currentLines = [];
|
||||
currentLang = null;
|
||||
currentType = 'text';
|
||||
}
|
||||
} else {
|
||||
currentLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLines.length > 0) {
|
||||
if (currentType === 'code') {
|
||||
segments.push({ type: 'code', text: currentLines.join('\n'), language: currentLang });
|
||||
} else {
|
||||
segments.push({ type: 'text', text: currentLines.join('\n') });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user