feat: improved file tree (#164)

feat: improved editor
This commit is contained in:
Carlos Florêncio
2026-02-03 04:14:07 +00:00
committed by GitHub
parent 150c3f06c6
commit b22e66750c
26 changed files with 2064 additions and 126 deletions
@@ -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 {
@@ -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()
@@ -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)
@@ -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
@@ -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))
@@ -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"`
}
+1
View File
@@ -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
+28 -12
View File
@@ -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: {}
+1
View File
@@ -423,6 +423,7 @@ export const DiffViewer = memo(function DiffViewer({
light: 'github-light',
},
enableLineSelection: enableComments,
hunkSeparators: 'simple',
enableHoverUtility: enableComments,
diffIndicators: 'none',
onLineSelectionEnd: handleLineSelectionEnd,
@@ -101,7 +101,7 @@ export const ToolEditMessage = memo(function ToolEditMessage({ comment, worktree
{!isSuccess && getStatusIcon()}
</span>
{filePath && (
<span className="min-w-0 flex-1">
<span className="min-w-0">
<FilePathButton
filePath={filePath}
worktreePath={worktreePath}
@@ -0,0 +1,123 @@
'use client';
import { useRef, useEffect } from 'react';
import { IconTrash } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { cn } from '@/lib/utils';
import type { DiffComment } from '@/lib/diff/types';
type CommentViewPopoverProps = {
comments: DiffComment[];
position: { x: number; y: number };
onDelete: (commentId: string) => void;
onClose: () => void;
};
export function CommentViewPopover({
comments,
position,
onDelete,
onClose,
}: CommentViewPopoverProps) {
const popoverRef = useRef<HTMLDivElement>(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 (
<div
ref={popoverRef}
className={cn(
'fixed z-50 w-[350px] max-h-[350px] overflow-auto rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm shadow-xl',
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
style={{
left,
top,
}}
>
{/* Comments list */}
<div className="divide-y divide-border/30">
{comments.map((comment) => (
<div key={comment.id} className="p-3">
{/* Line range and delete */}
<div className="flex items-center justify-between mb-2">
<span className="px-2 py-0.5 rounded-md bg-muted text-[10px] font-mono text-muted-foreground">
{formatLineRange(comment.startLine, comment.endLine)}
</span>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
onClick={() => onDelete(comment.id)}
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
{/* Code snippet with scroll */}
{comment.codeContent && (
<pre className="mb-2 p-2 rounded-md bg-muted/50 text-[10px] text-muted-foreground font-mono max-h-[80px] overflow-auto whitespace-pre-wrap">
{comment.codeContent}
</pre>
)}
{/* Annotation */}
<p className="text-xs text-foreground leading-relaxed">
{comment.annotation}
</p>
</div>
))}
</div>
</div>
);
}
@@ -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<HTMLTextAreaElement>(null);
const popoverRef = useRef<HTMLDivElement>(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 (
<div
ref={popoverRef}
className={cn(
'fixed z-50 w-96 rounded-xl border border-border/50 bg-popover/95 backdrop-blur-sm p-4 shadow-xl',
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
style={{
left,
top,
}}
>
{/* Line range badge */}
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-0.5 rounded-md bg-muted text-xs font-mono text-muted-foreground">
{lineRangeText}
</span>
</div>
{/* Code preview */}
<pre className="mb-3 p-2 rounded-md bg-muted/50 text-xs text-muted-foreground font-mono line-clamp-3 overflow-hidden whitespace-pre-wrap">
{previewText}
</pre>
{/* Comment input */}
<Textarea
ref={textareaRef}
value={comment}
onChange={(e) => setComment(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Add your comment or instruction..."
className="min-h-[72px] resize-none text-sm border-border/50 focus:border-primary/50"
/>
{/* Actions */}
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">
⌘+Enter to add
</span>
<Button
size="sm"
onClick={handleSubmit}
disabled={isDisabled}
className="gap-1.5 cursor-pointer"
>
<IconPlus className="h-3.5 w-3.5" />
Add comment
</Button>
</div>
</div>
);
}
+311 -20
View File
@@ -1,29 +1,85 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen } from '@tabler/icons-react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { IconChevronRight, IconChevronDown, IconFile, IconFolder, IconFolderOpen, IconSearch, IconX, IconLoader2, IconTrash } from '@tabler/icons-react';
import { ScrollArea } from '@kandev/ui/scroll-area';
import { Input } from '@kandev/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileTree, requestFileContent } from '@/lib/ws/workspace-files';
import { requestFileTree, requestFileContent, searchWorkspaceFiles } from '@/lib/ws/workspace-files';
import type { FileTreeNode, FileContentResponse, OpenFileTab } from '@/lib/types/backend';
import {
getFilesPanelExpandedPaths,
setFilesPanelExpandedPaths,
getFilesPanelScrollPosition,
setFilesPanelScrollPosition,
} from '@/lib/local-storage';
type FileBrowserProps = {
sessionId: string;
onOpenFile: (file: OpenFileTab) => void;
onDeleteFile?: (path: string) => void;
isSearchOpen?: boolean;
onCloseSearch?: () => void;
activeFilePath?: string | null;
};
export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen = false, onCloseSearch, activeFilePath }: FileBrowserProps) {
const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const [isLoadingTree, setIsLoadingTree] = useState(true);
// Search state
const [localSearchQuery, setLocalSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<string[] | null>(null);
const [isSearching, setIsSearching] = useState(false);
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Scroll persistence
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasRestoredScrollRef = useRef<string | null>(null);
const hasInitializedExpandedRef = useRef<string | null>(null);
// Focus search input when search opens
useEffect(() => {
if (isSearchOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isSearchOpen]);
// Clear search when closing
useEffect(() => {
if (!isSearchOpen) {
setLocalSearchQuery('');
setSearchResults(null);
setIsSearching(false);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
}
}, [isSearchOpen]);
// Load initial tree - always try to load, don't wait for agentctl ready status
useEffect(() => {
setTree(null);
setExpandedPaths(new Set());
setLoadingPaths(new Set());
setIsLoadingTree(true);
// Reset the auto-expand ref so it will run for the new session
hasInitializedExpandedRef.current = null;
// Restore expanded paths from session storage or reset
const savedPaths = getFilesPanelExpandedPaths(sessionId);
if (savedPaths.length > 0) {
setExpandedPaths(new Set(savedPaths));
// Mark as initialized only if we have saved paths to restore
hasInitializedExpandedRef.current = sessionId;
} else {
setExpandedPaths(new Set());
// Leave hasInitializedExpandedRef.current as null so auto-expand will run
}
const loadTree = async () => {
try {
@@ -42,6 +98,51 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
loadTree();
}, [sessionId]);
// Auto-expand first level folders and load their children
// Note: We intentionally use tree?.children?.length instead of tree to avoid re-running when tree updates
useEffect(() => {
if (!tree || isLoadingTree) {
return;
}
// Only auto-expand if we haven't initialized from saved state
if (hasInitializedExpandedRef.current === sessionId) {
return;
}
hasInitializedExpandedRef.current = sessionId;
// Only expand the root folder to show its immediate children
setExpandedPaths(new Set([tree.path]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tree?.children?.length, isLoadingTree, sessionId]);
// Save expanded paths when they change
useEffect(() => {
if (expandedPaths.size > 0 || hasInitializedExpandedRef.current === sessionId) {
setFilesPanelExpandedPaths(sessionId, Array.from(expandedPaths));
}
}, [expandedPaths, sessionId]);
// Track if tree is loaded (used for scroll restore timing)
const isTreeLoaded = !isLoadingTree && tree !== null;
// Restore scroll position after tree loads
useEffect(() => {
if (!isTreeLoaded || hasRestoredScrollRef.current === sessionId) return;
const savedScroll = getFilesPanelScrollPosition(sessionId);
if (savedScroll > 0 && scrollAreaRef.current) {
// Find the viewport element within ScrollArea
const viewport = scrollAreaRef.current.querySelector('[data-radix-scroll-area-viewport]');
if (viewport) {
viewport.scrollTop = savedScroll;
hasRestoredScrollRef.current = sessionId;
}
} else {
hasRestoredScrollRef.current = sessionId;
}
}, [isTreeLoaded, sessionId]);
// Subscribe to file changes and refresh tree
useEffect(() => {
const client = getWebSocketClient();
@@ -60,6 +161,89 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
return unsubscribe;
}, [sessionId]);
// Handle scroll events to save position
const handleScroll = useCallback((event: Event) => {
const target = event.target as HTMLElement;
const scrollTop = target.scrollTop;
// Debounce save
if (scrollSaveTimeoutRef.current) {
clearTimeout(scrollSaveTimeoutRef.current);
}
scrollSaveTimeoutRef.current = setTimeout(() => {
setFilesPanelScrollPosition(sessionId, scrollTop);
}, 150);
}, [sessionId]);
// Attach scroll listener to ScrollArea viewport
useEffect(() => {
const scrollArea = scrollAreaRef.current;
if (!scrollArea) return;
const viewport = scrollArea.querySelector('[data-radix-scroll-area-viewport]');
if (!viewport) return;
viewport.addEventListener('scroll', handleScroll);
return () => {
viewport.removeEventListener('scroll', handleScroll);
if (scrollSaveTimeoutRef.current) {
clearTimeout(scrollSaveTimeoutRef.current);
}
};
}, [handleScroll, tree]);
// Handle search input change with debounce
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setLocalSearchQuery(value);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
if (!value.trim()) {
setSearchResults(null);
setIsSearching(false);
return;
}
setIsSearching(true);
searchTimeoutRef.current = setTimeout(async () => {
try {
const client = getWebSocketClient();
if (!client) return;
const response = await searchWorkspaceFiles(client, sessionId, value, 50);
setSearchResults(response.files || []);
} catch (error) {
console.error('Failed to search files:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, 300);
}, [sessionId]);
// Close search (clears and notifies parent)
const handleCloseSearch = useCallback(() => {
setLocalSearchQuery('');
setSearchResults(null);
setIsSearching(false);
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
onCloseSearch?.();
}, [onCloseSearch]);
// Cleanup search timeout on unmount
useEffect(() => {
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, []);
const toggleExpand = useCallback(async (node: FileTreeNode) => {
if (!node.is_dir) return;
@@ -134,42 +318,85 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
}
}, [sessionId, onOpenFile]);
// Open file from search result (path only)
const openFileByPath = useCallback(async (path: string) => {
try {
const client = getWebSocketClient();
if (!client) return;
const response: FileContentResponse = await requestFileContent(client, sessionId, path);
// Calculate hash for the file content
const { calculateHash } = await import('@/lib/utils/file-diff');
const hash = await calculateHash(response.content);
// Extract filename from path
const name = path.split('/').pop() || path;
onOpenFile({
path,
name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
});
} catch (error) {
console.error('Failed to load file content:', error);
}
}, [sessionId, onOpenFile]);
const renderTreeNode = (node: FileTreeNode, depth: number = 0): React.ReactNode => {
const isExpanded = expandedPaths.has(node.path);
const isLoading = loadingPaths.has(node.path);
const isActive = !node.is_dir && activeFilePath === node.path;
return (
<div key={node.path}>
<div
className="flex items-center gap-1 py-1 px-2 hover:bg-accent cursor-pointer rounded text-sm"
style={{ paddingLeft: `${depth * 12 + 8}px` }}
className={`group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm ${isActive ? 'bg-muted/50' : ''}`}
style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }}
onClick={() => (node.is_dir ? toggleExpand(node) : openFile(node))}
>
{node.is_dir && (
<span className="flex-shrink-0">
{isLoading ? (
<span className="animate-spin">⏳</span>
<IconLoader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : isExpanded ? (
<IconChevronDown className="h-4 w-4" />
<IconChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<IconChevronRight className="h-4 w-4" />
<IconChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</span>
)}
{node.is_dir ? (
isExpanded ? (
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-blue-500" />
<IconFolderOpen className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
) : (
<IconFolder className="h-3 w-3 flex-shrink-0 text-blue-500" />
<IconFolder className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
)
) : (
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<IconFile className={`h-3 w-3 flex-shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`} />
)}
<span className="truncate">{node.name}</span>
{!node.is_dir && node.size && (
<span className="text-xs text-muted-foreground ml-auto">
{(node.size / 1024).toFixed(1)}KB
</span>
<span className={`flex-1 truncate ${isActive ? 'text-foreground' : 'text-muted-foreground'} group-hover:text-foreground ${node.is_dir ? 'font-medium' : ''}`}>{node.name}</span>
{!node.is_dir && onDeleteFile && (
<div className="flex items-center gap-1 ml-auto opacity-0 group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-destructive cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDeleteFile(node.path);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
</div>
)}
</div>
{node.is_dir && isExpanded && node.children && (
@@ -188,10 +415,74 @@ export function FileBrowser({ sessionId, onOpenFile }: FileBrowserProps) {
);
};
const renderSearchResults = () => {
if (!searchResults) return null;
if (searchResults.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground text-center">
No files found
</div>
);
}
return (
<div className="pb-2">
{searchResults.map((path) => {
const name = path.split('/').pop() || path;
const folder = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : '';
return (
<div
key={path}
className="group flex items-center gap-1 py-1 px-2 hover:bg-muted/50 cursor-pointer rounded text-sm"
onClick={() => openFileByPath(path)}
>
<IconFile className="h-3 w-3 flex-shrink-0 text-muted-foreground" />
<span className="truncate text-muted-foreground group-hover:text-foreground">
{folder && <span>{folder}/</span>}
<span>{name}</span>
</span>
</div>
);
})}
</div>
);
};
return (
<div className="flex flex-col h-full">
<ScrollArea className="flex-1">
{isLoadingTree ? (
{/* Search input - only shown when search is open */}
{isSearchOpen && (
<div className="px-2 pb-2">
<div className="relative">
{isSearching ? (
<IconLoader2 className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none animate-spin" />
) : (
<IconSearch className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" />
)}
<Input
ref={searchInputRef}
type="text"
value={localSearchQuery}
onChange={handleSearchChange}
placeholder="Search files..."
className="pl-7 pr-7 h-7 text-xs"
/>
<button
type="button"
onClick={handleCloseSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<IconX className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
<ScrollArea className="flex-1" ref={scrollAreaRef}>
{isSearchOpen && searchResults !== null ? (
renderSearchResults()
) : isLoadingTree ? (
<div className="p-4 text-sm text-muted-foreground">Loading files...</div>
) : tree ? (
<div className="pb-2">{renderTreeNode(tree)}</div>
+545 -49
View File
@@ -1,14 +1,21 @@
'use client';
import { useCallback, useEffect, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
import { EditorView } from '@codemirror/view';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
import { EditorView, gutter, GutterMarker, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import { Decoration, type DecorationSet } from '@codemirror/view';
import { getCodeMirrorExtensionFromPath } from '@/lib/languages';
import { Button } from '@kandev/ui/button';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons-react';
import { IconDeviceFloppy, IconLoader2, IconTrash, IconTextWrap, IconTextWrapDisabled, IconMessagePlus } from '@tabler/icons-react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { formatDiffStats } from '@/lib/utils/file-diff';
import { vscodeDark } from '@uiw/codemirror-theme-vscode';
import { useDiffCommentsStore, useFileComments } from '@/lib/state/slices/diff-comments/diff-comments-slice';
import type { DiffComment } from '@/lib/diff/types';
import { useToast } from '@/components/toast-provider';
import { EditorCommentPopover } from './editor-comment-popover';
import { CommentViewPopover } from './comment-view-popover';
type FileEditorContentProps = {
path: string;
@@ -16,60 +23,388 @@ type FileEditorContentProps = {
originalContent: string;
isDirty: boolean;
isSaving: boolean;
sessionId?: string;
enableComments?: boolean;
onChange: (newContent: string) => void;
onSave: () => void;
onDelete?: () => void;
};
type TextSelection = {
text: string;
startLine: number;
endLine: number;
position: { x: number; y: number };
} | null;
type FloatingButtonPosition = {
x: number;
y: number;
} | null;
type CommentViewState = {
comments: DiffComment[];
position: { x: number; y: number };
} | null;
// Create comment line decorations
function createCommentDecorations(
view: EditorView,
comments: DiffComment[]
): DecorationSet {
if (comments.length === 0) {
return Decoration.none;
}
const decorations: Array<{ from: number; decoration: Decoration }> = [];
// Get all lines that have comments
const linesWithComments = new Set<number>();
for (const comment of comments) {
for (let line = comment.startLine; line <= comment.endLine; line++) {
linesWithComments.add(line);
}
}
// Create line decorations for commented lines
for (const lineNum of linesWithComments) {
if (lineNum > view.state.doc.lines) continue;
const line = view.state.doc.line(lineNum);
decorations.push({
from: line.from,
decoration: Decoration.line({
class: 'cm-comment-line',
}),
});
}
decorations.sort((a, b) => a.from - b.from);
return Decoration.set(decorations.map(d => d.decoration.range(d.from)));
}
// Gutter marker for lines with comments - defined outside component to avoid re-creation
class CommentGutterMarker extends GutterMarker {
constructor(
readonly lineComments: DiffComment[],
readonly isFirstLine: boolean
) {
super();
}
toDOM() {
const marker = document.createElement('div');
marker.className = 'cm-comment-gutter-marker';
marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? 's' : ''} - click to view`;
// Add icon only on the first line of a comment
if (this.isFirstLine) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '12');
svg.setAttribute('height', '12');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'rgba(99, 102, 241, 0.9)');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z');
svg.appendChild(path);
marker.appendChild(svg);
}
return marker;
}
eq(other: CommentGutterMarker) {
return this.lineComments.length === other.lineComments.length && this.isFirstLine === other.isFirstLine;
}
}
export function FileEditorContent({
path,
content,
originalContent,
isDirty,
isSaving,
sessionId,
enableComments = false,
onChange,
onSave,
onDelete,
}: FileEditorContentProps) {
const [wrapEnabled, setWrapEnabled] = useState(true);
const [textSelection, setTextSelection] = useState<TextSelection>(null);
const [floatingButtonPos, setFloatingButtonPos] = useState<FloatingButtonPosition>(null);
const [currentSelection, setCurrentSelection] = useState<{ text: string; startLine: number; endLine: number } | null>(null);
const [commentView, setCommentView] = useState<CommentViewState>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { toast } = useToast();
// Comment store
const addComment = useDiffCommentsStore((state) => state.addComment);
const removeComment = useDiffCommentsStore((state) => state.removeComment);
const comments = useFileComments(sessionId ?? '', path);
const langExt = getCodeMirrorExtensionFromPath(path);
const extensions: Extension[] = [
EditorView.lineWrapping,
EditorView.editable.of(true),
];
if (langExt) {
extensions.push(langExt);
}
// Create the comment decoration plugin
const commentPlugin = useMemo(() => {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = createCommentDecorations(view, comments);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = createCommentDecorations(update.view, comments);
}
}
},
{ decorations: (v) => v.decorations }
);
}, [comments]);
// Create comment gutter
const commentGutter = useMemo(() => {
// Build a map of line number to comments that include that line
const commentsByLine = new Map<number, DiffComment[]>();
// Track which lines are the first line of at least one comment
const firstLines = new Set<number>();
for (const comment of comments) {
firstLines.add(comment.startLine);
// Add to all lines in the range
for (let lineNum = comment.startLine; lineNum <= comment.endLine; lineNum++) {
const existing = commentsByLine.get(lineNum) || [];
existing.push(comment);
commentsByLine.set(lineNum, existing);
}
}
return gutter({
class: 'cm-comment-gutter',
lineMarker: (view, line) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
return new CommentGutterMarker(lineComments, firstLines.has(lineNum));
}
return null;
},
domEventHandlers: {
click: (view, line, event) => {
const lineNum = view.state.doc.lineAt(line.from).number;
const lineComments = commentsByLine.get(lineNum);
if (lineComments && lineComments.length > 0) {
event.preventDefault();
event.stopPropagation();
setCommentView({
comments: lineComments,
position: { x: (event as MouseEvent).clientX, y: (event as MouseEvent).clientY },
});
return true;
}
return false;
},
},
});
}, [comments]);
// Track mouse position
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
mousePositionRef.current = { x: e.clientX, y: e.clientY };
};
document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, []);
// Show floating button after selection ends (on mouseup)
const handleSelectionEnd = useCallback(() => {
if (!enableComments || !sessionId) return;
const view = editorRef.current?.view;
if (!view) return;
const selection = view.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
const selectedText = view.state.sliceDoc(selection.from, selection.to);
if (!selectedText.trim()) {
setFloatingButtonPos(null);
setCurrentSelection(null);
return;
}
// Get line numbers
const startLine = view.state.doc.lineAt(selection.from).number;
const endLine = view.state.doc.lineAt(selection.to).number;
// Use mouse position for the floating button (where cursor is)
setCurrentSelection({ text: selectedText, startLine, endLine });
setFloatingButtonPos({ x: mousePositionRef.current.x, y: mousePositionRef.current.y });
}, [enableComments, sessionId]);
// Listen for mouseup to show floating button after selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleMouseUp = (e: MouseEvent) => {
// Don't show if clicking on the floating button itself
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Small delay to let selection finalize
setTimeout(handleSelectionEnd, 10);
};
// Clear selection state when clicking (new selection starts)
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
setFloatingButtonPos(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, [enableComments, sessionId, handleSelectionEnd]);
// Clear floating button when selection is cleared via keyboard
const selectionUpdateExtension = useMemo(() => {
return EditorView.updateListener.of((update) => {
if (update.selectionSet) {
const selection = update.state.selection.main;
if (selection.empty) {
setFloatingButtonPos(null);
setCurrentSelection(null);
}
}
});
}, []);
const extensions: Extension[] = useMemo(() => {
const exts: Extension[] = [
EditorView.editable.of(true),
EditorView.theme({
'&': { backgroundColor: 'hsl(var(--background)) !important' },
'.cm-gutters': { backgroundColor: 'hsl(var(--background)) !important', borderRight: 'none' },
// Comment gutter
'.cm-comment-gutter': {
width: '22px',
cursor: 'pointer',
},
'.cm-comment-gutter .cm-gutterElement': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
},
// Marker styles - background color on the marker itself (indigo)
'.cm-comment-gutter-marker': {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
cursor: 'pointer',
backgroundColor: 'rgba(99, 102, 241, 0.2)',
},
'.cm-comment-gutter-marker:hover': {
backgroundColor: 'rgba(99, 102, 241, 0.35)',
},
// Comment line highlight with left border (indigo)
'.cm-comment-line': {
backgroundColor: 'rgba(99, 102, 241, 0.15) !important',
borderLeft: '3px solid rgba(99, 102, 241, 0.6)',
marginLeft: '-3px',
paddingLeft: '3px',
},
}),
commentGutter,
commentPlugin,
selectionUpdateExtension,
];
if (wrapEnabled) {
exts.push(EditorView.lineWrapping);
}
if (langExt) {
exts.push(langExt);
}
return exts;
}, [langExt, wrapEnabled, commentGutter, commentPlugin, selectionUpdateExtension]);
// Calculate diff stats when dirty
const diffStats = useMemo(() => {
if (!isDirty) return null;
// Simple line-based diff for stats
const originalLines = originalContent.split('\n');
const currentLines = content.split('\n');
let additions = 0;
let deletions = 0;
// Simple comparison - count different lines
const maxLen = Math.max(originalLines.length, currentLines.length);
for (let i = 0; i < maxLen; i++) {
const origLine = originalLines[i];
const currLine = currentLines[i];
if (origLine === undefined && currLine !== undefined) {
additions++;
} else if (origLine !== undefined && currLine === undefined) {
deletions++;
} else if (origLine !== currLine) {
// Line changed - count as both addition and deletion
additions++;
deletions++;
}
}
return { additions, deletions };
}, [isDirty, content, originalContent]);
// Handle Cmd/Ctrl+S keyboard shortcut
// Handle Cmd+I to open comment popover
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper || !enableComments || !sessionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Check for Cmd+I (Mac) or Ctrl+I (Windows/Linux)
if ((e.metaKey || e.ctrlKey) && e.key === 'i') {
if (!currentSelection || !floatingButtonPos) return;
e.preventDefault();
e.stopPropagation();
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}
};
wrapper.addEventListener('keydown', handleKeyDown, true);
return () => wrapper.removeEventListener('keydown', handleKeyDown, true);
}, [enableComments, sessionId, currentSelection, floatingButtonPos]);
// Handle Cmd/Ctrl+S keyboard shortcut and Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
@@ -78,11 +413,16 @@ export function FileEditorContent({
onSave();
}
}
// Escape to close popovers
if (e.key === 'Escape') {
if (textSelection) setTextSelection(null);
if (commentView) setCommentView(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isDirty, isSaving, onSave]);
}, [isDirty, isSaving, onSave, textSelection, commentView]);
const handleChange = useCallback(
(value: string) => {
@@ -91,11 +431,91 @@ export function FileEditorContent({
[onChange]
);
// Open comment popover from floating button
const handleFloatingButtonClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!currentSelection || !floatingButtonPos) return;
setTextSelection({
...currentSelection,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
}, [currentSelection, floatingButtonPos]);
// Submit comment
const handleCommentSubmit = useCallback((annotation: string) => {
if (!textSelection || !sessionId) return;
const { text, startLine, endLine } = textSelection;
const comment: DiffComment = {
id: `${path}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
sessionId,
filePath: path,
startLine,
endLine,
side: 'additions',
codeContent: text,
annotation,
createdAt: new Date().toISOString(),
status: 'pending',
};
addComment(comment);
setTextSelection(null);
// Clear selection in editor
const view = editorRef.current?.view;
if (view) {
view.dispatch({
selection: { anchor: view.state.selection.main.head },
});
}
toast({
title: 'Comment added',
description: 'Your comment will be sent with your next message.',
});
}, [textSelection, sessionId, path, addComment, toast]);
// Close popover
const handlePopoverClose = useCallback(() => {
setTextSelection(null);
}, []);
// Delete comment
const handleDeleteComment = useCallback((commentId: string) => {
if (!sessionId) return;
removeComment(sessionId, path, commentId);
// Close the view if no more comments
if (commentView && commentView.comments.length <= 1) {
setCommentView(null);
} else if (commentView) {
setCommentView({
...commentView,
comments: commentView.comments.filter(c => c.id !== commentId),
});
}
toast({
title: 'Comment deleted',
});
}, [sessionId, path, removeComment, commentView, toast]);
// Close comment view
const handleCommentViewClose = useCallback(() => {
setCommentView(null);
}, []);
return (
<div className="flex h-full flex-col">
<div ref={wrapperRef} className="flex h-full flex-col bg-background rounded-lg">
{/* Editor header with save button */}
<div className="flex items-center justify-between border-b border-border bg-muted/30 px-4 py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between px-2 border-foreground/10 border-b">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{path}</span>
{isDirty && diffStats && (
<span className="text-xs text-yellow-500">
@@ -103,33 +523,70 @@ export function FileEditorContent({
</span>
)}
</div>
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={!isDirty || isSaving}
className="cursor-pointer gap-2"
>
{isSaving ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<IconDeviceFloppy className="h-4 w-4" />
Save
<span className="text-xs text-muted-foreground">
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
</span>
</>
<div className="flex items-center gap-1">
{enableComments && sessionId && comments.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
<IconMessagePlus className="h-3.5 w-3.5" />
<span>{comments.length} comment{comments.length > 1 ? 's' : ''}</span>
</div>
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={() => setWrapEnabled(!wrapEnabled)}
className={`h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? 'text-foreground' : 'text-muted-foreground'}`}
>
{wrapEnabled ? <IconTextWrap className="h-4 w-4" /> : <IconTextWrapDisabled className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>{wrapEnabled ? 'Disable word wrap' : 'Enable word wrap'}</TooltipContent>
</Tooltip>
{onDelete && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={onDelete}
className="h-8 w-8 p-0 cursor-pointer text-muted-foreground hover:text-destructive"
>
<IconTrash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete file</TooltipContent>
</Tooltip>
)}
<Button
size="sm"
variant="default"
onClick={onSave}
disabled={!isDirty || isSaving}
className="cursor-pointer gap-2"
>
{isSaving ? (
<>
<IconLoader2 className="h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<IconDeviceFloppy className="h-4 w-4" />
Save
<span className="text-xs text-muted-foreground">
({navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+S)
</span>
</>
)}
</Button>
</div>
</div>
{/* CodeMirror editor */}
<div className="flex-1 overflow-hidden">
<div className="flex-1 overflow-hidden relative">
<CodeMirror
ref={editorRef}
value={content}
height="100%"
theme={vscodeDark}
@@ -140,11 +597,50 @@ export function FileEditorContent({
foldGutter: true,
highlightActiveLine: true,
highlightSelectionMatches: true,
searchKeymap: true,
}}
className="h-full overflow-auto text-sm"
className="h-full overflow-auto text-xs"
/>
{/* Floating "Add comment" button when text is selected */}
{floatingButtonPos && !textSelection && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{
left: floatingButtonPos.x + 8,
top: floatingButtonPos.y + 8,
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{/* Comment popover for adding new comment */}
{textSelection && (
<EditorCommentPopover
selectedText={textSelection.text}
lineRange={{ start: textSelection.startLine, end: textSelection.endLine }}
position={textSelection.position}
onSubmit={handleCommentSubmit}
onClose={handlePopoverClose}
/>
)}
{/* Comment view popover for viewing/deleting existing comments */}
{commentView && (
<CommentViewPopover
comments={commentView.comments}
position={commentView.position}
onDelete={handleDeleteComment}
onClose={handleCommentViewClose}
/>
)}
</div>
</div>
);
}
@@ -34,7 +34,7 @@ export function FileViewerContent({ path, content }: FileViewerContentProps) {
highlightActiveLine: false,
highlightSelectionMatches: true,
}}
className="h-full overflow-auto text-sm"
className="h-full overflow-auto text-xs"
/>
);
}
@@ -30,6 +30,8 @@ type MilkdownEditorProps = {
onChange: (value: string) => void;
placeholder?: string;
onSelectionChange?: (selection: TextSelection | null) => void;
/** Called when selection ends (mouseup) with selected text - for showing floating button */
onSelectionEnd?: (selection: TextSelection | null) => void;
comments?: CommentHighlight[];
onCommentClick?: (id: string, position: { x: number; y: number }) => void;
};
@@ -349,6 +351,7 @@ function MilkdownEditorInner({
onChange,
placeholder = 'Start typing...',
onSelectionChange,
onSelectionEnd,
comments = [],
onCommentClick,
}: MilkdownEditorProps) {
@@ -359,6 +362,7 @@ function MilkdownEditorInner({
const placeholderRef = useRef(placeholder);
const onChangeRef = useRef(onChange);
const onSelectionChangeRef = useRef(onSelectionChange);
const onSelectionEndRef = useRef(onSelectionEnd);
const commentsRef = useRef(comments);
const onCommentClickRef = useRef(onCommentClick);
@@ -371,6 +375,10 @@ function MilkdownEditorInner({
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
useEffect(() => {
onSelectionEndRef.current = onSelectionEnd;
}, [onSelectionEnd]);
useEffect(() => {
commentsRef.current = comments;
}, [comments]);
@@ -399,6 +407,57 @@ function MilkdownEditorInner({
return () => wrapper.removeEventListener('click', handleClick);
}, []);
// Handle mouseup to show floating button when selection ends
useEffect(() => {
const wrapper = wrapperRef.current;
if (!wrapper) return;
const handleMouseUp = (e: MouseEvent) => {
if (!onSelectionEndRef.current) return;
// Capture mouse position immediately
const mouseX = e.clientX;
const mouseY = e.clientY;
// Small delay to let selection finalize
setTimeout(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
onSelectionEndRef.current?.(null);
return;
}
const selectedText = selection.toString().trim();
if (selectedText.length < MIN_COMMENT_TEXT_LENGTH) {
onSelectionEndRef.current?.(null);
return;
}
// Use mouse position (where cursor is) instead of selection end
onSelectionEndRef.current?.({
text: selectedText,
position: { x: mouseX, y: mouseY },
});
}, 10);
};
const handleMouseDown = (e: MouseEvent) => {
// Don't clear if clicking on the floating button
if ((e.target as HTMLElement).closest('.floating-comment-btn')) {
return;
}
// Clear selection state when starting new selection
onSelectionEndRef.current?.(null);
};
wrapper.addEventListener('mouseup', handleMouseUp);
wrapper.addEventListener('mousedown', handleMouseDown);
return () => {
wrapper.removeEventListener('mouseup', handleMouseUp);
wrapper.removeEventListener('mousedown', handleMouseDown);
};
}, []);
// Handle Cmd+I to trigger selection popover (overrides italic formatting)
useEffect(() => {
const wrapper = wrapperRef.current;
@@ -107,7 +107,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({
<TaskChatPanel
sessionId={sessionId}
onOpenFile={handleOpenFileFromChat}
onSelectDiff={handleSelectDiffAndSwitchPanel}
/>
)
) : (
@@ -1,7 +1,7 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Textarea } from '@kandev/ui/textarea';
import { cn } from '@/lib/utils';
@@ -17,6 +17,7 @@ type PlanSelectionPopoverProps = {
onAdd: (comment: string, selectedText: string) => void;
onClose: () => void;
editingComment?: string; // Pre-fill if editing existing comment
onDelete?: () => void; // Delete callback when editing existing comment
};
export function PlanSelectionPopover({
@@ -25,6 +26,7 @@ export function PlanSelectionPopover({
onAdd,
onClose,
editingComment,
onDelete,
}: PlanSelectionPopoverProps) {
const [comment, setComment] = useState(editingComment || '');
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -134,14 +136,29 @@ export function PlanSelectionPopover({
{/* Actions */}
<div className="mt-3 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">
⌘+Enter to add
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground/70">
⌘+Enter to {editingComment ? 'update' : 'add'}
</span>
{editingComment && onDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => {
onDelete();
onClose();
}}
className="h-7 px-2 text-muted-foreground hover:text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
)}
</div>
<Button
size="sm"
onClick={handleSubmit}
disabled={isDisabled}
className="gap-1.5"
className="gap-1.5 cursor-pointer"
>
<IconPlus className="h-3.5 w-3.5" />
{editingComment ? 'Update' : 'Add comment'}
+217 -19
View File
@@ -1,6 +1,6 @@
'use client';
import { memo, useCallback, useState, useEffect, useMemo } from 'react';
import { memo, useCallback, useState, useEffect, useMemo, useRef } from 'react';
import { IconSparkles, IconCheck, IconChevronDown, IconX } from '@tabler/icons-react';
import { TabsContent } from '@kandev/ui/tabs';
import { Button } from '@kandev/ui/button';
@@ -18,13 +18,12 @@ import { TaskPlanPanel } from './task-plan-panel';
import { FileEditorContent } from './file-editor-content';
import { PassthroughTerminal } from './passthrough-terminal';
import type { OpenFileTab, FileContentResponse } from '@/lib/types/backend';
import { FILE_EXTENSION_COLORS } from '@/lib/types/backend';
import { useAppStore } from '@/components/state-provider';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { approveSessionAction } from '@/app/actions/workspaces';
import { getWebSocketClient } from '@/lib/ws/connection';
import { requestFileContent, updateFileContent } from '@/lib/ws/workspace-files';
import { getPlanLastSeen, setPlanLastSeen, getCenterPanelTab, setCenterPanelTab } from '@/lib/local-storage';
import { requestFileContent, updateFileContent, deleteFile } from '@/lib/ws/workspace-files';
import { getPlanLastSeen, setPlanLastSeen, getOpenFileTabs, setOpenFileTabs as saveOpenFileTabs, getActiveTabForSession, setActiveTabForSession } from '@/lib/local-storage';
import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status';
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
import { generateUnifiedDiff, calculateHash } from '@/lib/utils/file-diff';
@@ -37,6 +36,7 @@ type TaskCenterPanelProps = {
openFileRequest: OpenFileTab | null;
onDiffHandled: () => void;
onFileOpenHandled: () => void;
onActiveFileChange?: (filePath: string | null) => void;
sessionId?: string | null;
};
@@ -45,6 +45,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
openFileRequest,
onDiffHandled,
onFileOpenHandled,
onActiveFileChange,
sessionId = null,
}: TaskCenterPanelProps) {
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
@@ -114,11 +115,17 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
return hasUncommittedChanges || hasCommits;
}, [gitStatus, commits]);
// Initialize tab from localStorage, defaulting to 'chat'
// If saved tab is 'changes' but there are no changes, fall back to 'chat'
// Initialize tab - restored from sessionStorage or default to 'chat'
const [leftTab, setLeftTab] = useState(() => {
const savedTab = getCenterPanelTab('chat');
return savedTab;
// Try to restore from session-specific storage on initial render
if (typeof window !== 'undefined' && activeSessionId) {
const savedTab = getActiveTabForSession(activeSessionId, 'chat');
// Only return non-file tabs synchronously, file tabs need async content loading
if (savedTab === 'chat' || savedTab === 'plan' || savedTab === 'changes') {
return savedTab;
}
}
return 'chat';
});
// If current tab is 'changes' but there are no changes, switch to 'chat'
@@ -143,8 +150,171 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(null);
const [openFileTabs, setOpenFileTabs] = useState<OpenFileTab[]>([]);
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
const restoredTabsRef = useRef<string | null>(null);
const restorationInProgressRef = useRef<boolean>(false);
const { toast } = useToast();
// Save current active tab for the current session before switching
const prevSessionRef = useRef<string | null>(null);
useEffect(() => {
// On unmount or before session change, save the current tab for the previous session
return () => {
if (prevSessionRef.current && leftTab) {
setActiveTabForSession(prevSessionRef.current, leftTab);
}
};
}, [leftTab]);
// Restore file tabs and active tab from sessionStorage on mount/session change
useEffect(() => {
if (!activeSessionId) return;
// When session changes, always clear existing tabs first and reset state
if (restoredTabsRef.current !== activeSessionId) {
// Save the active tab for the OLD session before switching
if (prevSessionRef.current && prevSessionRef.current !== activeSessionId) {
setActiveTabForSession(prevSessionRef.current, leftTab);
}
restoredTabsRef.current = activeSessionId;
prevSessionRef.current = activeSessionId;
restorationInProgressRef.current = false;
// Clear existing file tabs immediately when session changes
setOpenFileTabs([]);
} else if (restorationInProgressRef.current) {
// Already restoring for this session
return;
} else {
// Already restored for this session
return;
}
const savedTabs = getOpenFileTabs(activeSessionId);
const savedActiveTab = getActiveTabForSession(activeSessionId, 'chat');
// If no file tabs to load, just restore the active tab
if (savedTabs.length === 0) {
// Only set if it's a valid non-file tab
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
setLeftTab(savedActiveTab);
} else {
setLeftTab('chat');
}
return;
}
// Mark restoration as in progress
restorationInProgressRef.current = true;
// Load content for each saved tab with retry for WebSocket client
const loadTabs = async (retryCount = 0): Promise<void> => {
const maxRetries = 5;
const retryDelay = 200;
const client = getWebSocketClient();
if (!client) {
if (retryCount < maxRetries) {
setTimeout(() => loadTabs(retryCount + 1), retryDelay);
return;
}
restorationInProgressRef.current = false;
return;
}
// Verify we're still restoring for the same session
if (restoredTabsRef.current !== activeSessionId) {
restorationInProgressRef.current = false;
return;
}
const loadedTabs: OpenFileTab[] = [];
for (const savedTab of savedTabs) {
try {
const response = await requestFileContent(client, activeSessionId, savedTab.path);
const hash = await calculateHash(response.content);
loadedTabs.push({
path: savedTab.path,
name: savedTab.name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
});
} catch {
// Failed to restore tab, skip it
}
}
// Verify session hasn't changed
if (restoredTabsRef.current !== activeSessionId) {
restorationInProgressRef.current = false;
return;
}
if (loadedTabs.length > 0) {
setOpenFileTabs(loadedTabs);
// Restore active tab - check if it's a file tab that was loaded
if (savedActiveTab.startsWith('file:')) {
const filePath = savedActiveTab.replace('file:', '');
const tabExists = loadedTabs.some(t => t.path === filePath);
if (tabExists) {
// Use setTimeout to ensure React has processed setOpenFileTabs
setTimeout(() => {
setLeftTab(savedActiveTab);
restorationInProgressRef.current = false;
}, 0);
} else {
setLeftTab('chat');
restorationInProgressRef.current = false;
}
} else {
setLeftTab(savedActiveTab);
restorationInProgressRef.current = false;
}
} else {
// No tabs could be loaded, fall back to non-file tab
if (savedActiveTab === 'chat' || savedActiveTab === 'plan' || savedActiveTab === 'changes') {
setLeftTab(savedActiveTab);
} else {
setLeftTab('chat');
}
restorationInProgressRef.current = false;
}
};
void loadTabs();
// eslint-disable-next-line react-hooks/exhaustive-deps -- leftTab is intentionally excluded to prevent re-running on tab changes
}, [activeSessionId]);
// Persist open file tabs to sessionStorage when they change
useEffect(() => {
if (!activeSessionId) return;
const tabsToSave = openFileTabs.map(({ path, name }) => ({ path, name }));
saveOpenFileTabs(activeSessionId, tabsToSave);
}, [activeSessionId, openFileTabs]);
// Persist active tab whenever it changes (not just via handleTabChange)
useEffect(() => {
if (!activeSessionId) return;
// Skip during restoration to avoid overwriting with intermediate values
if (restorationInProgressRef.current) {
return;
}
setActiveTabForSession(activeSessionId, leftTab);
}, [activeSessionId, leftTab]);
// Notify parent when the active file changes
useEffect(() => {
if (leftTab.startsWith('file:')) {
const filePath = leftTab.replace('file:', '');
onActiveFileChange?.(filePath);
} else {
onActiveFileChange?.(null);
}
}, [leftTab, onActiveFileChange]);
// Track plan updates for notification dot
const plan = useAppStore((state) =>
activeTaskId ? state.taskPlans.byTaskId[activeTaskId] : null
@@ -165,11 +335,11 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
setPlanLastSeen(activeTaskId, plan.updated_at);
}
setLeftTab(tab);
// Persist tab selection (only for main tabs, not file tabs)
if (tab === 'plan' || tab === 'changes' || tab === 'chat') {
setCenterPanelTab(tab);
// Persist tab selection per-session (for all tabs including file tabs)
if (activeSessionId) {
setActiveTabForSession(activeSessionId, tab);
}
}, [activeTaskId, plan]);
}, [activeTaskId, activeSessionId, plan]);
// Handle external diff selection
useEffect(() => {
@@ -314,6 +484,33 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
}
}, [openFileTabs, activeSessionId, toast]);
// Handler for deleting file from editor
const handleFileDelete = useCallback(async (path: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
try {
const response = await deleteFile(client, activeSessionId, path);
if (response.success) {
// Close the tab
handleCloseFileTab(path);
} else {
toast({
title: 'Delete failed',
description: response.error || 'Failed to delete file',
variant: 'error',
});
}
} catch (error) {
console.error('Failed to delete file:', error);
toast({
title: 'Delete failed',
description: error instanceof Error ? error.message : 'An error occurred while deleting the file',
variant: 'error',
});
}
}, [activeSessionId, handleCloseFileTab, toast]);
const tabs: SessionTab[] = useMemo(() => {
const staticTabs: SessionTab[] = [
{
@@ -332,12 +529,10 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
];
const fileTabs: SessionTab[] = openFileTabs.map((tab) => {
const ext = tab.name.split('.').pop()?.toLowerCase() || '';
const dotColor = tab.isDirty ? 'bg-yellow-500' : (FILE_EXTENSION_COLORS[ext] || 'bg-muted-foreground');
return {
id: `file:${tab.path}`,
label: tab.isDirty ? `${tab.name} *` : tab.name,
icon: <span className={`h-2 w-2 rounded-full ${dotColor}`} />,
icon: tab.isDirty ? <span className="h-2 w-2 rounded-full bg-yellow-500" /> : undefined,
closable: true,
onClose: (e) => {
e.stopPropagation();
@@ -427,9 +622,9 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
onOpenFile={handleOpenFileFromChat}
showRequestChangesTooltip={showRequestChangesTooltip}
onRequestChangesTooltipDismiss={() => setShowRequestChangesTooltip(false)}
onSelectDiff={(path) => {
setSelectedDiff({ path });
setLeftTab('changes');
onOpenFileAtLine={(filePath) => {
// Open the file in editor tab (or switch to it)
handleOpenFileFromChat(filePath);
}}
/>
)
@@ -441,15 +636,18 @@ export const TaskCenterPanel = memo(function TaskCenterPanel({
</TabsContent>
{openFileTabs.map((tab) => (
<TabsContent key={tab.path} value={`file:${tab.path}`} className="mt-3 flex-1 min-h-0">
<TabsContent key={tab.path} value={`file:${tab.path}`} className="flex-1 min-h-0">
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={savingFiles.has(tab.path)}
sessionId={activeSessionId || undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => handleFileChange(tab.path, newContent)}
onSave={() => handleFileSave(tab.path)}
onDelete={() => handleFileDelete(tab.path)}
/>
</TabsContent>
))}
+4 -4
View File
@@ -24,8 +24,8 @@ type TaskChatPanelProps = {
onOpenFile?: (path: string) => void;
showRequestChangesTooltip?: boolean;
onRequestChangesTooltipDismiss?: () => void;
/** Callback to select a file in the changes panel (for jump-to-line) */
onSelectDiff?: (path: string) => void;
/** Callback to open a file at a specific line (for comment clicks) */
onOpenFileAtLine?: (filePath: string) => void;
};
export const TaskChatPanel = memo(function TaskChatPanel({
@@ -34,7 +34,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
onOpenFile,
showRequestChangesTooltip = false,
onRequestChangesTooltipDismiss,
onSelectDiff,
onOpenFileAtLine,
}: TaskChatPanelProps) {
const [planModeEnabled, setPlanModeEnabled] = useState(false);
const [isSending, setIsSending] = useState(false);
@@ -211,7 +211,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
pendingCommentsByFile={pendingCommentsByFile}
onRemoveCommentFile={handleRemoveCommentFile}
onRemoveComment={handleRemoveComment}
onCommentClick={onSelectDiff ? (comment) => onSelectDiff(comment.filePath) : undefined}
onCommentClick={onOpenFileAtLine ? (comment) => onOpenFileAtLine(comment.filePath) : undefined}
/>
</div>
</div>
+107 -12
View File
@@ -1,6 +1,6 @@
'use client';
import { memo, useMemo, useState, useCallback } from 'react';
import { memo, useMemo, useState, useCallback, useEffect, useRef } from 'react';
import {
IconArrowBackUp,
IconExternalLink,
@@ -11,6 +11,7 @@ import {
IconChevronRight,
IconGitCommit,
IconLoader2,
IconSearch,
} from '@tabler/icons-react';
import { TabsContent } from '@kandev/ui/tabs';
@@ -34,15 +35,23 @@ import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-sta
import { useSessionCommits } from '@/hooks/domains/session/use-session-commits';
import { useGitOperations } from '@/hooks/use-git-operations';
import { getWebSocketClient } from '@/lib/ws/connection';
import { deleteFile } from '@/lib/ws/workspace-files';
import type { FileInfo } from '@/lib/state/store';
import { FileBrowser } from '@/components/task/file-browser';
import { SessionTabs, type SessionTab } from '@/components/session-tabs';
import { useToast } from '@/components/toast-provider';
import type { OpenFileTab } from '@/lib/types/backend';
import {
getFilesPanelTab,
setFilesPanelTab,
hasUserSelectedFilesPanelTab,
setUserSelectedFilesPanelTab,
} from '@/lib/local-storage';
type TaskFilesPanelProps = {
onSelectDiff: (path: string, content?: string) => void;
onOpenFile: (file: OpenFileTab) => void;
activeFilePath?: string | null;
};
@@ -92,10 +101,11 @@ const splitPath = (path: string) => {
};
};
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }: TaskFilesPanelProps) {
const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile, activeFilePath }: TaskFilesPanelProps) {
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null);
@@ -103,6 +113,14 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
const gitOps = useGitOperations(activeSessionId ?? null);
const { toast } = useToast();
// Track if we've initialized the tab for this session
const hasInitializedTabRef = useRef<string | null>(null);
// Close search when switching tabs
useEffect(() => {
setIsSearchOpen(false);
}, [topTab]);
// State for commit diffs
const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
@@ -156,13 +174,7 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
try {
const result = await gitOps.discard([fileToDiscard]);
if (result.success) {
toast({
title: 'Changes discarded',
description: `Successfully discarded changes to ${fileToDiscard}`,
variant: 'success',
});
} else {
if (!result.success) {
toast({
title: 'Failed to discard changes',
description: result.error || 'An unknown error occurred',
@@ -181,6 +193,29 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
}
}, [fileToDiscard, gitOps, toast]);
// Handle delete file
const handleDeleteFile = useCallback(async (path: string) => {
const client = getWebSocketClient();
if (!client || !activeSessionId) return;
try {
const response = await deleteFile(client, activeSessionId, path);
if (!response.success) {
toast({
title: 'Failed to delete file',
description: response.error || 'An unknown error occurred',
variant: 'error',
});
}
} catch (error) {
toast({
title: 'Failed to delete file',
description: error instanceof Error ? error.message : 'An unknown error occurred',
variant: 'error',
});
}
}, [activeSessionId, toast]);
// Convert git status files to array for display
const changedFiles = useMemo(() => {
if (!gitStatus?.files || Object.keys(gitStatus.files).length === 0) {
@@ -196,6 +231,36 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
}));
}, [gitStatus]);
// Smart tab selection: restore user preference or auto-select based on changes
useEffect(() => {
if (!activeSessionId) return;
// Only initialize once per session
if (hasInitializedTabRef.current === activeSessionId) return;
hasInitializedTabRef.current = activeSessionId;
// If user has previously selected a tab for this session, restore it
if (hasUserSelectedFilesPanelTab(activeSessionId)) {
const savedTab = getFilesPanelTab(activeSessionId, 'diff');
setTopTab(savedTab);
return;
}
// Auto-select based on whether there are changes
const hasChanges = changedFiles.length > 0 || commits.length > 0;
const defaultTab = hasChanges ? 'diff' : 'files';
setTopTab(defaultTab);
}, [activeSessionId, changedFiles.length, commits.length]);
// Handle tab change - save preference and mark as user-selected
const handleTabChange = useCallback((tab: 'diff' | 'files') => {
setTopTab(tab);
if (activeSessionId) {
setFilesPanelTab(activeSessionId, tab);
setUserSelectedFilesPanelTab(activeSessionId);
}
}, [activeSessionId]);
const tabs: SessionTab[] = [
{
id: 'diff',
@@ -207,13 +272,36 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
},
];
// Contextual buttons based on active tab
const tabRightContent = useMemo(() => {
if (topTab === 'files') {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setIsSearchOpen(true)}
>
<IconSearch className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Search files</TooltipContent>
</Tooltip>
);
}
// No contextual buttons for diff tab (for now)
return null;
}, [topTab]);
return (
<SessionPanel borderSide="left">
<SessionTabs
tabs={tabs}
activeTab={topTab}
onTabChange={(value) => setTopTab(value as 'diff' | 'files')}
onTabChange={(value) => handleTabChange(value as 'diff' | 'files')}
className="flex-1 min-h-0"
rightContent={tabRightContent}
>
<TabsContent value="diff" className="flex-1 min-h-0">
<SessionPanelContent>
@@ -401,10 +489,17 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile }
</div>
</SessionPanelContent>
</TabsContent>
<TabsContent value="files" className="mt-3 flex-1 min-h-0">
<TabsContent value="files" className="flex-1 min-h-0">
<SessionPanelContent>
{activeSessionId ? (
<FileBrowser sessionId={activeSessionId} onOpenFile={onOpenFile} />
<FileBrowser
sessionId={activeSessionId}
onOpenFile={onOpenFile}
onDeleteFile={handleDeleteFile}
isSearchOpen={isSearchOpen}
onCloseSearch={() => setIsSearchOpen(false)}
activeFilePath={activeFilePath}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No task selected
+11 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { memo, useMemo } from 'react';
import { memo, useMemo, useState, useCallback } from 'react';
import { Group, Panel, type Layout } from 'react-resizable-panels';
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint';
@@ -67,6 +67,13 @@ export const TaskLayout = memo(function TaskLayout({
handleFileOpenHandled,
} = useSessionLayoutState({ sessionId });
// Track active file path for highlighting in file tree
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
const handleActiveFileChange = useCallback((filePath: string | null) => {
setActiveFilePath(filePath);
}, []);
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
const layoutState = useMemo(
() => layoutBySession[sessionKey] ?? DEFAULT_LAYOUT_STATE,
@@ -134,6 +141,7 @@ export const TaskLayout = memo(function TaskLayout({
<TaskFilesPanel
onSelectDiff={handleSelectDiff}
onOpenFile={handleOpenFile}
activeFilePath={activeFilePath}
/>
);
@@ -172,6 +180,7 @@ export const TaskLayout = memo(function TaskLayout({
openFileRequest={openFileRequest}
onDiffHandled={handleClearSelectedDiff}
onFileOpenHandled={handleFileOpenHandled}
onActiveFileChange={handleActiveFileChange}
/>
</Panel>
<Panel id="preview" minSize="470px" className="min-h-0 min-w-0">
@@ -186,6 +195,7 @@ export const TaskLayout = memo(function TaskLayout({
openFileRequest={openFileRequest}
onDiffHandled={handleClearSelectedDiff}
onFileOpenHandled={handleFileOpenHandled}
onActiveFileChange={handleActiveFileChange}
sessionId={sessionId}
/>
</Panel>
+55 -1
View File
@@ -2,8 +2,9 @@
import { memo, useState, useCallback, useEffect, useRef } from 'react';
import dynamic from 'next/dynamic';
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick } from '@tabler/icons-react';
import { IconLoader2, IconFileText, IconRobot, IconMessage, IconClick, IconMessagePlus } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { Button } from '@kandev/ui/button';
import { useTaskPlan } from '@/hooks/domains/session/use-task-plan';
import { useAppStore } from '@/components/state-provider';
import { getWebSocketClient } from '@/lib/ws/connection';
@@ -42,6 +43,9 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
const lastPlanContentRef = useRef<string | undefined>(undefined);
// Text selection state for the selection popover
const [textSelection, setTextSelection] = useState<TextSelection | null>(null);
// Floating button position (shown after selection ends)
const [floatingButtonPos, setFloatingButtonPos] = useState<{ x: number; y: number } | null>(null);
const [currentSelectionText, setCurrentSelectionText] = useState<string | null>(null);
// Plan comments state
const [comments, setComments] = useState<PlanComment[]>([]);
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
@@ -172,10 +176,35 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
const handleSelectionClose = useCallback(() => {
setTextSelection(null);
setEditingCommentId(null);
setFloatingButtonPos(null);
setCurrentSelectionText(null);
// Clear the browser selection
window.getSelection()?.removeAllRanges();
}, []);
// Handle selection end (mouseup) - show floating button
const handleSelectionEnd = useCallback((selection: TextSelection | null) => {
if (!activeSessionId) return;
if (selection) {
setFloatingButtonPos(selection.position);
setCurrentSelectionText(selection.text);
} else {
setFloatingButtonPos(null);
setCurrentSelectionText(null);
}
}, [activeSessionId]);
// Handle floating button click - open the popover
const handleFloatingButtonClick = useCallback(() => {
if (!floatingButtonPos || !currentSelectionText) return;
setTextSelection({
text: currentSelectionText,
position: floatingButtonPos,
});
setFloatingButtonPos(null);
setCurrentSelectionText(null);
}, [floatingButtonPos, currentSelectionText]);
// Handle clicking on a highlighted comment in the editor (opens edit popover)
const handleCommentHighlightClick = useCallback(
(id: string, position: { x: number; y: number }) => {
@@ -196,6 +225,11 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
setComments([]);
}, []);
// Handle deleting a single comment
const handleDeleteComment = useCallback((commentId: string) => {
setComments(prev => prev.filter(c => c.id !== commentId));
}, []);
// Handle submitting all comments to the agent
const handleSubmitComments = useCallback(async () => {
if (!taskId || !activeSessionId || comments.length === 0) return;
@@ -292,9 +326,28 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
onChange={setDraftContent}
placeholder="Start typing your plan..."
onSelectionChange={activeSessionId ? setTextSelection : undefined}
onSelectionEnd={activeSessionId ? handleSelectionEnd : undefined}
comments={commentHighlights}
onCommentClick={handleCommentHighlightClick}
/>
{/* Floating "Comment" button when text is selected */}
{floatingButtonPos && !textSelection && activeSessionId && (
<Button
size="sm"
variant="secondary"
className="floating-comment-btn fixed z-50 gap-1.5 shadow-lg animate-in fade-in-0 zoom-in-95 duration-100 cursor-pointer"
style={{
left: floatingButtonPos.x + 8,
top: floatingButtonPos.y + 8,
}}
onMouseDown={(e) => e.stopPropagation()}
onClick={handleFloatingButtonClick}
>
<IconMessagePlus className="h-3.5 w-3.5" />
Comment
</Button>
)}
{/* Rich empty state - shows when no content and editor not focused */}
{!isLoading && draftContent.trim() === '' && !isEditorFocused && (
<div
@@ -379,6 +432,7 @@ export const TaskPlanPanel = memo(function TaskPlanPanel({ taskId, visible = tru
onAdd={handleAddComment}
onClose={handleSelectionClose}
editingComment={editingCommentId ? comments.find(c => c.id === editingCommentId)?.comment : undefined}
onDelete={editingCommentId ? () => handleDeleteComment(editingCommentId) : undefined}
/>
)}
</div>
+212
View File
@@ -171,3 +171,215 @@ export function setCenterPanelTab(tab: string): void {
// Ignore write failures
}
}
// Internal storage keys for files panel (uses sessionStorage for per-tab persistence)
const FILES_PANEL_KEYS = {
TAB: 'kandev.filesPanel.tab',
USER_SELECTED: 'kandev.filesPanel.userSelected',
EXPANDED: 'kandev.filesPanel.expanded',
SCROLL: 'kandev.filesPanel.scroll',
} as const;
/**
* Get the saved files panel tab for a session
* @param sessionId - The session ID
* @param fallback - Default tab if not found
* @returns The saved tab ('diff' or 'files')
*/
export function getFilesPanelTab(sessionId: string, fallback: 'diff' | 'files'): 'diff' | 'files' {
if (typeof window === 'undefined') return fallback;
try {
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
const value = JSON.parse(raw) as string;
return value === 'diff' || value === 'files' ? value : fallback;
} catch {
return fallback;
}
}
/**
* Save the files panel tab for a session
* @param sessionId - The session ID
* @param tab - The tab to save ('diff' or 'files')
*/
export function setFilesPanelTab(sessionId: string, tab: 'diff' | 'files'): void {
if (typeof window === 'undefined') return;
try {
const key = `${FILES_PANEL_KEYS.TAB}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tab));
} catch {
// Ignore write failures
}
}
/**
* Check if user has explicitly selected a tab for this session
* @param sessionId - The session ID
* @returns true if user has made a selection
*/
export function hasUserSelectedFilesPanelTab(sessionId: string): boolean {
if (typeof window === 'undefined') return false;
try {
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
return window.sessionStorage.getItem(key) === 'true';
} catch {
return false;
}
}
/**
* Mark that user has explicitly selected a tab for this session
* @param sessionId - The session ID
*/
export function setUserSelectedFilesPanelTab(sessionId: string): void {
if (typeof window === 'undefined') return;
try {
const key = `${FILES_PANEL_KEYS.USER_SELECTED}.${sessionId}`;
window.sessionStorage.setItem(key, 'true');
} catch {
// Ignore write failures
}
}
/**
* Get the saved expanded paths for file browser
* @param sessionId - The session ID
* @returns Array of expanded folder paths
*/
export function getFilesPanelExpandedPaths(sessionId: string): string[] {
if (typeof window === 'undefined') return [];
try {
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return [];
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
/**
* Save the expanded paths for file browser
* @param sessionId - The session ID
* @param paths - Array of expanded folder paths
*/
export function setFilesPanelExpandedPaths(sessionId: string, paths: string[]): void {
if (typeof window === 'undefined') return;
try {
const key = `${FILES_PANEL_KEYS.EXPANDED}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(paths));
} catch {
// Ignore write failures
}
}
/**
* Get the saved scroll position for file browser
* @param sessionId - The session ID
* @returns The scroll position in pixels
*/
export function getFilesPanelScrollPosition(sessionId: string): number {
if (typeof window === 'undefined') return 0;
try {
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return 0;
return JSON.parse(raw) as number;
} catch {
return 0;
}
}
/**
* Save the scroll position for file browser
* @param sessionId - The session ID
* @param position - The scroll position in pixels
*/
export function setFilesPanelScrollPosition(sessionId: string, position: number): void {
if (typeof window === 'undefined') return;
try {
const key = `${FILES_PANEL_KEYS.SCROLL}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(position));
} catch {
// Ignore write failures
}
}
// Internal storage keys for open file tabs
const OPEN_FILES_KEY = 'kandev.openFiles';
const ACTIVE_TAB_KEY = 'kandev.activeTab';
/**
* Minimal tab info stored in sessionStorage (no content - reloaded on restore)
*/
export interface StoredFileTab {
path: string;
name: string;
}
/**
* Get the saved open file tabs for a session
* @param sessionId - The session ID
* @returns Array of stored file tabs (path and name only)
*/
export function getOpenFileTabs(sessionId: string): StoredFileTab[] {
if (typeof window === 'undefined') return [];
try {
const key = `${OPEN_FILES_KEY}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return [];
return JSON.parse(raw) as StoredFileTab[];
} catch {
return [];
}
}
/**
* Save the open file tabs for a session
* @param sessionId - The session ID
* @param tabs - Array of tabs (only path and name are stored)
*/
export function setOpenFileTabs(sessionId: string, tabs: StoredFileTab[]): void {
if (typeof window === 'undefined') return;
try {
const key = `${OPEN_FILES_KEY}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tabs));
} catch {
// Ignore write failures
}
}
/**
* Get the saved active tab for a session
* @param sessionId - The session ID
* @param fallback - Default tab if not found
* @returns The saved active tab id (e.g., 'chat', 'plan', 'file:/path/to/file')
*/
export function getActiveTabForSession(sessionId: string, fallback: string): string {
if (typeof window === 'undefined') return fallback;
try {
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
const raw = window.sessionStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as string;
} catch {
return fallback;
}
}
/**
* Save the active tab for a session
* @param sessionId - The session ID
* @param tabId - The tab id to save
*/
export function setActiveTabForSession(sessionId: string, tabId: string): void {
if (typeof window === 'undefined') return;
try {
const key = `${ACTIVE_TAB_KEY}.${sessionId}`;
window.sessionStorage.setItem(key, JSON.stringify(tabId));
} catch {
// Ignore write failures
}
}
+24
View File
@@ -74,3 +74,27 @@ export async function updateFileContent(
original_hash: originalHash,
});
}
/**
* File delete response from backend
*/
export type FileDeleteResponse = {
path: string;
success: boolean;
error?: string;
};
/**
* Delete a file from the workspace
*/
export async function deleteFile(
client: WebSocketClient,
sessionId: string,
path: string
): Promise<FileDeleteResponse> {
return client.request<FileDeleteResponse>('workspace.file.delete', {
session_id: sessionId,
path,
});
}
+1
View File
@@ -46,6 +46,7 @@
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.18",
"@uiw/codemirror-theme-github": "^4.25.4",
"@uiw/codemirror-theme-vscode": "^4.25.4",
"@uiw/react-codemirror": "^4.25.4",
"@xterm/addon-attach": "^0.12.0",