From b22e66750c3967b7792bf39c021e004f9ab70561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 3 Feb 2026 04:14:07 +0000 Subject: [PATCH] feat: improved file tree (#164) feat: improved editor --- .../agent/handlers/workspace_file_handlers.go | 41 ++ .../internal/agentctl/client/client.go | 42 ++ .../internal/agentctl/server/api/server.go | 1 + .../internal/agentctl/server/api/workspace.go | 24 + .../server/process/workspace_tracker.go | 51 ++ .../internal/agentctl/types/streams/file.go | 23 + apps/backend/pkg/websocket/actions.go | 1 + apps/pnpm-lock.yaml | 40 +- apps/web/components/diff/diff-viewer.tsx | 1 + .../task/chat/messages/tool-edit-message.tsx | 2 +- .../components/task/comment-view-popover.tsx | 123 ++++ .../task/editor-comment-popover.tsx | 159 +++++ apps/web/components/task/file-browser.tsx | 331 +++++++++- .../components/task/file-editor-content.tsx | 594 ++++++++++++++++-- .../components/task/file-viewer-content.tsx | 2 +- apps/web/components/task/markdown-editor.tsx | 59 ++ .../task/mobile/session-mobile-layout.tsx | 1 - .../task/plan-selection-popover.tsx | 27 +- .../web/components/task/task-center-panel.tsx | 236 ++++++- apps/web/components/task/task-chat-panel.tsx | 8 +- apps/web/components/task/task-files-panel.tsx | 119 +++- apps/web/components/task/task-layout.tsx | 12 +- apps/web/components/task/task-plan-panel.tsx | 56 +- apps/web/lib/local-storage.ts | 212 +++++++ apps/web/lib/ws/workspace-files.ts | 24 + apps/web/package.json | 1 + 26 files changed, 2064 insertions(+), 126 deletions(-) create mode 100644 apps/web/components/task/comment-view-popover.tsx create mode 100644 apps/web/components/task/editor-comment-popover.tsx diff --git a/apps/backend/internal/agent/handlers/workspace_file_handlers.go b/apps/backend/internal/agent/handlers/workspace_file_handlers.go index 819a095b67..70758b6468 100644 --- a/apps/backend/internal/agent/handlers/workspace_file_handlers.go +++ b/apps/backend/internal/agent/handlers/workspace_file_handlers.go @@ -29,6 +29,7 @@ func (h *WorkspaceFileHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorkspaceFileTreeGet, h.wsGetFileTree) d.RegisterFunc(ws.ActionWorkspaceFileContentGet, h.wsGetFileContent) d.RegisterFunc(ws.ActionWorkspaceFileContentUpdate, h.wsUpdateFileContent) + d.RegisterFunc(ws.ActionWorkspaceFileDelete, h.wsDeleteFile) d.RegisterFunc(ws.ActionWorkspaceFilesSearch, h.wsSearchFiles) } @@ -154,6 +155,46 @@ func (h *WorkspaceFileHandlers) wsUpdateFileContent(ctx context.Context, msg *ws return ws.NewResponse(msg.ID, msg.Action, response) } +// wsDeleteFile handles workspace.file.delete action +func (h *WorkspaceFileHandlers) wsDeleteFile(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + SessionID string `json:"session_id"` + Path string `json:"path"` + } + + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + + if req.SessionID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil) + } + if req.Path == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "path is required", nil) + } + + // Get agent execution for this session + execution, found := h.lifecycle.GetExecutionBySessionID(req.SessionID) + if !found { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "No agent found for session", nil) + } + + // Get agentctl client + client := execution.GetAgentCtlClient() + if client == nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent client not available", nil) + } + + // Delete file via agentctl + response, err := client.DeleteFile(ctx, req.Path) + if err != nil { + h.logger.Error("failed to delete file", zap.Error(err), zap.String("session_id", req.SessionID), zap.String("path", req.Path)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fmt.Sprintf("Failed to delete file: %v", err), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, response) +} + // wsSearchFiles handles workspace.files.search action func (h *WorkspaceFileHandlers) wsSearchFiles(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { diff --git a/apps/backend/internal/agentctl/client/client.go b/apps/backend/internal/agentctl/client/client.go index 13d6572c1d..3c50af7a01 100644 --- a/apps/backend/internal/agentctl/client/client.go +++ b/apps/backend/internal/agentctl/client/client.go @@ -461,6 +461,48 @@ func (c *Client) ApplyFileDiff(ctx context.Context, path string, diff string, or return &response, nil } +// FileDeleteResponse represents a file delete response +type FileDeleteResponse struct { + Path string `json:"path"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +// DeleteFile deletes a file via HTTP DELETE +func (c *Client) DeleteFile(ctx context.Context, path string) (*FileDeleteResponse, error) { + reqURL := fmt.Sprintf("%s/api/v1/workspace/file?path=%s", c.baseURL, path) + + req, err := http.NewRequestWithContext(ctx, "DELETE", reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to delete file: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + c.logger.Debug("failed to close file delete response body", zap.Error(err)) + } + }() + + var response FileDeleteResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if response.Error != "" { + return nil, fmt.Errorf("file delete error: %s", response.Error) + } + + if !response.Success { + return nil, fmt.Errorf("file delete failed") + } + + return &response, nil +} + // Close closes all connections func (c *Client) Close() { c.CloseUpdatesStream() diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 76fa49ffa7..3b123f6aa5 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -90,6 +90,7 @@ func (s *Server) setupRoutes() { api.GET("/workspace/tree", s.handleFileTree) api.GET("/workspace/file/content", s.handleFileContent) api.POST("/workspace/file/content", s.handleFileUpdate) + api.DELETE("/workspace/file", s.handleFileDelete) api.GET("/workspace/search", s.handleFileSearch) // Shell access (HTTP endpoints only - streaming is via /workspace/stream) diff --git a/apps/backend/internal/agentctl/server/api/workspace.go b/apps/backend/internal/agentctl/server/api/workspace.go index 59d356cc13..9194acce0e 100644 --- a/apps/backend/internal/agentctl/server/api/workspace.go +++ b/apps/backend/internal/agentctl/server/api/workspace.go @@ -203,6 +203,30 @@ func (s *Server) handleFileSearch(c *gin.Context) { c.JSON(200, types.FileSearchResponse{Files: files}) } +// handleFileDelete handles file delete requests via HTTP DELETE +func (s *Server) handleFileDelete(c *gin.Context) { + path := c.Query("path") + if path == "" { + c.JSON(400, streams.FileDeleteResponse{Error: "path is required"}) + return + } + + err := s.procMgr.GetWorkspaceTracker().DeleteFile(path) + if err != nil { + c.JSON(400, streams.FileDeleteResponse{ + Path: path, + Success: false, + Error: err.Error(), + }) + return + } + + c.JSON(200, streams.FileDeleteResponse{ + Path: path, + Success: true, + }) +} + // mustParseInt parses a string to int, returns 0 on error func mustParseInt(s string) int { var n int diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index a24bc80568..a3428df15c 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -1310,6 +1310,57 @@ func (wt *WorkspaceTracker) ApplyFileDiff(reqPath string, unifiedDiff string, or return newHash, nil } +// DeleteFile deletes a file from the workspace +func (wt *WorkspaceTracker) DeleteFile(reqPath string) error { + cleanWorkDir := filepath.Clean(wt.workDir) + cleanReqPath := filepath.Clean(reqPath) + + // Resolve the full path - if already absolute and within workDir, use directly + var fullPath string + if filepath.IsAbs(cleanReqPath) && strings.HasPrefix(cleanReqPath, cleanWorkDir+string(os.PathSeparator)) { + fullPath = cleanReqPath + } else { + fullPath = filepath.Join(wt.workDir, cleanReqPath) + } + + // Path traversal protection + if !strings.HasPrefix(fullPath, cleanWorkDir+string(os.PathSeparator)) && fullPath != cleanWorkDir { + return fmt.Errorf("path traversal detected") + } + + // Check if file exists + info, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("file does not exist: %s", reqPath) + } + return fmt.Errorf("failed to stat file: %w", err) + } + + // Don't allow deleting directories + if info.IsDir() { + return fmt.Errorf("cannot delete directory: %s", reqPath) + } + + // Delete the file + if err := os.Remove(fullPath); err != nil { + return fmt.Errorf("failed to delete file: %w", err) + } + + // Trigger filesystem change notification + select { + case wt.fsChangeTrigger <- struct{}{}: + default: + // Channel already has a pending trigger, skip + } + + wt.logger.Debug("deleted file", + zap.String("path", reqPath), + ) + + return nil +} + // calculateSHA256 calculates the SHA256 hash of a string func calculateSHA256(content string) string { hash := sha256.Sum256([]byte(content)) diff --git a/apps/backend/internal/agentctl/types/streams/file.go b/apps/backend/internal/agentctl/types/streams/file.go index ac596562ee..abe3c41e54 100644 --- a/apps/backend/internal/agentctl/types/streams/file.go +++ b/apps/backend/internal/agentctl/types/streams/file.go @@ -180,3 +180,26 @@ type FileUpdateResponse struct { // Error contains error message if the request failed. Error string `json:"error,omitempty"` } + +// FileDeleteRequest represents a request to delete a file. +// +// HTTP endpoint: DELETE /api/v1/workspace/file +type FileDeleteRequest struct { + // Path is the file path (relative to workspace root). + Path string `json:"path"` +} + +// FileDeleteResponse represents a response to a file delete request. +type FileDeleteResponse struct { + // RequestID identifies the request. + RequestID string `json:"request_id"` + + // Path is the file path. + Path string `json:"path"` + + // Success indicates if the deletion was successful. + Success bool `json:"success"` + + // Error contains error message if the request failed. + Error string `json:"error,omitempty"` +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 0f2c34cf20..6ca31cbfb1 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -170,6 +170,7 @@ const ( ActionWorkspaceFileTreeGet = "workspace.tree.get" ActionWorkspaceFileContentGet = "workspace.file.get" ActionWorkspaceFileContentUpdate = "workspace.file.update" + ActionWorkspaceFileDelete = "workspace.file.delete" ActionWorkspaceFilesSearch = "workspace.files.search" ActionWorkspaceFileChanges = "session.workspace.file.changes" // Notification diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 8fe0d3e78c..f88af4123c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -269,6 +269,9 @@ 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) + '@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) '@uiw/codemirror-theme-vscode': specifier: ^4.25.4 version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10) @@ -319,7 +322,7 @@ importers: version: 11.12.2 next: specifier: 16.1.1 - version: 16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2813,6 +2816,9 @@ packages: '@codemirror/state': 6.5.3 '@codemirror/view': 6.39.10 + '@uiw/codemirror-theme-github@4.25.4': + resolution: {integrity: sha512-M5zRT2vIpNsuKN0Lz+DwLnmhHW8Eddp1M9zC0hm3V+bvffmaSn/pUDey1eqGIv5xNNmjhqvDAz0a90xLYCzvSw==} + '@uiw/codemirror-theme-vscode@4.25.4': resolution: {integrity: sha512-9ob5EtLqrXBFl8uf4eFRkXjyjfyfBRVsJdt7xbc33f+2/I29/2v2nEdU/xw40+dhloxF/h1Ry281f8wAs97MWQ==} @@ -8937,6 +8943,14 @@ snapshots: '@codemirror/state': 6.5.3 '@codemirror/view': 6.39.10 + '@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)': + dependencies: + '@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + '@uiw/codemirror-theme-vscode@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)': dependencies: '@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10) @@ -10002,8 +10016,8 @@ snapshots: '@next/eslint-plugin-next': 16.1.1 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -10025,7 +10039,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -10036,22 +10050,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10062,7 +10076,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -11481,7 +11495,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.1.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -11490,7 +11504,7 @@ snapshots: postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) + styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -12578,10 +12592,12 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(react@19.2.3): + styled-jsx@5.1.6(@babel/core@7.28.6)(react@19.2.3): dependencies: client-only: 0.0.1 react: 19.2.3 + optionalDependencies: + '@babel/core': 7.28.6 stylis@4.3.6: {} diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 6ff82c4d9f..194e2fd864 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -423,6 +423,7 @@ export const DiffViewer = memo(function DiffViewer({ light: 'github-light', }, enableLineSelection: enableComments, + hunkSeparators: 'simple', enableHoverUtility: enableComments, diffIndicators: 'none', onLineSelectionEnd: handleLineSelectionEnd, diff --git a/apps/web/components/task/chat/messages/tool-edit-message.tsx b/apps/web/components/task/chat/messages/tool-edit-message.tsx index d9dfdc692f..4de9d57ef6 100644 --- a/apps/web/components/task/chat/messages/tool-edit-message.tsx +++ b/apps/web/components/task/chat/messages/tool-edit-message.tsx @@ -101,7 +101,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree {!isSuccess && getStatusIcon()} {filePath && ( - + void; + onClose: () => void; +}; + +export function CommentViewPopover({ + comments, + position, + onDelete, + onClose, +}: CommentViewPopoverProps) { + const popoverRef = useRef(null); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Delay adding listener to avoid immediate close + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [onClose]); + + // Calculate position + const popoverWidth = 350; + const popoverHeight = 200; + const gap = 8; + + let left = position.x + gap; + let top = position.y + gap; + + // If would overflow right, shift left + if (left + popoverWidth > window.innerWidth - 16) { + left = Math.max(16, window.innerWidth - popoverWidth - 16); + } + + // If would overflow bottom, position above + if (top + popoverHeight > window.innerHeight - 16) { + top = Math.max(16, position.y - popoverHeight - gap); + } + + // Format line range + const formatLineRange = (start: number, end: number) => { + return start === end ? `L${start}` : `L${start}-${end}`; + }; + + return ( +
+ {/* Comments list */} +
+ {comments.map((comment) => ( +
+ {/* Line range and delete */} +
+ + {formatLineRange(comment.startLine, comment.endLine)} + + +
+ + {/* Code snippet with scroll */} + {comment.codeContent && ( +
+                {comment.codeContent}
+              
+ )} + + {/* Annotation */} +

+ {comment.annotation} +

+
+ ))} +
+
+ ); +} diff --git a/apps/web/components/task/editor-comment-popover.tsx b/apps/web/components/task/editor-comment-popover.tsx new file mode 100644 index 0000000000..4b4fdadb61 --- /dev/null +++ b/apps/web/components/task/editor-comment-popover.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { IconPlus } from '@tabler/icons-react'; +import { Button } from '@kandev/ui/button'; +import { Textarea } from '@kandev/ui/textarea'; +import { cn } from '@/lib/utils'; + +type EditorCommentPopoverProps = { + selectedText: string; + lineRange: { start: number; end: number }; + position: { x: number; y: number }; + onSubmit: (comment: string) => void; + onClose: () => void; +}; + +export function EditorCommentPopover({ + selectedText, + lineRange, + position, + onSubmit, + onClose, +}: EditorCommentPopoverProps) { + const [comment, setComment] = useState(''); + const textareaRef = useRef(null); + const popoverRef = useRef(null); + + // Focus textarea on mount + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Delay adding listener to avoid immediate close from selection click + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside); + }, 100); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [onClose]); + + const handleSubmit = useCallback(() => { + if (!comment.trim()) return; + onSubmit(comment.trim()); + }, [comment, onSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit] + ); + + const isDisabled = !comment.trim(); + + // Truncate code preview + const previewText = selectedText.length > 100 + ? selectedText.slice(0, 100).trim() + '…' + : selectedText; + + // Format line range + const lineRangeText = lineRange.start === lineRange.end + ? `Line ${lineRange.start}` + : `Lines ${lineRange.start}-${lineRange.end}`; + + // Calculate position - anchor at bottom-right of selection, adjust if would overflow + const popoverWidth = 384; // w-96 = 24rem = 384px + const popoverHeight = 220; // approximate height of popover + const gap = 8; + + // Start at bottom-right of selection + let left = position.x + gap; + let top = position.y + gap; + + // If would overflow right, shift left + if (left + popoverWidth > window.innerWidth - 16) { + left = Math.max(16, window.innerWidth - popoverWidth - 16); + } + + // If would overflow bottom, position above the selection instead + if (top + popoverHeight > window.innerHeight - 16) { + top = Math.max(16, position.y - popoverHeight - gap); + } + + return ( +
+ {/* Line range badge */} +
+ + {lineRangeText} + +
+ + {/* Code preview */} +
+        {previewText}
+      
+ + {/* Comment input */} +