diff --git a/apps/backend/internal/agentctl/server/process/git.go b/apps/backend/internal/agentctl/server/process/git.go index 83dc992564..98cb1485f8 100644 --- a/apps/backend/internal/agentctl/server/process/git.go +++ b/apps/backend/internal/agentctl/server/process/git.go @@ -142,6 +142,26 @@ func (g *GitOperator) getCurrentBranch(ctx context.Context) (string, error) { return strings.TrimSpace(output), nil } +// getUpstreamRef returns the upstream tracking ref for the given branch, or "" if none is set. +func (g *GitOperator) getUpstreamRef(ctx context.Context, branch string) string { + output, err := g.runGitCommand(ctx, "rev-parse", "--abbrev-ref", "--symbolic-full-name", branch+"@{upstream}") + if err != nil { + return "" + } + return strings.TrimSpace(output) +} + +// getDefaultRemoteBranch returns "main" or "master", whichever exists on origin. +func (g *GitOperator) getDefaultRemoteBranch(ctx context.Context) string { + if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", "origin/main"); err == nil { + return "main" + } + if _, err := g.runGitCommand(ctx, "rev-parse", "--verify", "origin/master"); err == nil { + return "master" + } + return "" +} + // hasUncommittedChanges checks if there are uncommitted changes func (g *GitOperator) hasUncommittedChanges(ctx context.Context) (bool, error) { output, err := g.runGitCommand(ctx, "status", "--porcelain") @@ -192,11 +212,20 @@ func (g *GitOperator) Pull(ctx context.Context, rebase bool) (*GitOperationResul return result, nil } + // Use upstream branch if set, otherwise fall back to default (main/master). + // This handles local branches that haven't been pushed to the remote yet. + pullBranch := branch + if upstream := g.getUpstreamRef(ctx, branch); upstream == "" { + if defaultBranch := g.getDefaultRemoteBranch(ctx); defaultBranch != "" { + pullBranch = defaultBranch + } + } + var args []string if rebase { - args = []string{"pull", "--rebase", "origin", branch} + args = []string{"pull", "--rebase", "origin", pullBranch} } else { - args = []string{"pull", "origin", branch} + args = []string{"pull", "origin", pullBranch} } output, err := g.runGitCommand(ctx, args...) @@ -217,7 +246,7 @@ func (g *GitOperator) Pull(ctx context.Context, rebase bool) (*GitOperationResul } result.Success = true - g.logger.Info("pull completed", zap.String("branch", branch), zap.Bool("rebase", rebase)) + g.logger.Info("pull completed", zap.String("branch", pullBranch), zap.Bool("rebase", rebase)) return result, nil } diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index c96a7cbf2e..3531c779fa 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -27,6 +27,10 @@ func toolKindToMessageType(normalized *streams.NormalizedPayload) string { // Event handlers +func (s *Service) handleTaskDeleted(ctx context.Context, data watcher.TaskEventData) { + s.scheduler.RemoveTask(data.TaskID) +} + func (s *Service) handleACPSessionCreated(ctx context.Context, data watcher.ACPSessionEventData) { if data.SessionID == "" || data.ACPSessionID == "" { return diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index 28a496ff3f..35dd5fe3fd 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -315,6 +315,15 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio e.logger.Info("found resume token for session resumption", zap.String("task_id", task.ID), zap.String("session_id", session.ID)) + } else if startAgent && session.State == models.TaskSessionStateWaitingForInput { + // Fresh-start resume (no resume token): don't auto-prompt with the task description. + // The original prompt may be outdated. The agent boots idle; for agents that opt into + // HistoryContextInjection (e.g. Auggie), conversation history is injected on the + // user's first message via dispatchInitialPrompt → shouldInjectResumeContext. + req.TaskDescription = "" + e.logger.Info("fresh-start resume, clearing task description to avoid auto-prompt", + zap.String("task_id", task.ID), + zap.String("session_id", session.ID)) } } diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler.go b/apps/backend/internal/orchestrator/scheduler/scheduler.go index 7f7dcbb3ac..114331ec9f 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler.go @@ -4,6 +4,7 @@ package scheduler import ( "context" "errors" + "strings" "sync" "sync/atomic" "time" @@ -283,7 +284,15 @@ func (s *Scheduler) processTasks(ctx context.Context) { s.logger.Error("failed to update task state to IN_PROGRESS", zap.String("task_id", task.ID), zap.Error(err)) - // Re-enqueue the task + + // Don't re-enqueue if task was deleted from DB + if strings.Contains(err.Error(), "not found") { + s.logger.Warn("task no longer exists, dropping from queue", + zap.String("task_id", task.ID)) + continue + } + + // Re-enqueue for transient errors if enqErr := s.queue.Enqueue(task); enqErr != nil { s.logger.Error("failed to re-enqueue task after state update failure", zap.String("task_id", task.ID), diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 6166a4e4d3..24bdaf9a20 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -264,6 +264,7 @@ func NewService( // Create the watcher with event handlers that wire everything together handlers := watcher.EventHandlers{ + OnTaskDeleted: s.handleTaskDeleted, OnAgentRunning: s.handleAgentRunning, OnAgentReady: s.handleAgentReady, OnAgentCompleted: s.handleAgentCompleted, diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 2f72609c91..0fc31b749e 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -679,13 +679,14 @@ func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID st // 4. No resume token, but session may still be startable as a fresh session // if there's an ExecutorRunning record (worktree info) and session is in an active state. - // Don't set NeedsResume — agent will be launched lazily when the user sends their next message - // via ensureSessionRunning(). This avoids auto-starting agents that can't natively resume. + // NeedsResume triggers the frontend to auto-resume, which launches agentctl and the agent + // process (idle, no prompt). For agents with HistoryContextInjection, conversation history + // is injected into the user's first message. running, runErr := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) if runErr == nil && running != nil && isActiveSessionState(session.State) { resp.IsAgentRunning = false resp.IsResumable = true - resp.NeedsResume = false + resp.NeedsResume = true resp.ResumeReason = "agent_not_running_fresh_start" return resp, nil } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index e5ae169dc5..8ea4ef8661 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -123,6 +123,7 @@ type TaskDTO struct { PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` + PrimarySessionState *string `json:"primary_session_state,omitempty"` IsRemoteExecutor bool `json:"is_remote_executor,omitempty"` ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -451,7 +452,7 @@ func FromTask(task *models.Task) TaskDTO { // FromTaskWithPrimarySession converts a task model to a TaskDTO, including the primary session ID. func FromTaskWithPrimarySession(task *models.Task, primarySessionID *string) TaskDTO { - return FromTaskWithSessionInfo(task, primarySessionID, nil, nil, nil, nil, nil) + return FromTaskWithSessionInfo(task, primarySessionID, nil, nil, nil, nil, nil, nil) } // FromTaskWithSessionInfo converts a task model to a TaskDTO, including session information. @@ -463,6 +464,7 @@ func FromTaskWithSessionInfo( primaryExecutorID *string, primaryExecutorType *string, primaryExecutorName *string, + primarySessionState *string, ) TaskDTO { // Convert repositories var repositories []TaskRepositoryDTO @@ -496,6 +498,7 @@ func FromTaskWithSessionInfo( PrimaryExecutorID: primaryExecutorID, PrimaryExecutorType: primaryExecutorType, PrimaryExecutorName: primaryExecutorName, + PrimarySessionState: primarySessionState, IsRemoteExecutor: primaryExecutorType != nil && (*primaryExecutorType == string(models.ExecutorTypeSprites) || *primaryExecutorType == string(models.ExecutorTypeRemoteDocker)), ArchivedAt: task.ArchivedAt, CreatedAt: task.CreatedAt, diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 7fd0964af0..78b2042855 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -103,40 +103,54 @@ func buildTaskDTOsWithSessionInfo(ctx context.Context, svc *service.Service, tas if count, ok := sessionCountMap[task.ID]; ok { sessionCount = &count } - var reviewStatus *string - var primaryExecutorID *string - var primaryExecutorType *string - var primaryExecutorName *string - if sessionInfo, ok := primarySessionInfoMap[task.ID]; ok && sessionInfo.ReviewStatus != nil { - reviewStatus = sessionInfo.ReviewStatus - if sessionInfo.ExecutorID != "" { - val := sessionInfo.ExecutorID - primaryExecutorID = &val - } - if sessionInfo.ExecutorSnapshot != nil { - if execType, ok := sessionInfo.ExecutorSnapshot["executor_type"].(string); ok && execType != "" { - val := execType - primaryExecutorType = &val - } - if execName, ok := sessionInfo.ExecutorSnapshot["executor_name"].(string); ok && execName != "" { - val := execName - primaryExecutorName = &val - } - } - } + si := extractSessionInfo(primarySessionInfoMap[task.ID]) result = append(result, dto.FromTaskWithSessionInfo( task, primarySessionID, sessionCount, - reviewStatus, - primaryExecutorID, - primaryExecutorType, - primaryExecutorName, + si.reviewStatus, + si.executorID, + si.executorType, + si.executorName, + si.sessionState, )) } return result, nil } +type sessionInfoFields struct { + reviewStatus *string + sessionState *string + executorID *string + executorType *string + executorName *string +} + +func extractSessionInfo(info *models.TaskSession) sessionInfoFields { + var si sessionInfoFields + if info == nil { + return si + } + si.reviewStatus = info.ReviewStatus + if info.State != "" { + val := string(info.State) + si.sessionState = &val + } + if info.ExecutorID != "" { + val := info.ExecutorID + si.executorID = &val + } + if info.ExecutorSnapshot != nil { + if t, ok := info.ExecutorSnapshot["executor_type"].(string); ok && t != "" { + si.executorType = &t + } + if n, ok := info.ExecutorSnapshot["executor_name"].(string); ok && n != "" { + si.executorName = &n + } + } + return si +} + func (h *TaskHandlers) toTaskDTOsWithSessionInfo(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) { return buildTaskDTOsWithSessionInfo(ctx, h.service, tasks) } diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 960081e458..55bc0b6c29 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -881,7 +881,7 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs } query := fmt.Sprintf(` - SELECT ts.task_id, ts.review_status, ts.executor_id, e.type, e.name + SELECT ts.task_id, ts.review_status, ts.executor_id, ts.state, e.type, e.name FROM task_sessions ts LEFT JOIN executors e ON e.id = ts.executor_id WHERE ts.task_id IN (%s) AND ts.is_primary = 1 @@ -898,14 +898,18 @@ func (r *Repository) GetPrimarySessionInfoByTaskIDs(ctx context.Context, taskIDs var taskID string var reviewStatus sql.NullString var executorID sql.NullString + var sessionState sql.NullString var executorType sql.NullString var executorName sql.NullString - if err := rows.Scan(&taskID, &reviewStatus, &executorID, &executorType, &executorName); err != nil { + if err := rows.Scan(&taskID, &reviewStatus, &executorID, &sessionState, &executorType, &executorName); err != nil { return nil, err } session := &models.TaskSession{ TaskID: taskID, } + if sessionState.Valid { + session.State = models.TaskSessionState(sessionState.String) + } if reviewStatus.Valid { session.ReviewStatus = &reviewStatus.String } diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 3970c29dd5..8d3e3a5d43 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -47,6 +47,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * if sessionInfo.ReviewStatus != nil { data["review_status"] = *sessionInfo.ReviewStatus } + if sessionInfo.State != "" { + data["primary_session_state"] = string(sessionInfo.State) + } } } diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 1ea511f32c..8b3ba6236c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -242,9 +242,6 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-virtual': - specifier: ^3.13.18 - version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tiptap/core': specifier: ^3.19.0 version: 3.19.0(@tiptap/pm@3.19.0) @@ -410,6 +407,9 @@ importers: react-resizable-panels: specifier: ^4.4.1 version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-virtuoso: + specifier: ^4.18.1 + version: 4.18.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) recharts: specifier: 2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2577,19 +2577,10 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.18': - resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.18': - resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} - '@tiptap/core@3.19.0': resolution: {integrity: sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==} peerDependencies: @@ -5705,6 +5696,12 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-virtuoso@4.18.1: + resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -8621,16 +8618,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@tanstack/virtual-core': 3.13.18 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.18': {} - '@tiptap/core@3.19.0(@tiptap/pm@3.19.0)': dependencies: '@tiptap/pm': 3.19.0 @@ -12338,6 +12327,11 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + react-virtuoso@4.18.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} recast@0.23.11: diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d828ca48e6..c8a9c479cc 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -14,7 +14,9 @@ --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-sans); - --font-mono: var(--font-geist-mono); + --font-mono: + "Monaspace Neon", "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --font-mono-size: 12px; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -744,11 +746,19 @@ } /* Base text styling */ -.tiptap-plan-wrapper .tiptap, -.tiptap-plan-wrapper .tiptap *:not(.mermaid-container):not(.mermaid-container *) { - font-size: 0.75rem !important; - line-height: 1.625; +.tiptap-plan-wrapper .tiptap { color: var(--foreground); + font-family: + -apple-system, "system-ui", "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; +} + +.tiptap-plan-wrapper .tiptap, +.tiptap-plan-wrapper + .tiptap + *:not(.mermaid-container):not(.mermaid-container *):not(pre code):not(pre code *) { + font-size: 13px !important; + line-height: 1.625; } /* Headings */ @@ -758,42 +768,49 @@ .tiptap-plan-wrapper .tiptap h4 { font-weight: 700 !important; color: var(--foreground) !important; - margin-top: 0.5rem !important; - margin-bottom: 0.25rem !important; + margin-top: 1.25rem !important; + margin-bottom: 0.375rem !important; line-height: 1.3 !important; } +.tiptap-plan-wrapper .tiptap :is(h1, h2, h3, h4):first-child { + margin-top: 0 !important; +} + .tiptap-plan-wrapper .tiptap h1:not(.mermaid-container):not(.mermaid-container *) { - font-size: 1.125rem !important; + font-size: 1.25rem !important; } .tiptap-plan-wrapper .tiptap h2:not(.mermaid-container):not(.mermaid-container *) { - font-size: 0.9375rem !important; + font-size: 1.0625rem !important; } .tiptap-plan-wrapper .tiptap h3:not(.mermaid-container):not(.mermaid-container *) { - font-size: 0.8125rem !important; + font-size: 0.9375rem !important; } /* Paragraphs */ .tiptap-plan-wrapper .tiptap p { - margin-top: 0.5rem !important; - margin-bottom: 0.5rem !important; line-height: 1.625 !important; - font-size: 0.75rem !important; + font-size: 13px !important; } /* Lists */ .tiptap-plan-wrapper .tiptap ul { list-style-type: disc !important; padding-left: 1.25rem !important; - margin-top: 0.375rem !important; - margin-bottom: 0.375rem !important; + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.tiptap-plan-wrapper .tiptap li > ul { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; } .tiptap-plan-wrapper .tiptap ol { list-style-type: decimal !important; padding-left: 1.25rem !important; - margin-top: 0.375rem !important; - margin-bottom: 0.375rem !important; + margin-top: 1rem !important; + margin-bottom: 1rem !important; } .tiptap-plan-wrapper .tiptap li { @@ -817,13 +834,13 @@ margin-top: 0.125rem; } -/* Inline code */ -.tiptap-plan-wrapper .tiptap code { - background: rgb(59 130 246 / 0.2) !important; - color: rgb(96 165 250) !important; +/* Inline code (not code blocks) */ +.tiptap-plan-wrapper .tiptap :not(pre) > code { + background: var(--muted) !important; + color: var(--foreground) !important; padding: 0.125rem 0.375rem !important; border-radius: 0.25rem !important; - font-size: 0.675rem !important; + font-size: 0.85em !important; font-family: var(--font-mono) !important; } @@ -843,14 +860,14 @@ .tiptap-plan-wrapper .tiptap pre code { background: transparent !important; - color: #d4d4d4 !important; + color: #d4d4d4; padding: 0 !important; - font-size: 0.625rem !important; + font-size: var(--font-mono-size) !important; font-family: var(--font-mono) !important; } .tiptap-plan-wrapper:not(.dark) .tiptap pre code { - color: #1e1e1e !important; + color: #1e1e1e; } /* Blockquote */ @@ -875,21 +892,21 @@ .tiptap-plan-wrapper .tiptap hr { border: none !important; border-top: 1px solid var(--border) !important; - margin: 1rem 0 !important; + margin: 1.5rem 0 !important; } /* Tables */ .tiptap-plan-wrapper .tiptap table { border-collapse: collapse; width: 100%; - margin: 0.5rem 0; + margin: 1.5rem 0; } .tiptap-plan-wrapper .tiptap th, .tiptap-plan-wrapper .tiptap td { border: 1px solid var(--border); padding: 0.25rem 0.5rem; - font-size: 0.75rem; + font-size: 13px; } .tiptap-plan-wrapper .tiptap th { @@ -1003,7 +1020,7 @@ opacity: 0.5; pointer-events: none; height: 0; - font-size: 0.75rem; + font-size: 13px; } /* Monaco Editor - Diff gutter indicators (colors from --git-addition / --git-deletion CSS vars) */ @@ -1069,15 +1086,15 @@ font-size: 11px; } -/* Match references list font size to editor font size (12px) */ +/* Match references list font size to editor font size */ .monaco-editor .reference-zone-widget .ref-tree .monaco-list-row { - font-size: 12px !important; + font-size: var(--font-mono-size) !important; height: 20px !important; line-height: 20px !important; } .monaco-editor .reference-zone-widget .ref-tree .reference-file { - font-size: 12px !important; + font-size: var(--font-mono-size) !important; height: 22px !important; line-height: 22px !important; } @@ -1159,7 +1176,7 @@ margin: 0; padding: 8px 12px; overflow-x: auto; - font-size: 12px; + font-size: var(--font-mono-size); line-height: 1.5; background-color: var(--background) !important; } diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index 91863c1958..e57dc55f1c 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -278,7 +278,7 @@ export function TipTapPlanEditor({ immediatelyRender: false, extensions, content: value, - editorProps: { attributes: { class: "tiptap-plan-editor" } }, + editorProps: { attributes: { class: "tiptap-plan-editor", spellcheck: "false" } }, onUpdate: ({ editor: ed }) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const md = (ed.storage as any).markdown?.getMarkdown?.() as string | undefined; diff --git a/apps/web/components/review/review-diff-list.tsx b/apps/web/components/review/review-diff-list.tsx index 2b1e240453..2df81aae6c 100644 --- a/apps/web/components/review/review-diff-list.tsx +++ b/apps/web/components/review/review-diff-list.tsx @@ -258,11 +258,9 @@ function FileDiffSection({ }: FileDiffSectionProps) { const [collapsed, setCollapsed] = useState(false); const handleToggleCollapse = useCallback(() => setCollapsed((v) => !v), []); - useEffect(() => { - setCollapsed(isReviewed); - }, [isReviewed]); // Force-expand when the user explicitly navigates to this file useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncing collapsed state from parent selection prop if (isSelected) setCollapsed(false); }, [isSelected]); const { isVisible, sentinelRef } = useLazyVisible(scrollContainer); diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index 20365ea874..231774fb9a 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -11,7 +11,6 @@ import { type ChatInputContainerHandle, type MessageAttachment, } from "@/components/task/chat/chat-input-container"; -import { type QueuedMessageIndicatorHandle } from "@/components/task/chat/queued-message-indicator"; import { formatReviewCommentsAsMarkdown, formatPRFeedbackAsMarkdown, @@ -243,7 +242,6 @@ After completing the implementation, provide a summary of what was done. type ChatInputAreaProps = { chatInputRef: React.RefObject; - queuedMessageRef: React.RefObject; clarificationKey: number; onClarificationResolved: () => void; handleSubmit: ( @@ -253,8 +251,6 @@ type ChatInputAreaProps = { inlineMentions?: ContextFile[], ) => Promise; handleCancelTurn: () => Promise; - handleCancelQueue: () => Promise; - handleQueueEditComplete: () => void; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss?: () => void; isPanelFocused?: boolean; @@ -264,13 +260,10 @@ type ChatInputAreaProps = { export function ChatInputArea({ chatInputRef, - queuedMessageRef, clarificationKey, onClarificationResolved, handleSubmit, handleCancelTurn, - handleCancelQueue, - handleQueueEditComplete, showRequestChangesTooltip, onRequestChangesTooltipDismiss, isPanelFocused, @@ -294,9 +287,6 @@ export function ChatInputArea({ pendingCommentsByFile, chatSubmitKey, agentCommands, - isQueued, - queuedMessage, - updateQueueContent, contextItems, planContextEnabled, todoItems, @@ -345,18 +335,12 @@ export function ChatInputArea({ submitKey={chatSubmitKey} hasAgentCommands={!!(agentCommands && agentCommands.length > 0)} isFailed={isFailed} - isQueued={isQueued} contextItems={contextItems} planContextEnabled={planContextEnabled} contextFiles={contextFiles} onToggleContextFile={handleToggleContextFile} onAddContextFile={handleAddContextFile} todoItems={todoItems} - queuedMessage={queuedMessage?.content} - onCancelQueue={handleCancelQueue} - updateQueueContent={updateQueueContent} - queuedMessageRef={queuedMessageRef} - onQueueEditComplete={handleQueueEditComplete} isPanelFocused={isPanelFocused} onImplementPlan={handleImplementPlan} /> diff --git a/apps/web/components/task/chat/chat-input-body.tsx b/apps/web/components/task/chat/chat-input-body.tsx index 525aa87ec6..d583d4f65a 100644 --- a/apps/web/components/task/chat/chat-input-body.tsx +++ b/apps/web/components/task/chat/chat-input-body.tsx @@ -6,10 +6,6 @@ import { TipTapInput } from "./tiptap-input"; import { ChatInputFocusHint } from "./chat-input-focus-hint"; import { ResizeHandle } from "./resize-handle"; import { TodoSummary } from "./todo-summary"; -import { - QueuedMessageIndicator, - type QueuedMessageIndicatorHandle, -} from "./queued-message-indicator"; import { ChatInputToolbar } from "./chat-input-toolbar"; import { ContextZone } from "./context-items/context-zone"; import type { ContextItem } from "@/lib/types/context"; @@ -146,12 +142,6 @@ export type ChatInputContextAreaProps = { hasContextZone: boolean; allItems: ContextItem[]; sessionId: string | null; - hasQueuedMessage: boolean; - queuedMessage?: string | null; - onCancelQueue?: () => void; - updateQueueContent?: (content: string) => Promise; - queuedMessageRef?: React.RefObject; - onQueueEditComplete?: () => void; hasTodos: boolean; todoItems: TodoItem[]; }; @@ -160,30 +150,12 @@ export function ChatInputContextArea({ hasContextZone, allItems, sessionId, - hasQueuedMessage, - queuedMessage, - onCancelQueue, - updateQueueContent, - queuedMessageRef, - onQueueEditComplete, hasTodos, todoItems, }: ChatInputContextAreaProps) { if (!hasContextZone) return null; - const queueSlot = hasQueuedMessage ? ( - - ) : undefined; const todoSlot = hasTodos ? : undefined; - return ( - - ); + return ; } export type ChatInputBodyProps = { @@ -217,7 +189,6 @@ export function ChatInputBody({ contextAreaProps, editorAreaProps, }: ChatInputBodyProps) { - console.log("[ChatInputBody] planModeEnabled:", planModeEnabled); return (
void; onAddContextFile?: (file: ContextFile) => void; todoItems?: TodoItem[]; - queuedMessage?: string | null; - onCancelQueue?: () => void; - updateQueueContent?: (content: string) => Promise; - queuedMessageRef?: React.RefObject; - onQueueEditComplete?: () => void; isPanelFocused?: boolean; onImplementPlan?: () => void; }; @@ -139,12 +132,6 @@ function buildContextAreaProps( hasContextZone: s.hasContextZone, allItems: s.allItems, sessionId: p.sessionId, - hasQueuedMessage: s.hasQueuedMessage, - queuedMessage: p.queuedMessage, - onCancelQueue: p.onCancelQueue, - updateQueueContent: p.updateQueueContent, - queuedMessageRef: p.queuedMessageRef, - onQueueEditComplete: p.onQueueEditComplete, hasTodos: s.hasTodos, todoItems: p.todoItems ?? [], }; @@ -207,7 +194,6 @@ export const ChatInputContainer = forwardRef 0; +export function ContextZone({ items, sessionId, todoSlot }: ContextZoneProps) { + const hasContent = !!todoSlot || items.length > 0; if (!hasContent) return null; return (
- {queueSlot} {todoSlot} {items.length > 0 && (
diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx new file mode 100644 index 0000000000..167cc6c78a --- /dev/null +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, memo } from "react"; +import { SessionPanelContent } from "@kandev/ui/pannel-session"; +import type { Message } from "@/lib/types/http"; +import { AgentStatus } from "@/components/task/chat/messages/agent-status"; +import { PrepareProgress } from "@/components/session/prepare-progress"; +import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; +import { + type MessageListProps, + MessageListStatus, + MessageItem, + getItemKey, + getSessionRunningState, + getLastTurnGroupId, +} from "./message-list-shared"; + +/** + * Continuously captures scroll state via scroll listener. + * On prepend (itemCount increases), restores scroll position so the user + * stays at the same visual spot. + */ +function useScrollPositionOnPrepend( + scrollRef: React.RefObject, + itemCount: number, +) { + const scrollState = useRef({ scrollHeight: 0, scrollTop: 0 }); + const prevItemCount = useRef(itemCount); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => { + scrollState.current.scrollHeight = el.scrollHeight; + scrollState.current.scrollTop = el.scrollTop; + }; + onScroll(); + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollRef]); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el || itemCount <= prevItemCount.current) { + prevItemCount.current = itemCount; + return; + } + const prev = scrollState.current; + const delta = el.scrollHeight - prev.scrollHeight; + if (delta > 0) { + el.scrollTop = prev.scrollTop + delta; + } + prevItemCount.current = itemCount; + }, [itemCount, scrollRef]); +} + +/** + * Observes a sentinel element at the top of the list to trigger lazy loading. + * Uses a callback ref so the observer reconnects when the sentinel remounts. + * + * Handles the timing issue where the sentinel DOM node mounts (callback ref fires) + * before the useEffect creates the IntersectionObserver. The sentinelNodeRef bridges + * the gap: the callback ref stores the node, and the effect observes it if present. + */ +function useLazyLoadSentinel( + scrollRef: React.RefObject, + hasMore: boolean, + isLoadingMore: boolean, + loadMore: () => Promise, +) { + const stateRef = useRef({ hasMore, isLoadingMore }); + useEffect(() => { + stateRef.current = { hasMore, isLoadingMore }; + }, [hasMore, isLoadingMore]); + + const observerRef = useRef(null); + const sentinelNodeRef = useRef(null); + + // Create/destroy observer when scroll container changes + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const observer = new IntersectionObserver( + (entries) => { + const { hasMore, isLoadingMore } = stateRef.current; + const isIntersecting = entries[0]?.isIntersecting; + if (isIntersecting && hasMore && !isLoadingMore) { + loadMore(); + } + }, + { root, rootMargin: "200px 0px 0px 0px" }, + ); + observerRef.current = observer; + // If sentinel already mounted before this effect ran, observe it now + if (sentinelNodeRef.current) { + observer.observe(sentinelNodeRef.current); + } + return () => { + observer.disconnect(); + observerRef.current = null; + }; + }, [scrollRef, loadMore]); + + // Callback ref — stores node and observes if observer already exists + const sentinelRef = useCallback((node: HTMLDivElement | null) => { + sentinelNodeRef.current = node; + const observer = observerRef.current; + if (observer) { + observer.disconnect(); + if (node) { + observer.observe(node); + } + } + }, []); + + return sentinelRef; +} + +/** + * Auto-scrolls to bottom when new messages arrive (if user is near bottom) + * or when the agent starts working (isWorking transitions to true). + */ +function useAutoScroll( + scrollRef: React.RefObject, + messages: Message[], + isWorking: boolean, +) { + const isNearBottomRef = useRef(true); + const prevIsWorkingRef = useRef(isWorking); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onScroll = () => { + isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 100; + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => el.removeEventListener("scroll", onScroll); + }, [scrollRef]); + + // When isWorking transitions to true, force scroll to bottom + useEffect(() => { + if (isWorking && !prevIsWorkingRef.current) { + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + isNearBottomRef.current = true; + } + } + prevIsWorkingRef.current = isWorking; + }, [isWorking, scrollRef]); + + // Auto-scroll on new messages if near bottom + useLayoutEffect(() => { + const el = scrollRef.current; + if (el && isNearBottomRef.current) { + el.scrollTop = el.scrollHeight; + } + }, [messages, scrollRef]); +} + +function useScrollToMessage() { + return useCallback((messageId: string) => { + const el = document.getElementById(`msg-${messageId}`); + el?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, []); +} + +export const NativeMessageList = memo(function NativeMessageList({ + items, + messages, + permissionsByToolCallId, + childrenByParentToolCallId, + taskId, + sessionId, + messagesLoading, + isWorking, + sessionState, + worktreePath, + onOpenFile, +}: MessageListProps) { + const scrollRef = useRef(null); + + const isInitialLoading = messagesLoading && messages.length === 0; + const isNonLoadableSession = + !sessionState || ["CREATED", "FAILED", "COMPLETED", "CANCELLED"].includes(sessionState); + const showLoadingState = isInitialLoading && !isWorking && !isNonLoadableSession; + const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); + const isRunning = getSessionRunningState(sessionState); + const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); + const handleScrollToMessage = useScrollToMessage(); + + useScrollPositionOnPrepend(scrollRef, items.length); + const sentinelRef = useLazyLoadSentinel(scrollRef, hasMore, isLoadingMore, loadMore); + useAutoScroll(scrollRef, messages, isWorking); + + // Scroll to bottom on initial load + const didInitialScroll = useRef(false); + useEffect(() => { + if (didInitialScroll.current || items.length === 0) return; + const el = scrollRef.current; + if (el) { + el.scrollTop = el.scrollHeight; + didInitialScroll.current = true; + } + }, [items.length]); + + return ( + + {/* Sentinel for lazy loading older messages */} + {hasMore &&
} + + + + {items.map((item) => { + const key = getItemKey(item); + return ( +
+ +
+ ); + })} + + {sessionId && } + + + {/* Bottom anchor — browser keeps scroll pinned here when new content appends */} +
+ + ); +}); diff --git a/apps/web/components/task/chat/message-list-shared.tsx b/apps/web/components/task/chat/message-list-shared.tsx new file mode 100644 index 0000000000..274c4fa091 --- /dev/null +++ b/apps/web/components/task/chat/message-list-shared.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { GridSpinner } from "@/components/grid-spinner"; +import type { Message, TaskSessionState } from "@/lib/types/http"; +import type { RenderItem } from "@/hooks/use-processed-messages"; +import { MessageRenderer } from "@/components/task/chat/message-renderer"; +import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-message"; + +export type MessageListProps = { + items: RenderItem[]; + messages: Message[]; + permissionsByToolCallId: Map; + childrenByParentToolCallId: Map; + taskId?: string; + sessionId: string | null; + messagesLoading: boolean; + isWorking: boolean; + sessionState?: TaskSessionState; + worktreePath?: string; + onOpenFile?: (path: string) => void; +}; + +export function getItemKey(item: RenderItem): string { + return item.type === "turn_group" ? item.id : item.message.id; +} + +export function getSessionRunningState(sessionState: string | null | undefined) { + return sessionState === "CREATED" || sessionState === "STARTING" || sessionState === "RUNNING"; +} + +export function getLastTurnGroupId(items: RenderItem[]) { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item.type === "turn_group") return item.id; + } + return null; +} + +export function MessageListStatus({ + isLoadingMore, + hasMore, + showLoadingState, + messagesLoading, + isInitialLoading, + messagesCount, +}: { + isLoadingMore: boolean; + hasMore: boolean; + showLoadingState: boolean; + messagesLoading: boolean; + isInitialLoading: boolean; + messagesCount: number; +}) { + return ( + <> + {isLoadingMore && hasMore && ( +
+ Loading older messages... +
+ )} + {showLoadingState && ( +
+ + Loading messages... +
+ )} + {!messagesLoading && !isInitialLoading && messagesCount === 0 && ( +
+ No messages yet. Start the conversation! +
+ )} + + ); +} + +export function MessageItem({ + item, + sessionId, + permissionsByToolCallId, + childrenByParentToolCallId, + taskId, + worktreePath, + onOpenFile, + isLastGroup, + isTurnActive, + messages, + sessionState, + onScrollToMessage, +}: { + item: RenderItem; + sessionId: string | null; + permissionsByToolCallId: Map; + childrenByParentToolCallId: Map; + taskId?: string; + worktreePath?: string; + onOpenFile?: (path: string) => void; + isLastGroup: boolean; + isTurnActive: boolean; + messages: Message[]; + sessionState?: TaskSessionState; + onScrollToMessage: (id: string) => void; +}) { + if (item.type === "turn_group") { + return ( + + ); + } + return ( + + ); +} diff --git a/apps/web/components/task/chat/message-list-virtuoso.tsx b/apps/web/components/task/chat/message-list-virtuoso.tsx new file mode 100644 index 0000000000..7715d83672 --- /dev/null +++ b/apps/web/components/task/chat/message-list-virtuoso.tsx @@ -0,0 +1,280 @@ +"use client"; + +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; +import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; +import { SessionPanelContent } from "@kandev/ui/pannel-session"; +import type { RenderItem } from "@/hooks/use-processed-messages"; +import { AgentStatus } from "@/components/task/chat/messages/agent-status"; +import { PrepareProgress } from "@/components/session/prepare-progress"; +import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; +import { + type MessageListProps, + MessageListStatus, + MessageItem, + getItemKey, + getSessionRunningState, + getLastTurnGroupId, +} from "./message-list-shared"; + +const FIRST_INDEX_BASE = 100_000; + +type VirtuosoBodyProps = MessageListProps & { + scrollParent: HTMLDivElement; + isRunning: boolean; + lastTurnGroupId: string | null; + hasMore: boolean; + isLoadingMore: boolean; + loadMore: () => Promise; + Header: () => React.ReactNode; + Footer: () => React.ReactNode; +}; + +function computeFirstItemIndex(prevKeys: string[], prevIndex: number, keys: string[]): number { + if (prevKeys.length > 0 && keys.length > prevKeys.length) { + const oldFirstKey = prevKeys[0]; + const newPos = keys.indexOf(oldFirstKey); + if (newPos > 0) return prevIndex - newPos; + if (newPos === -1) { + for (let i = 0; i < prevKeys.length; i++) { + const idx = keys.indexOf(prevKeys[i]); + if (idx >= 0) return prevIndex - (idx - i); + } + } + return prevIndex; + } + if (prevKeys.length === 0 && keys.length > 0) { + return FIRST_INDEX_BASE - keys.length + 1; + } + return prevIndex; +} + +type IndexState = { keys: string[]; firstItemIndex: number }; + +function useStableFirstItemIndex(items: RenderItem[]) { + const keys = useMemo(() => items.map(getItemKey), [items]); + + const [state, setState] = useState(() => ({ + keys, + firstItemIndex: FIRST_INDEX_BASE - keys.length + 1, + })); + + if (keys !== state.keys) { + const nextIndex = computeFirstItemIndex(state.keys, state.firstItemIndex, keys); + setState({ keys, firstItemIndex: nextIndex }); + return nextIndex; + } + + return state.firstItemIndex; +} + +function useVirtuosoCallbacks(props: VirtuosoBodyProps) { + const { items, sessionId, permissionsByToolCallId, childrenByParentToolCallId, taskId } = props; + const { worktreePath, onOpenFile, lastTurnGroupId, isRunning, messages, sessionState } = props; + const { hasMore, isLoadingMore, loadMore } = props; + const virtuosoRef = useRef(null); + const itemCount = items.length; + const firstItemIndex = useStableFirstItemIndex(items); + + const loadCooldownRef = useRef(false); + const handleStartReached = useCallback(() => { + if (hasMore && !isLoadingMore && !loadCooldownRef.current) { + loadCooldownRef.current = true; + loadMore().finally(() => { + setTimeout(() => { + loadCooldownRef.current = false; + }, 500); + }); + } + }, [hasMore, isLoadingMore, loadMore]); + + const handleScrollToMessage = useCallback( + (messageId: string) => { + const idx = items.findIndex((item) => { + if (item.type === "turn_group") return item.messages.some((m) => m.id === messageId); + return item.message?.id === messageId; + }); + if (idx >= 0) + virtuosoRef.current?.scrollToIndex({ index: firstItemIndex + idx, align: "center" }); + }, + [items, firstItemIndex], + ); + + const computeItemKey = useCallback( + (index: number) => { + const item = items[index - firstItemIndex]; + if (!item) return index; + return getItemKey(item); + }, + [items, firstItemIndex], + ); + + const renderItem = useCallback( + (index: number) => { + const item = items[index - firstItemIndex]; + if (!item) return
; + + return ( +
+ +
+ ); + }, + [ + items, + firstItemIndex, + sessionId, + permissionsByToolCallId, + childrenByParentToolCallId, + taskId, + worktreePath, + onOpenFile, + lastTurnGroupId, + isRunning, + messages, + sessionState, + handleScrollToMessage, + ], + ); + + return { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem }; +} + +const FOLLOW_SMOOTH = "smooth" as const; +const followOutput = (isAtBottom: boolean) => (isAtBottom ? FOLLOW_SMOOTH : false); + +function VirtuosoBody(props: VirtuosoBodyProps) { + const { scrollParent, Header, Footer } = props; + const { virtuosoRef, itemCount, firstItemIndex, handleStartReached, computeItemKey, renderItem } = + useVirtuosoCallbacks(props); + + return ( + + ); +} + +/** Defer providing scroll parent to Virtuoso until the element has non-zero size. */ +function useVisibleScrollParent() { + const [scrollParent, setScrollParent] = useState(null); + const nodeRef = useRef(null); + const setScrollRef = useCallback((node: HTMLDivElement | null) => { + nodeRef.current = node; + if (node && node.offsetHeight > 0) setScrollParent(node); + }, []); + useEffect(() => { + const node = nodeRef.current; + if (!node || scrollParent) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + if (entry.contentRect.height > 0) { + setScrollParent(node); + ro.disconnect(); + return; + } + } + }); + ro.observe(node); + return () => ro.disconnect(); + }, [scrollParent]); + return { scrollParent, setScrollRef }; +} + +export const VirtuosoMessageList = memo(function VirtuosoMessageList(props: MessageListProps) { + const { items, messages, sessionId, messagesLoading, isWorking, sessionState } = props; + const { scrollParent, setScrollRef } = useVisibleScrollParent(); + const isInitialLoading = messagesLoading && messages.length === 0; + const isNonLoadableSession = + !sessionState || ["CREATED", "FAILED", "COMPLETED", "CANCELLED"].includes(sessionState); + const showLoadingState = + (messagesLoading || isInitialLoading) && !isWorking && !isNonLoadableSession; + const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); + const isRunning = getSessionRunningState(sessionState); + const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); + + const Header = useCallback( + () => ( + + ), + [isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messages.length], + ); + + const Footer = useCallback( + () => ( + <> + {sessionId && } + + + ), + [sessionId, sessionState, messages], + ); + + if (isInitialLoading || items.length === 0) { + return ( + + + {sessionId && } + + + ); + } + + return ( + + {scrollParent && ( + + )} + + ); +}); diff --git a/apps/web/components/task/chat/message-list.tsx b/apps/web/components/task/chat/message-list.tsx new file mode 100644 index 0000000000..33488789dc --- /dev/null +++ b/apps/web/components/task/chat/message-list.tsx @@ -0,0 +1,26 @@ +"use client"; + +import type { ComponentType } from "react"; +import { memo } from "react"; +import { NativeMessageList } from "./message-list-native"; +import { VirtuosoMessageList } from "./message-list-virtuoso"; +import type { MessageListProps } from "./message-list-shared"; + +const strategies: Record> = { + native: NativeMessageList, + virtuoso: VirtuosoMessageList, +}; + +/** + * Rendering strategy for the message list. + * - "native": simple DOM rendering with overflow-anchor for scroll pinning. + * Better for short/medium conversations; avoids Virtuoso measurement quirks. + * - "virtuoso": react-virtuoso windowed rendering. + * Better for very long conversations (1000+ messages) where DOM node count matters. + */ +const STRATEGY = "native"; + +export const MessageList = memo(function MessageList(props: MessageListProps) { + const Renderer = strategies[STRATEGY] ?? NativeMessageList; + return ; +}); diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 833439ab63..968d92a674 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -75,7 +75,7 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] { parts.push( @{filePath} , @@ -105,26 +105,28 @@ const markdownComponents = { return {content}; }, ol: ({ children }: { children?: ReactNode }) => ( -
    {children}
+
    {children}
), ul: ({ children }: { children?: ReactNode }) => ( -
    {children}
+
    {children}
), li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , - p: ({ children }: { children?: ReactNode }) => ( -

    {children}

    - ), + p: ({ children }: { children?: ReactNode }) =>

    {children}

    , h1: ({ children }: { children?: ReactNode }) => ( -

    {children}

    +

    {children}

    ), h2: ({ children }: { children?: ReactNode }) => ( -

    {children}

    +

    {children}

    ), h3: ({ children }: { children?: ReactNode }) => ( -

    {children}

    +

    {children}

    + ), + h4: ({ children }: { children?: ReactNode }) => ( +

    {children}

    + ), + h5: ({ children }: { children?: ReactNode }) => ( +
    {children}
    ), - h4: ({ children }: { children?: ReactNode }) =>

    {children}

    , - h5: ({ children }: { children?: ReactNode }) =>

    {children}

    , hr: ({ children }: { children?: ReactNode }) =>
    {children}, table: ({ children }: { children?: ReactNode }) => (
    diff --git a/apps/web/components/task/chat/messages/inline-code.tsx b/apps/web/components/task/chat/messages/inline-code.tsx index b184da78ff..7f0335a467 100644 --- a/apps/web/components/task/chat/messages/inline-code.tsx +++ b/apps/web/components/task/chat/messages/inline-code.tsx @@ -30,8 +30,8 @@ export function InlineCode({ children }: InlineCodeProps) { {children} @@ -41,14 +41,14 @@ export function InlineCode({ children }: InlineCodeProps) { {tooltipText} - + ); diff --git a/apps/web/components/task/chat/use-chat-input-container.ts b/apps/web/components/task/chat/use-chat-input-container.ts index e7fed4e778..8ea50342c1 100644 --- a/apps/web/components/task/chat/use-chat-input-container.ts +++ b/apps/web/components/task/chat/use-chat-input-container.ts @@ -1,10 +1,9 @@ "use client"; -import { useCallback, useState, useEffect, useImperativeHandle } from "react"; +import { useCallback, useRef, useState, useEffect, useImperativeHandle } from "react"; import type React from "react"; import { useResizableInput } from "@/hooks/use-resizable-input"; import { useChatInputState } from "./use-chat-input-state"; -import type { QueuedMessageIndicatorHandle } from "./queued-message-indicator"; import type { TipTapInputHandle } from "./tiptap-input"; import type { ContextItem } from "@/lib/types/context"; import type { ContextFile } from "@/lib/state/context-files-store"; @@ -20,16 +19,12 @@ type UseChatInputContainerParams = { isFailed: boolean; isAgentBusy: boolean; hasAgentCommands: boolean; - isQueued: boolean; placeholder: string | undefined; contextItems: ContextItem[]; pendingClarification: Message | null | undefined; onClarificationResolved: (() => void) | undefined; pendingCommentsByFile: Record | undefined; hasContextComments: boolean; - queuedMessage: string | null | undefined; - onCancelQueue: (() => void) | undefined; - updateQueueContent: ((content: string) => Promise) | undefined; todoItems: { text: string; done?: boolean }[]; showRequestChangesTooltip: boolean; onRequestChangesTooltipDismiss: (() => void) | undefined; @@ -39,7 +34,6 @@ type UseChatInputContainerParams = { attachments?: MessageAttachment[], inlineMentions?: ContextFile[], ) => void; - queuedMessageRef?: React.RefObject; }; function useInputHandle( @@ -80,10 +74,6 @@ function computeDerivedState(params: { pendingClarification: Message | null | undefined; onClarificationResolved: (() => void) | undefined; pendingCommentsByFile: Record | undefined; - isQueued: boolean; - queuedMessage: string | null | undefined; - onCancelQueue: (() => void) | undefined; - updateQueueContent: ((content: string) => Promise) | undefined; todoItems: { text: string }[]; allItemsLength: number; isInputFocused: boolean; @@ -96,14 +86,8 @@ function computeDerivedState(params: { const hasPendingComments = !!( params.pendingCommentsByFile && Object.keys(params.pendingCommentsByFile).length > 0 ); - const hasQueuedMessage = !!( - params.isQueued && - params.queuedMessage && - params.onCancelQueue && - params.updateQueueContent - ); const hasTodos = params.todoItems.length > 0; - const hasContextZone = hasQueuedMessage || hasTodos || params.allItemsLength > 0; + const hasContextZone = hasTodos || params.allItemsLength > 0; const showFocusHint = !params.isInputFocused && !hasClarification && !hasPendingComments; const inputPlaceholder = getInputPlaceholder( params.placeholder, @@ -114,7 +98,6 @@ function computeDerivedState(params: { isDisabled, hasClarification, hasPendingComments, - hasQueuedMessage, hasTodos, hasContextZone, showFocusHint, @@ -131,15 +114,11 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { isFailed, isAgentBusy, hasAgentCommands, - isQueued, placeholder, contextItems, pendingClarification, onClarificationResolved, pendingCommentsByFile, - queuedMessage, - onCancelQueue, - updateQueueContent, todoItems, showRequestChangesTooltip, onRequestChangesTooltipDismiss, @@ -150,8 +129,11 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); const [contextPopoverOpen, setContextPopoverOpen] = useState(false); + const tiptapRef = useRef(null); + const getContentElement = useCallback(() => tiptapRef.current?.getTextareaElement() ?? null, []); const { height, resetHeight, autoExpand, containerRef, resizeHandleProps } = useResizableInput( sessionId ?? undefined, + getContentElement, ); const { value, inputRef, handleImagePaste, handleChange, handleSubmit, allItems } = useChatInputState({ @@ -165,6 +147,10 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { onSubmit, }); + useEffect(() => { + tiptapRef.current = inputRef.current; + }); + useInputHandle(ref, inputRef); // Auto-expand the input container as the user types more lines @@ -198,10 +184,6 @@ export function useChatInputContainer(params: UseChatInputContainerParams) { pendingClarification, onClarificationResolved, pendingCommentsByFile, - isQueued, - queuedMessage, - onCancelQueue, - updateQueueContent, todoItems, allItemsLength: allItems.length, isInputFocused, diff --git a/apps/web/components/task/chat/virtualized-message-list.tsx b/apps/web/components/task/chat/virtualized-message-list.tsx deleted file mode 100644 index 8aa0cab7b5..0000000000 --- a/apps/web/components/task/chat/virtualized-message-list.tsx +++ /dev/null @@ -1,401 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useRef, memo } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import type { Virtualizer } from "@tanstack/react-virtual"; -import { GridSpinner } from "@/components/grid-spinner"; -import { SessionPanelContent } from "@kandev/ui/pannel-session"; -import type { Message, TaskSessionState } from "@/lib/types/http"; -import type { RenderItem } from "@/hooks/use-processed-messages"; -import { MessageRenderer } from "@/components/task/chat/message-renderer"; -import { TurnGroupMessage } from "@/components/task/chat/messages/turn-group-message"; -import { AgentStatus } from "@/components/task/chat/messages/agent-status"; -import { PrepareProgress } from "@/components/session/prepare-progress"; -import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages"; - -type ContainerResizeContext = { - isHiddenRef: React.RefObject; - previousHeightRef: React.RefObject; - previousWidthRef: React.RefObject; - wasAtBottomRef: React.RefObject; - virtualizerRef: React.RefObject; - scrollSnapshot: React.RefObject<{ wasAtBottom: boolean; firstVisibleIndex: number }>; -}; - -type MessageVirtualizer = Virtualizer; - -function handleContainerResize( - entry: ResizeObserverEntry, - element: HTMLDivElement, - ctx: ContainerResizeContext, -): void { - const { - isHiddenRef, - previousHeightRef, - previousWidthRef, - wasAtBottomRef, - virtualizerRef, - scrollSnapshot, - } = ctx; - const currentHeight = entry.contentRect.height; - const currentWidth = entry.contentRect.width; - const previousHeight = previousHeightRef.current; - const previousWidth = previousWidthRef.current; - - if (currentWidth === 0 || currentHeight === 0) { - isHiddenRef.current = true; - return; - } - - if (isHiddenRef.current) { - isHiddenRef.current = false; - previousHeightRef.current = currentHeight; - previousWidthRef.current = currentWidth; - const virt = virtualizerRef.current; - const snapshot = scrollSnapshot.current; - virt.measure(); - requestAnimationFrame(() => { - if (snapshot.wasAtBottom) { - const count = virt.options.count; - if (count > 0) virt.scrollToIndex(count - 1, { align: "end" }); - } else { - virt.scrollToIndex(snapshot.firstVisibleIndex, { align: "start" }); - } - }); - return; - } - - if (previousWidth > 0 && Math.abs(currentWidth - previousWidth) > 20) - virtualizerRef.current.measure(); - if (previousHeight > 0 && currentHeight < previousHeight && wasAtBottomRef.current) { - requestAnimationFrame(() => { - element.scrollTop = element.scrollHeight; - }); - } - previousHeightRef.current = currentHeight; - previousWidthRef.current = currentWidth; -} - -function useVirtualListScrolling( - sessionId: string | null, - virtualizer: MessageVirtualizer, - messagesContainerRef: React.RefObject, -) { - const wasAtBottomRef = useRef(true); - const savedScrollTopRef = useRef(0); - const previousHeightRef = useRef(0); - const previousWidthRef = useRef(0); - const isHiddenRef = useRef(false); - const scrollSnapshot = useRef<{ wasAtBottom: boolean; firstVisibleIndex: number }>({ - wasAtBottom: true, - firstVisibleIndex: 0, - }); - const { loadMore, hasMore, isLoading: isLoadingMore } = useLazyLoadMessages(sessionId); - const loadingRef = useRef({ hasMore, isLoadingMore }); - loadingRef.current = { hasMore, isLoadingMore }; - const virtualizerRef = useRef(virtualizer); - virtualizerRef.current = virtualizer; - - const handleScroll = useCallback(() => { - const element = messagesContainerRef.current; - if (!element || isHiddenRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = element; - wasAtBottomRef.current = scrollHeight - scrollTop - clientHeight < 100; - savedScrollTopRef.current = scrollTop; - const virt = virtualizerRef.current; - const visibleItems = virt.getVirtualItems(); - scrollSnapshot.current = { - wasAtBottom: wasAtBottomRef.current, - firstVisibleIndex: visibleItems.length > 0 ? visibleItems[0].index : 0, - }; - if (scrollTop < 40 && loadingRef.current.hasMore && !loadingRef.current.isLoadingMore) { - const prevScrollHeight = scrollHeight; - const prevScrollTop = scrollTop; - loadMore().then((added) => { - if (!added) return; - requestAnimationFrame(() => { - const nextScrollHeight = element.scrollHeight; - element.scrollTop = prevScrollTop + (nextScrollHeight - prevScrollHeight); - }); - }); - } - }, [loadMore, messagesContainerRef]); - - useEffect(() => { - const element = messagesContainerRef.current; - if (!element) return; - element.addEventListener("scroll", handleScroll); - return () => element.removeEventListener("scroll", handleScroll); - }, [handleScroll, messagesContainerRef]); - - useEffect(() => { - const element = messagesContainerRef.current; - if (!element) return; - const resizeCtx: ContainerResizeContext = { - isHiddenRef, - previousHeightRef, - previousWidthRef, - wasAtBottomRef, - virtualizerRef, - scrollSnapshot, - }; - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) handleContainerResize(entry, element, resizeCtx); - }); - resizeObserver.observe(element); - return () => resizeObserver.disconnect(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { wasAtBottomRef, hasMore, isLoadingMore }; -} - -type VirtualizedMessageListProps = { - items: RenderItem[]; - messages: Message[]; - permissionsByToolCallId: Map; - childrenByParentToolCallId: Map; - taskId?: string; - sessionId: string | null; - messagesLoading: boolean; - isWorking: boolean; - sessionState?: TaskSessionState; - worktreePath?: string; - onOpenFile?: (path: string) => void; -}; - -type MessageListBodyProps = { - items: RenderItem[]; - messages: Message[]; - permissionsByToolCallId: Map; - childrenByParentToolCallId: Map; - taskId?: string; - sessionId: string | null; - sessionState?: TaskSessionState; - worktreePath?: string; - onOpenFile?: (path: string) => void; - isInitialLoading: boolean; - virtualizer: MessageVirtualizer; - lastTurnGroupId: string | null; - isRunning: boolean; - handleScrollToMessage: (messageId: string) => void; -}; - -function MessageListBody({ - items, - messages, - permissionsByToolCallId, - childrenByParentToolCallId, - taskId, - sessionId, - sessionState, - worktreePath, - onOpenFile, - isInitialLoading, - virtualizer, - lastTurnGroupId, - isRunning, - handleScrollToMessage, -}: MessageListBodyProps) { - const itemCount = items.length; - if (isInitialLoading || itemCount === 0) return null; - return ( -
    - {virtualizer.getVirtualItems().map((virtualRow) => { - const item = items[virtualRow.index]; - return ( -
    -
    - {item.type === "turn_group" ? ( - - ) : ( - - )} -
    -
    - ); - })} -
    - ); -} - -function getSessionRunningState(sessionState: string | null | undefined) { - return sessionState === "CREATED" || sessionState === "STARTING" || sessionState === "RUNNING"; -} - -function getLastTurnGroupId(items: RenderItem[]) { - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (item.type === "turn_group") return item.id; - } - return null; -} - -function MessageListStatus({ - isLoadingMore, - hasMore, - showLoadingState, - messagesLoading, - isInitialLoading, - messagesCount, -}: { - isLoadingMore: boolean; - hasMore: boolean; - showLoadingState: boolean; - messagesLoading: boolean; - isInitialLoading: boolean; - messagesCount: number; -}) { - return ( - <> - {isLoadingMore && hasMore && ( -
    - Loading older messages... -
    - )} - {showLoadingState && ( -
    - - Loading messages... -
    - )} - {!messagesLoading && !isInitialLoading && messagesCount === 0 && ( -
    - No messages yet. Start the conversation! -
    - )} - - ); -} - -export const VirtualizedMessageList = memo(function VirtualizedMessageList({ - items, - messages, - permissionsByToolCallId, - childrenByParentToolCallId, - taskId, - sessionId, - messagesLoading, - isWorking, - sessionState, - worktreePath, - onOpenFile, -}: VirtualizedMessageListProps) { - const messagesContainerRef = useRef(null); - const isInitialLoading = messagesLoading && messages.length === 0; - const isNonLoadableSession = - !sessionState || ["CREATED", "FAILED", "COMPLETED", "CANCELLED"].includes(sessionState); - const showLoadingState = - (messagesLoading || isInitialLoading) && !isWorking && !isNonLoadableSession; - const itemCount = items.length; - - // eslint-disable-next-line react-hooks/incompatible-library - const virtualizer = useVirtualizer({ - count: itemCount, - getScrollElement: () => messagesContainerRef.current, - estimateSize: () => 96, - overscan: 6, - }); - const { wasAtBottomRef, hasMore, isLoadingMore } = useVirtualListScrolling( - sessionId, - virtualizer, - messagesContainerRef, - ); - - const handleScrollToMessage = useCallback( - (messageId: string) => { - const index = items.findIndex((item) => { - if (item.type === "turn_group") return item.messages.some((msg) => msg.id === messageId); - return item.message?.id === messageId; - }); - if (index >= 0) virtualizer.scrollToIndex(index, { align: "center" }); - }, - [items, virtualizer], - ); - - const lastMessageContent = useMemo(() => { - if (items.length === 0) return ""; - const lastItem = items[items.length - 1]; - if (lastItem.type === "turn_group") - return lastItem.messages[lastItem.messages.length - 1]?.content ?? ""; - return lastItem.message?.content ?? ""; - }, [items]); - - useEffect(() => { - if (itemCount === 0) return; - if (wasAtBottomRef.current) virtualizer.scrollToIndex(itemCount - 1, { align: "end" }); - }, [itemCount, lastMessageContent, virtualizer, wasAtBottomRef]); - - const isRunning = getSessionRunningState(sessionState); - const lastTurnGroupId = useMemo(() => getLastTurnGroupId(items), [items]); - - useEffect(() => { - if (!isRunning) return; - const element = messagesContainerRef.current; - if (!element) return; - requestAnimationFrame(() => { - element.scrollTop = element.scrollHeight; - }); - }, [isRunning]); - - return ( - - - - {sessionId && } - - - ); -}); diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 3196cbce9a..4f363ab604 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type IDockviewHeaderActionsProps } from "dockview-react"; import { IconPlus, @@ -16,6 +16,8 @@ import { IconLayoutRows, IconX, IconBrandVscode, + IconArrowsMaximize, + IconArrowsMinimize, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -154,11 +156,23 @@ export function LeftHeaderActions(props: IDockviewHeaderActionsProps) { ); } -/** Faded split-vertical, split-horizontal, and close buttons for any non-sidebar group. */ +const ACTION_BTN = + "h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"; + +/** Faded maximize, split, and close buttons for any non-sidebar group. */ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) { const centerGroupId = useDockviewStore((s) => s.centerGroupId); const isChatGroup = group.id === centerGroupId; + const handleMaximize = useCallback(() => { + if (containerApi.hasMaximizedGroup()) { + containerApi.exitMaximizedGroup(); + } else { + const panel = group.activePanel; + if (panel) containerApi.maximizeGroup(panel); + } + }, [group, containerApi]); + const handleSplitRight = useCallback(() => { containerApi.addGroup({ referenceGroup: group, direction: "right" }); }, [group, containerApi]); @@ -186,28 +200,40 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP } }, [group, containerApi]); + const [isMaximized, setIsMaximized] = useState(() => containerApi.hasMaximizedGroup()); + useEffect(() => { + const disposable = containerApi.onDidMaximizedGroupChange(() => { + setIsMaximized(containerApi.hasMaximizedGroup()); + }); + return () => disposable.dispose(); + }, [containerApi]); + return ( <> - + + {isMaximized ? "Restore" : "Maximize"} + + + + Split right - Split down @@ -215,12 +241,8 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP {!isChatGroup && ( - Close group @@ -308,14 +330,13 @@ function RightTopGroupActions() { return ( - + Hide right panels diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx index 30d162632a..fb693ccaa0 100644 --- a/apps/web/components/task/file-browser-parts.tsx +++ b/apps/web/components/task/file-browser-parts.tsx @@ -149,7 +149,7 @@ function getGitStatusTextClass(status: GitFileStatus): string { switch (status) { case "added": case "untracked": - return "text-emerald-600"; + return "text-green-700 dark:text-green-600"; case "modified": return "text-yellow-600"; default: @@ -157,24 +157,6 @@ function getGitStatusTextClass(status: GitFileStatus): string { } } -function getGitStatusDotClass(status: GitFileStatus): string { - switch (status) { - case "added": - case "untracked": - return "bg-emerald-500"; - case "modified": - return "bg-yellow-500"; - default: - return "bg-transparent"; - } -} - -function TreeNodeGitStatusDot({ status }: { status: GitFileStatus }) { - if (!status || (status !== "added" && status !== "untracked" && status !== "modified")) - return null; - return ; -} - /** Delete button shown on hover for file nodes. */ function TreeNodeDeleteButton({ node, @@ -266,17 +248,14 @@ export function TreeNodeItem({ > {node.name} - {!node.is_dir && ( + {!node.is_dir && onDeleteFile && (
    - - {onDeleteFile && ( - - )} +
    )}
    @@ -365,9 +344,6 @@ export function SearchResultsList({ {folder && {folder}/} {name} -
    - -
    ); })} diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index d94c4d323d..c7e90798f1 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useMemo, useCallback, useRef, useState } from "react"; +import React, { useEffect, useMemo, useCallback, useRef, useState } from "react"; import { ScrollArea } from "@kandev/ui/scroll-area"; import type { FileTreeNode, OpenFileTab } from "@/lib/types/backend"; import { useSession } from "@/hooks/domains/session/use-session"; +import { useRepository } from "@/hooks/domains/workspace/use-repository"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useOpenSessionFolder } from "@/hooks/use-open-session-folder"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; @@ -165,6 +166,7 @@ export function FileBrowser({ activeFilePath, }: FileBrowserProps) { const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId); + const repository = useRepository(session?.repository_id ?? null); const gitStatus = useSessionGitStatus(sessionId); const { open: openFolder } = useOpenSessionFolder(sessionId); const { copied, copy: copyPath } = useCopyToClipboard(1000); @@ -180,7 +182,7 @@ export function FileBrowser({ new Map(Object.entries(gitStatus?.files ?? {}).map(([path, info]) => [path, info.status])), [gitStatus?.files], ); - const fullPath = session?.worktree_path ?? ""; + const fullPath = session?.worktree_path || repository?.local_path || ""; const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, "~/"); const { @@ -193,6 +195,24 @@ export function FileBrowser({ handleCancelCreate, } = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, treeState); + // Auto-expand ancestor directories when the active file changes + useEffect(() => { + if (!activeFilePath) return; + const parts = activeFilePath.split("/"); + if (parts.length <= 1) return; + const ancestors: string[] = []; + for (let i = 1; i < parts.length; i++) { + ancestors.push(parts.slice(0, i).join("/")); + } + treeState.setExpandedPaths((prev) => { + const allExpanded = ancestors.every((p) => prev.has(p)); + if (allExpanded) return prev; + const next = new Set(prev); + for (const p of ancestors) next.add(p); + return next; + }); + }, [activeFilePath, treeState]); + return (
    state.tasks.activeSessionId); + const activeFilePath = useDockviewStore((s) => s.activeFilePath); const isArchived = useIsTaskArchived(); const { createFile, deleteFile } = useFileOperations(activeSessionId ?? null); @@ -49,6 +51,7 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { onOpenFile={onOpenFile} onCreateFile={handleCreateFile} onDeleteFile={deleteFile} + activeFilePath={activeFilePath} /> ) : (
    diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 9035811bc5..d07dba762a 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -15,6 +15,7 @@ import { useTaskActions } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import type { TaskState, + TaskSessionState, Workspace, Repository, TaskSession, @@ -51,6 +52,7 @@ function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) state: task.state, repositoryId: task.repositories?.[0]?.repository_id ?? undefined, primarySessionId: task.primary_session_id ?? undefined, + primarySessionState: task.primary_session_state ?? undefined, sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, primaryExecutorId: task.primary_executor_id ?? undefined, @@ -91,13 +93,18 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) { const getSessionInfoForTask = useCallback( (taskId: string) => { const sessions = sessionsByTaskId[taskId] ?? []; - if (sessions.length === 0) return { diffStats: undefined, updatedAt: undefined }; + if (sessions.length === 0) { + return { diffStats: undefined, updatedAt: undefined, sessionState: undefined }; + } const primarySession = sessions.find((s: TaskSession) => s.is_primary); const latestSession = primarySession ?? sessions[0]; - if (!latestSession) return { diffStats: undefined, updatedAt: undefined }; + if (!latestSession) { + return { diffStats: undefined, updatedAt: undefined, sessionState: undefined }; + } const updatedAt = latestSession.updated_at; + const sessionState = latestSession.state as TaskSessionState | undefined; const gitStatus = gitStatusBySessionId[latestSession.id]; - if (!gitStatus?.files) return { diffStats: undefined, updatedAt }; + if (!gitStatus?.files) return { diffStats: undefined, updatedAt, sessionState }; let additions = 0; let deletions = 0; for (const file of Object.values(gitStatus.files)) { @@ -105,7 +112,7 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) { deletions += file.deletions ?? 0; } const diffStats = additions === 0 && deletions === 0 ? undefined : { additions, deletions }; - return { diffStats, updatedAt }; + return { diffStats, updatedAt, sessionState }; }, [sessionsByTaskId, gitStatusBySessionId], ); @@ -121,6 +128,8 @@ function useSheetData(workspaceId: string | null, workflowId: string | null) { id: task.id, title: task.title, state: task.state as TaskState | undefined, + sessionState: + sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), description: task.description, workflowStepId: task.workflowStepId, repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, diff --git a/apps/web/components/task/model-selector.tsx b/apps/web/components/task/model-selector.tsx index 61bed6c86a..25c323ddc5 100644 --- a/apps/web/components/task/model-selector.tsx +++ b/apps/web/components/task/model-selector.tsx @@ -64,20 +64,6 @@ function buildModelOptions( return options; } -function NoModelButton() { - return ( - - ); -} - export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSelectorProps) { useSettingsData(true); @@ -103,7 +89,7 @@ export const ModelSelector = memo(function ModelSelector({ sessionId }: ModelSel setActiveModel(sessionId, modelId); }; - if (!sessionId || !currentModel) return ; + if (!sessionId || !currentModel) return null; const displayName = modelOptions.find((m) => m.id === currentModel)?.name || currentModel; diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index b1e86cc27e..01122dccc8 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -4,13 +4,50 @@ import { useCallback, useEffect, useRef, useState, memo } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; -import { type QueuedMessageIndicatorHandle } from "@/components/task/chat/queued-message-indicator"; -import { VirtualizedMessageList } from "@/components/task/chat/virtualized-message-list"; +import { + QueuedMessageIndicator, + type QueuedMessageIndicatorHandle, +} from "@/components/task/chat/queued-message-indicator"; +import { MessageList } from "@/components/task/chat/message-list"; import { useIsTaskArchived } from "./task-archived-context"; import { useChatPanelState } from "./chat/use-chat-panel-state"; import { ChatInputArea, useSubmitHandler, useChatPanelHandlers } from "./chat/chat-input-area"; import { ClarificationInputOverlay } from "./chat/clarification-input-overlay"; +type QueuedOverlayProps = { + isQueued: boolean; + queuedMessage: { content: string } | null | undefined; + isArchived: boolean; + indicatorRef: React.RefObject; + onCancel: () => void; + onUpdate: (content: string) => Promise; + onEditComplete: () => void; +}; + +function QueuedMessageOverlay({ + isQueued, + queuedMessage, + isArchived, + indicatorRef, + onCancel, + onUpdate, + onEditComplete, +}: QueuedOverlayProps) { + if (!isQueued || !queuedMessage || isArchived) return null; + return ( +
    + +
    + ); +} + type TaskChatPanelProps = { onSend?: (message: string) => void; sessionId?: string | null; @@ -54,6 +91,9 @@ export const TaskChatPanel = memo(function TaskChatPanel({ agentMessageCount, cancelQueue, pendingClarification, + isQueued, + queuedMessage, + updateQueueContent, } = panelState; const { handleCancelTurn, handleCancelQueue, handleQueueEditComplete } = useChatPanelHandlers( resolvedSessionId, @@ -70,7 +110,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ return ( -
    )} + {isArchived ? (
    This task is archived and read-only. @@ -99,13 +148,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({ ) : ( void; + currentTitle: string; + onSubmit: (newTitle: string) => void; +}; + +function TaskRenameForm({ + currentTitle, + onSubmit, + onClose, +}: { + currentTitle: string; + onSubmit: (newTitle: string) => void; + onClose: () => void; +}) { + const [value, setValue] = useState(currentTitle); + const inputRef = useRef(null); + + const trimmed = value.trim(); + const canSubmit = trimmed.length > 0 && trimmed !== currentTitle; + + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + onSubmit(trimmed); + onClose(); + }, [canSubmit, trimmed, onSubmit, onClose]); + + return ( + <> + + Rename task + + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmit(); + }} + onFocus={(e) => e.target.select()} + placeholder="Task title" + /> + + + + + + ); +} + +export function TaskRenameDialog({ + open, + onOpenChange, + currentTitle, + onSubmit, +}: TaskRenameDialogProps): React.JSX.Element { + const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]); + return ( + + + {open && ( + + )} + + + ); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 9d35202fa0..0380c5cc29 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -4,6 +4,7 @@ import { useCallback, useMemo, useState, memo } from "react"; import type { TaskState, TaskSessionState, Repository, TaskSession, Task } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; import { TaskSwitcher } from "./task-switcher"; +import { TaskRenameDialog } from "./task-rename-dialog"; import { Button } from "@kandev/ui/button"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { IconPlus } from "@tabler/icons-react"; @@ -169,7 +170,8 @@ function useSidebarData(workspaceId: string | null) { id: task.id, title: task.title, state: task.state as TaskState | undefined, - sessionState: sessionInfo.sessionState, + sessionState: + sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined), description: task.description, workflowStepId: task.workflowStepId as string | undefined, repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, @@ -221,7 +223,7 @@ function useSidebarActions(store: ReturnType) { const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const [deletingTaskId, setDeletingTaskId] = useState(null); - const { deleteTaskById, archiveTaskById } = useTaskActions(); + const { deleteTaskById, archiveTaskById, renameTaskById } = useTaskActions(); const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store, useLayoutSwitch: true, @@ -288,7 +290,35 @@ function useSidebarActions(store: ReturnType) { [deleteTaskById, removeTaskFromBoard], ); - return { deletingTaskId, handleSelectTask, handleArchiveTask, handleDeleteTask }; + const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); + + const handleRenameTask = useCallback((taskId: string, currentTitle: string) => { + setRenamingTask({ id: taskId, title: currentTitle }); + }, []); + + const handleRenameSubmit = useCallback( + async (newTitle: string) => { + if (!renamingTask) return; + try { + await renameTaskById(renamingTask.id, newTitle); + } catch (error) { + console.error("Failed to rename task:", error); + } + setRenamingTask(null); + }, + [renamingTask, renameTaskById], + ); + + return { + deletingTaskId, + handleSelectTask, + handleArchiveTask, + handleDeleteTask, + renamingTask, + setRenamingTask, + handleRenameTask, + handleRenameSubmit, + }; } export const TaskSessionSidebar = memo(function TaskSessionSidebar({ @@ -299,8 +329,16 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ const { activeTaskId, selectedTaskId, allSteps, isLoadingWorkflow, tasksWithRepositories } = useSidebarData(workspaceId); - const { deletingTaskId, handleSelectTask, handleArchiveTask, handleDeleteTask } = - useSidebarActions(store); + const { + deletingTaskId, + handleSelectTask, + handleArchiveTask, + handleDeleteTask, + renamingTask, + setRenamingTask, + handleRenameTask, + handleRenameSubmit, + } = useSidebarActions(store); return ( @@ -311,12 +349,21 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={handleSelectTask} + onRenameTask={handleRenameTask} onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} /> + { + if (!open) setRenamingTask(null); + }} + currentTitle={renamingTask?.title ?? ""} + onSubmit={handleRenameSubmit} + /> ); }); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 2e1ed03857..1566adb8fa 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -1,10 +1,21 @@ "use client"; import { memo, useMemo } from "react"; +import type { ComponentType } from "react"; +import { IconCircleCheck, IconCircleDashed, IconProgress } from "@tabler/icons-react"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { truncateRepoPath } from "@/lib/utils"; import { TaskItem } from "./task-item"; +const SECTION_ICONS: Record< + string, + { Icon: ComponentType<{ className?: string }>; className: string } +> = { + Review: { Icon: IconCircleCheck, className: "text-green-500" }, + "In Progress": { Icon: IconProgress, className: "text-yellow-500" }, + Backlog: { Icon: IconCircleDashed, className: "text-muted-foreground" }, +}; + type DiffStats = { additions: number; deletions: number; @@ -33,6 +44,7 @@ type TaskSwitcherProps = { activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; + onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; deletingTaskId?: string | null; @@ -75,9 +87,11 @@ function TaskSwitcherSkeleton() { } function SectionHeader({ label, count }: { label: string; count: number }) { + const icon = SECTION_ICONS[label]; return (
    - + + {icon && } {label} {count} @@ -91,6 +105,7 @@ function TaskSwitcherSection({ activeTaskId, selectedTaskId, onSelectTask, + onRenameTask, onArchiveTask, onDeleteTask, deletingTaskId, @@ -100,6 +115,7 @@ function TaskSwitcherSection({ activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; + onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; deletingTaskId?: string | null; @@ -131,6 +147,7 @@ function TaskSwitcherSection({ primarySessionId={task.primarySessionId ?? null} updatedAt={task.updatedAt} onClick={() => onSelectTask(task.id)} + onRename={onRenameTask ? () => onRenameTask(task.id, task.title) : undefined} onArchive={onArchiveTask ? () => onArchiveTask(task.id) : undefined} onDelete={onDeleteTask ? () => onDeleteTask(task.id) : undefined} isDeleting={deletingTaskId === task.id} @@ -147,6 +164,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ activeTaskId, selectedTaskId, onSelectTask, + onRenameTask, onArchiveTask, onDeleteTask, deletingTaskId, @@ -172,10 +190,15 @@ export const TaskSwitcher = memo(function TaskSwitcher({ else backlog.push(task); } - const byTitle = (a: TaskSwitcherItem, b: TaskSwitcherItem) => a.title.localeCompare(b.title); - review.sort(byTitle); - inProgress.sort(byTitle); - backlog.sort(byTitle); + const byRecent = (a: TaskSwitcherItem, b: TaskSwitcherItem) => { + const ta = a.updatedAt ?? ""; + const tb = b.updatedAt ?? ""; + if (ta !== tb) return tb.localeCompare(ta); + return a.title.localeCompare(b.title); + }; + review.sort(byRecent); + inProgress.sort(byRecent); + backlog.sort(byRecent); return [ { label: "Review", tasks: review }, @@ -202,6 +225,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={onSelectTask} + onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} deletingTaskId={deletingTaskId} diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index d6ecc67342..6b004abe3e 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -2,7 +2,6 @@ import { memo, useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { IconBug, IconCopy, @@ -87,7 +86,6 @@ const TaskTopBar = memo(function TaskTopBar({ remoteCheckedAt, remoteStatusError, }: TaskTopBarProps) { - const router = useRouter(); const git = useSessionGit(activeSessionId); const displayBranch = worktreeBranch || baseBranch; @@ -123,7 +121,6 @@ const TaskTopBar = memo(function TaskTopBar({ showDebugOverlay={showDebugOverlay} onToggleDebugOverlay={onToggleDebugOverlay} isArchived={isArchived} - router={router} /> ); @@ -394,7 +391,6 @@ function TopBarRight({ showDebugOverlay, onToggleDebugOverlay, isArchived, - router, }: { activeSessionId?: string | null; baseBranch?: string; @@ -402,7 +398,6 @@ function TopBarRight({ showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; isArchived?: boolean; - router: ReturnType; }) { return (
    @@ -435,13 +430,10 @@ function TopBarRight({ )} - Settings diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 9a4f198ffe..d10b53abe7 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -2,9 +2,35 @@ import { useEffect, useRef } from "react"; import { fetchWorkflowSnapshot } from "@/lib/api"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { Task } from "@/lib/types/http"; type KanbanTask = KanbanState["tasks"][number]; +// eslint-disable-next-line complexity -- pure field mapping, no real branching logic +function mapSnapshotTask(task: Task, stepIds: Set): KanbanTask | null { + const workflowStepId = task.workflow_step_id; + if (!workflowStepId || !stepIds.has(workflowStepId)) return null; + + return { + id: task.id, + workflowStepId, + title: task.title, + description: task.description ?? undefined, + position: task.position ?? 0, + state: task.state, + repositoryId: task.repositories?.[0]?.repository_id ?? undefined, + primarySessionId: task.primary_session_id ?? undefined, + primarySessionState: task.primary_session_state ?? undefined, + sessionCount: task.session_count ?? undefined, + reviewStatus: task.review_status ?? undefined, + primaryExecutorId: task.primary_executor_id ?? undefined, + primaryExecutorType: task.primary_executor_type ?? undefined, + primaryExecutorName: task.primary_executor_name ?? undefined, + isRemoteExecutor: task.is_remote_executor ?? false, + updatedAt: task.updated_at, + } as KanbanTask; +} + export function useAllWorkflowSnapshots(workspaceId: string | null) { const store = useAppStoreApi(); const connectionStatus = useAppStore((state) => state.connection.status); @@ -55,28 +81,7 @@ export function useAllWorkflowSnapshots(workspaceId: string | null) { const stepIds = new Set(steps.map((s) => s.id)); const tasks: KanbanTask[] = snapshot.tasks - .map((task) => { - const workflowStepId = task.workflow_step_id; - if (!workflowStepId || !stepIds.has(workflowStepId)) return null; - - return { - id: task.id, - workflowStepId, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - primarySessionId: task.primary_session_id ?? undefined, - sessionCount: task.session_count ?? undefined, - reviewStatus: task.review_status ?? undefined, - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - updatedAt: task.updated_at, - } as KanbanTask; - }) + .map((task) => mapSnapshotTask(task, stepIds)) .filter((t): t is KanbanTask => t !== null); setWorkflowSnapshot(wf.id, { diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index f419137fb9..851396206b 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type MutableRefObject } from "react"; +import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { TaskSessionState, Message } from "@/lib/types/http"; @@ -76,6 +76,40 @@ async function doFetchMessages({ } } +function useTerminalStateFetch( + taskSessionId: string | null, + taskSessionState: TaskSessionState | null, + hasAgentMessage: boolean, + refs: { + store: ReturnType; + setIsLoading: (v: boolean) => void; + setIsWaitingForInitialMessages: (v: boolean) => void; + initialFetchStartRef: MutableRefObject; + lastFetchedSessionIdRef: MutableRefObject; + }, +) { + const lastFetchStateKeyRef = useRef(null); + const connectionStatus = useAppStore((state) => state.connection.status); + + useEffect(() => { + if (!taskSessionId || connectionStatus !== "connected") return; + if (!taskSessionState || hasAgentMessage) return; + + const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]); + if (!terminalStates.has(taskSessionState)) return; + + const key = `${taskSessionId}:${taskSessionState}`; + if (lastFetchStateKeyRef.current === key) return; + lastFetchStateKeyRef.current = key; + + void doFetchMessages({ + taskSessionId, + ...refs, + onError: (error) => console.error("Failed to fetch messages after state change:", error), + }); + }, [taskSessionId, taskSessionState, hasAgentMessage, connectionStatus, refs]); +} + export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { const store = useAppStoreApi(); const messages = useAppStore((state) => @@ -92,7 +126,6 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess const [isWaitingForInitialMessages, setIsWaitingForInitialMessages] = useState(false); const initialFetchStartRef = useRef(null); const lastFetchedSessionIdRef = useRef(null); - const lastFetchStateKeyRef = useRef(null); const prevSessionIdRef = useRef(null); const hasAgentMessage = messages.some((message: Message) => message.author_type === "agent"); @@ -121,6 +154,7 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess useEffect(() => { if (!taskSessionId || connectionStatus !== "connected") return; + const isFreshMount = prevSessionIdRef.current === null; const sessionChanged = prevSessionIdRef.current !== null && prevSessionIdRef.current !== taskSessionId; prevSessionIdRef.current = taskSessionId; @@ -129,13 +163,22 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess lastFetchedSessionIdRef.current = null; } - if (messages.length > 0 && !sessionChanged) { + // Normal re-render with cached messages — skip fetch + if (messages.length > 0 && !sessionChanged && !isFreshMount) { lastFetchedSessionIdRef.current = taskSessionId; // eslint-disable-next-line react-hooks/set-state-in-effect setIsWaitingForInitialMessages(false); return; } + // Fresh mount with cached messages — show cached instantly, fetch in background + if (isFreshMount && messages.length > 0) { + lastFetchedSessionIdRef.current = taskSessionId; + setIsWaitingForInitialMessages(false); + fetchAndStoreMessages(taskSessionId, store).catch(() => {}); + return; + } + if (lastFetchedSessionIdRef.current === taskSessionId) return; void doFetchMessages({ @@ -158,26 +201,17 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess }; }, [taskSessionId, connectionStatus]); - useEffect(() => { - if (!taskSessionId || !taskSessionState || hasAgentMessage) return; - - const terminalStates = new Set(["WAITING_FOR_INPUT", "COMPLETED", "FAILED"]); - if (!terminalStates.has(taskSessionState)) return; - - const key = `${taskSessionId}:${taskSessionState}`; - if (lastFetchStateKeyRef.current === key) return; - lastFetchStateKeyRef.current = key; - - void doFetchMessages({ - taskSessionId, + const terminalFetchRefs = useMemo( + () => ({ store, setIsLoading, setIsWaitingForInitialMessages, initialFetchStartRef, lastFetchedSessionIdRef, - onError: (error) => console.error("Failed to fetch messages after state change:", error), - }); - }, [taskSessionId, taskSessionState, hasAgentMessage, store]); + }), + [store], + ); + useTerminalStateFetch(taskSessionId, taskSessionState, hasAgentMessage, terminalFetchRefs); return { isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading, diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index 3f16532baf..4dab113b71 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -31,6 +31,8 @@ export function useLazyLoadMessages(sessionId: string | null) { return 0; } + // Update ref synchronously so concurrent calls are blocked immediately + stateRef.current.isLoading = true; setMessagesMetadata(sessionId, { isLoading: true }); try { const response = await listTaskSessionMessages(sessionId, { @@ -41,6 +43,13 @@ export function useLazyLoadMessages(sessionId: string | null) { const orderedMessages = [...(response.messages ?? [])].reverse(); // After reversing, orderedMessages[0] is the oldest message in this batch const newOldestCursor = orderedMessages[0]?.id ?? null; + // Sync ref immediately so the next intersection callback sees correct state + // (the useEffect sync may not have run yet between store update and next observer fire) + stateRef.current = { + hasMore: response.has_more, + oldestCursor: newOldestCursor, + isLoading: false, + }; prependMessages(sessionId, orderedMessages, { hasMore: response.has_more, oldestCursor: newOldestCursor, @@ -48,6 +57,7 @@ export function useLazyLoadMessages(sessionId: string | null) { return orderedMessages.length; } catch (error) { console.error("[useLazyLoadMessages] Error loading messages:", error); + stateRef.current.isLoading = false; setMessagesMetadata(sessionId, { isLoading: false }); return 0; } diff --git a/apps/web/hooks/use-resizable-input.ts b/apps/web/hooks/use-resizable-input.ts index f2e110ce3e..1d46cff4ed 100644 --- a/apps/web/hooks/use-resizable-input.ts +++ b/apps/web/hooks/use-resizable-input.ts @@ -7,17 +7,29 @@ const MIN_HEIGHT = 100; const DEFAULT_HEIGHT = 134; const MAX_ABSOLUTE = 450; -export function useResizableInput(sessionId: string | undefined) { +function clampHeight(value: number): number { + const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); + return Math.min(Math.max(value, MIN_HEIGHT), maxHeight); +} + +/** Measure the natural content height of an editor inside its container. */ +function measureContentHeight(container: HTMLElement, content: HTMLElement): number { + const chrome = container.offsetHeight - content.offsetHeight; + const prev = content.style.height; + content.style.height = "0px"; + const naturalHeight = content.scrollHeight; + content.style.height = prev; + return clampHeight(naturalHeight + chrome); +} + +export function useResizableInput( + sessionId: string | undefined, + getContentElement?: () => HTMLElement | null, +) { const [height, setHeight] = useState(() => { - const maxHeight = - typeof window !== "undefined" - ? Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE) - : MAX_ABSOLUTE; - if (sessionId) { - const saved = getChatInputHeight(sessionId); - return saved ? Math.min(saved, maxHeight) : DEFAULT_HEIGHT; - } - return DEFAULT_HEIGHT; + if (typeof window === "undefined" || !sessionId) return DEFAULT_HEIGHT; + const saved = getChatInputHeight(sessionId); + return saved ? clampHeight(saved) : DEFAULT_HEIGHT; }); const containerRef = useRef(null); const isDragging = useRef(false); @@ -29,13 +41,8 @@ export function useResizableInput(sessionId: string | undefined) { if (sessionId === prevSessionRef.current) return; prevSessionRef.current = sessionId; /* eslint-disable react-hooks/set-state-in-effect -- syncing from localStorage on session switch */ - if (sessionId) { - const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); - const saved = getChatInputHeight(sessionId); - setHeight(saved ? Math.min(saved, maxHeight) : DEFAULT_HEIGHT); - } else { - setHeight(DEFAULT_HEIGHT); - } + const saved = sessionId ? getChatInputHeight(sessionId) : null; + setHeight(saved ? clampHeight(saved) : DEFAULT_HEIGHT); /* eslint-enable react-hooks/set-state-in-effect */ }, [sessionId]); @@ -66,18 +73,11 @@ export function useResizableInput(sessionId: string | undefined) { persistHeight(DEFAULT_HEIGHT); }, [persistHeight]); - const handleDoubleClick = useCallback(() => { - resetHeight(); - }, [resetHeight]); - useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isDragging.current) return; - // Dragging UP (clientY decreases) increases height const delta = startY.current - e.clientY; - const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); - const newHeight = Math.min(Math.max(startHeight.current + delta, MIN_HEIGHT), maxHeight); - setHeight(newHeight); + setHeight(clampHeight(startHeight.current + delta)); }; const handleMouseUp = () => { @@ -100,17 +100,16 @@ export function useResizableInput(sessionId: string | undefined) { }; }, [persistHeight]); - /** Expand the input to fit content, clamped to max height. Does not shrink below user-set height. */ const autoExpand = useCallback(() => { - const el = containerRef.current; - if (!el || isDragging.current) return; - const maxHeight = Math.min(window.innerHeight * 0.6, MAX_ABSOLUTE); - if (el.scrollHeight > height) { - const next = Math.min(el.scrollHeight, maxHeight); - setHeight(next); - persistHeight(next); + const container = containerRef.current; + const content = getContentElement?.(); + if (!container || !content || isDragging.current) return; + const needed = measureContentHeight(container, content); + if (needed !== height) { + setHeight(needed); + persistHeight(needed); } - }, [height, persistHeight]); + }, [height, persistHeight, getContentElement]); return { height, @@ -119,7 +118,7 @@ export function useResizableInput(sessionId: string | undefined) { containerRef, resizeHandleProps: { onMouseDown: handleMouseDown, - onDoubleClick: handleDoubleClick, + onDoubleClick: resetHeight, }, }; } diff --git a/apps/web/hooks/use-task-actions.ts b/apps/web/hooks/use-task-actions.ts index 1ce17a776b..34314bae02 100644 --- a/apps/web/hooks/use-task-actions.ts +++ b/apps/web/hooks/use-task-actions.ts @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { archiveTask, deleteTask, moveTask } from "@/lib/api"; +import { archiveTask, deleteTask, moveTask, updateTask } from "@/lib/api"; type MovePayload = { workflow_id: string; workflow_step_id: string; position: number }; @@ -16,5 +16,9 @@ export function useTaskActions() { return archiveTask(taskId); }, []); - return { moveTaskById, deleteTaskById, archiveTaskById }; + const renameTaskById = useCallback(async (taskId: string, title: string) => { + return updateTask(taskId, { title }); + }, []); + + return { moveTaskById, deleteTaskById, archiveTaskById, renameTaskById }; } diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 3ba4cec52f..5a9286ec59 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -17,6 +17,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { state: task.state, repositoryId: task.repositories?.[0]?.repository_id ?? undefined, primarySessionId: task.primary_session_id ?? undefined, + primarySessionState: task.primary_session_state ?? undefined, sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, updatedAt: task.updated_at, diff --git a/apps/web/lib/state/dockview-file-state.ts b/apps/web/lib/state/dockview-file-state.ts new file mode 100644 index 0000000000..a9469a3d95 --- /dev/null +++ b/apps/web/lib/state/dockview-file-state.ts @@ -0,0 +1,40 @@ +import type { FileEditorState } from "./dockview-store"; + +type StoreSet = ( + partial: + | { openFiles: Map } + | ((s: { openFiles: Map }) => { + openFiles: Map; + }), +) => void; + +export function buildFileStateActions(set: StoreSet) { + return { + setFileState: (path: string, state: FileEditorState) => { + set((prev) => { + const m = new Map(prev.openFiles); + m.set(path, state); + return { openFiles: m }; + }); + }, + updateFileState: (path: string, updates: Partial) => { + set((prev) => { + const e = prev.openFiles.get(path); + if (!e) return prev as { openFiles: Map }; + const m = new Map(prev.openFiles); + m.set(path, { ...e, ...updates }); + return { openFiles: m }; + }); + }, + removeFileState: (path: string) => { + set((prev) => { + const m = new Map(prev.openFiles); + m.delete(path); + return { openFiles: m }; + }); + }, + clearFileStates: () => { + set({ openFiles: new Map() }); + }, + }; +} diff --git a/apps/web/lib/state/dockview-layout-builders.ts b/apps/web/lib/state/dockview-layout-builders.ts index 0087a650d3..a525562f32 100644 --- a/apps/web/lib/state/dockview-layout-builders.ts +++ b/apps/web/lib/state/dockview-layout-builders.ts @@ -1,5 +1,12 @@ import type { DockviewApi, AddPanelOptions } from "dockview-react"; -import { SIDEBAR_LOCK, LAYOUT_SIDEBAR_MAX_PX, resolveGroupIds } from "./layout-manager"; +import { + SIDEBAR_LOCK, + LAYOUT_SIDEBAR_MAX_PX, + LAYOUT_RIGHT_MAX_PX, + RIGHT_TOP_GROUP, + RIGHT_BOTTOM_GROUP, + resolveGroupIds, +} from "./layout-manager"; import type { LayoutGroupIds } from "./layout-manager"; // Re-export for consumers that import from this module @@ -18,6 +25,16 @@ export function applyLayoutFixups(api: DockviewApi): LayoutGroupIds { const oldFiles = api.getPanel("all-files"); if (oldFiles) oldFiles.api.setTitle("Files"); + // Constrain right column groups by their well-known IDs. + // Groups created from presets carry stable IDs (e.g. "group-right-top"), + // so this works regardless of which panels are in them. + for (const gid of [RIGHT_TOP_GROUP, RIGHT_BOTTOM_GROUP]) { + const group = api.groups.find((g) => g.id === gid); + if (group) { + group.api.setConstraints({ maximumWidth: LAYOUT_RIGHT_MAX_PX }); + } + } + return resolveGroupIds(api); } diff --git a/apps/web/lib/state/dockview-store.ts b/apps/web/lib/state/dockview-store.ts index c7b3b5d213..eee98c9655 100644 --- a/apps/web/lib/state/dockview-store.ts +++ b/apps/web/lib/state/dockview-store.ts @@ -16,6 +16,7 @@ import { mergeCurrentPanelsIntoPreset, } from "./layout-manager"; import type { BuiltInPreset, LayoutState, LayoutGroupIds } from "./layout-manager"; +import { buildFileStateActions } from "./dockview-file-state"; // Re-export types and constants used by other modules export type { BuiltInPreset } from "./layout-manager"; @@ -110,6 +111,7 @@ type DockviewStore = { setPinnedWidth: (columnId: string, width: number) => void; userDefaultLayout: LayoutState | null; setUserDefaultLayout: (layout: LayoutState | null) => void; + activeFilePath: string | null; }; type StoreGet = () => DockviewStore; @@ -137,17 +139,22 @@ function applyDeferredPanelActions(api: DockviewApi, actions: DeferredPanelActio } } -/** Read live column widths from dockview's splitview and persist them as pinned overrides. */ +/** Read live column widths from dockview's splitview and persist them as pinned overrides. + * Only syncs widths for columns identified as "sidebar" or "right" to avoid + * capturing plan/preview/vscode column widths as stale "right" overrides. */ function syncPinnedWidthsFromApi(api: DockviewApi, set: StoreSet): void { const sv = getRootSplitview(api); if (!sv || sv.length < 2) return; try { + const state = fromDockviewApi(api); + if (state.columns.length !== sv.length) return; const updates = new Map(); - const sidebarW = sv.getViewSize(0); - if (sidebarW > 50) updates.set("sidebar", sidebarW); - if (sv.length >= 3) { - const rightW = sv.getViewSize(sv.length - 1); - if (rightW > 50) updates.set("right", rightW); + for (let i = 0; i < state.columns.length; i++) { + const col = state.columns[i]; + if (col.id === "sidebar" || col.id === "right") { + const w = sv.getViewSize(i); + if (w > 50) updates.set(col.id, w); + } } if (updates.size > 0) { set((prev) => { @@ -167,12 +174,17 @@ function captureLiveWidths(api: DockviewApi, set: StoreSet): Map return useDockviewStore.getState().pinnedWidths; } -function performSessionSwitch( - api: DockviewApi, - oldSessionId: string | null, - newSessionId: string, - buildDefault: (api: DockviewApi) => void, -): void { +type SessionSwitchParams = { + api: DockviewApi; + oldSessionId: string | null; + newSessionId: string; + safeWidth: number; + safeHeight: number; + buildDefault: (api: DockviewApi) => void; +}; + +function performSessionSwitch(params: SessionSwitchParams): LayoutGroupIds { + const { api, oldSessionId, newSessionId, safeWidth, safeHeight, buildDefault } = params; if (oldSessionId) { try { setSessionLayout(oldSessionId, api.toJSON()); @@ -184,43 +196,15 @@ function performSessionSwitch( if (saved) { try { api.fromJSON(saved as SerializedDockview); - return; + api.layout(safeWidth, safeHeight); + return applyLayoutFixups(api); } catch { /* fall through */ } } buildDefault(api); -} - -function buildFileStateActions(set: StoreSet) { - return { - setFileState: (path: string, state: FileEditorState) => { - set((prev) => { - const m = new Map(prev.openFiles); - m.set(path, state); - return { openFiles: m }; - }); - }, - updateFileState: (path: string, updates: Partial) => { - set((prev) => { - const e = prev.openFiles.get(path); - if (!e) return prev; - const m = new Map(prev.openFiles); - m.set(path, { ...e, ...updates }); - return { openFiles: m }; - }); - }, - removeFileState: (path: string) => { - set((prev) => { - const m = new Map(prev.openFiles); - m.delete(path); - return { openFiles: m }; - }); - }, - clearFileStates: () => { - set({ openFiles: new Map() }); - }, - }; + api.layout(safeWidth, safeHeight); + return applyLayoutFixups(api); } function applyLayoutAndSet( @@ -271,9 +255,11 @@ function buildVisibilityActions(set: StoreSet, get: StoreGet) { const liveWidths = captureLiveWidths(api, set); if (rightPanelsVisible) { const current = fromDockviewApi(api); - const centerIdx = current.columns.findIndex((c) => c.id === "center"); const withoutRight: LayoutState = { - columns: current.columns.slice(0, centerIdx + 1), + columns: current.columns.filter( + (c) => + !c.groups.some((g) => g.panels.some((p) => p.id === "files" || p.id === "changes")), + ), }; set({ isRestoringLayout: true, rightPanelsVisible: false }); applyLayoutAndSet(api, withoutRight, liveWidths, set); @@ -316,17 +302,28 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const { api } = get(); if (!api) return; const liveWidths = captureLiveWidths(api, set); + // Capture dimensions before layout change — api.width can become stale + // inside the rAF callback after dockview serialization + const safeWidth = api.width; + const safeHeight = api.height; set({ isRestoringLayout: true }); const presetState = getPresetLayout(preset); const state = mergeCurrentPanelsIntoPreset(api, presetState); - const ids = applyLayout(api, state, liveWidths); + // Remove stale pinned overrides for columns absent in the target layout + const targetColumnIds = new Set(state.columns.map((c) => c.id)); + const cleanedWidths = new Map(liveWidths); + for (const key of cleanedWidths.keys()) { + if (!targetColumnIds.has(key)) cleanedWidths.delete(key); + } + const ids = applyLayout(api, state, cleanedWidths); set({ ...ids, sidebarVisible: true, rightPanelsVisible: preset === "default", + pinnedWidths: cleanedWidths, }); requestAnimationFrame(() => { - api.layout(api.width, api.height); + api.layout(safeWidth, safeHeight); syncPinnedWidthsFromApi(api, set); set({ isRestoringLayout: false }); }); @@ -335,6 +332,8 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const { api } = get(); if (!api) return; const liveWidths = captureLiveWidths(api, set); + const safeWidth = api.width; + const safeHeight = api.height; set({ isRestoringLayout: true }); const state = layout.layout as unknown as LayoutState; if (!state?.columns) { @@ -354,7 +353,7 @@ function buildPresetActions(set: StoreSet, get: StoreGet) { const hasRight = colCount > sidebarCols + 1; set({ sidebarVisible: hasSidebar, rightPanelsVisible: hasRight }); requestAnimationFrame(() => { - api.layout(api.width, api.height); + api.layout(safeWidth, safeHeight); syncPinnedWidthsFromApi(api, set); set({ isRestoringLayout: false }); }); @@ -537,7 +536,16 @@ function buildExtraPanelActions(_set: StoreSet, get: StoreGet) { export const useDockviewStore = create((set, get) => ({ api: null, - setApi: (api) => set({ api }), + activeFilePath: null, + setApi: (api) => { + set({ api, activeFilePath: null }); + if (api) { + api.onDidActivePanelChange((event) => { + const id = event?.id; + set({ activeFilePath: id?.startsWith("file:") ? id.slice(5) : null }); + }); + } + }, activeGroupId: null, selectedDiff: null, setSelectedDiff: (diff) => set({ selectedDiff: diff }), @@ -571,10 +579,19 @@ export const useDockviewStore = create((set, get) => ({ switchSessionLayout: (oldSessionId, newSessionId) => { const { api, currentLayoutSessionId } = get(); if (!api || currentLayoutSessionId === newSessionId) return; + const safeWidth = api.width; + const safeHeight = api.height; set({ isRestoringLayout: true, currentLayoutSessionId: newSessionId }); try { - performSessionSwitch(api, oldSessionId, newSessionId, (a) => get().buildDefaultLayout(a)); - if (getSessionLayout(newSessionId)) set(applyLayoutFixups(api)); + const ids = performSessionSwitch({ + api, + oldSessionId, + newSessionId, + safeWidth, + safeHeight, + buildDefault: (a) => get().buildDefaultLayout(a), + }); + set(ids); } finally { set({ isRestoringLayout: false }); } diff --git a/apps/web/lib/state/layout-manager/applier.ts b/apps/web/lib/state/layout-manager/applier.ts index 78fc8be784..db54d9a003 100644 --- a/apps/web/lib/state/layout-manager/applier.ts +++ b/apps/web/lib/state/layout-manager/applier.ts @@ -25,16 +25,19 @@ export function getRootSplitview(api: DockviewApi): any | null { return sv?.resizeView && sv?.getViewSize ? sv : null; } -export function resolveGroupIds(api: DockviewApi): LayoutGroupIds { - const sidebar = api.getPanel("sidebar"); - const changes = api.getPanel("changes") ?? api.getPanel("diff-files"); - const term = api.panels.find((p) => p.id.startsWith("terminal-") || p.id === TERMINAL_DEFAULT_ID); +/** Find a group by well-known ID, falling back to panel-based lookup. */ +function findGroupId(api: DockviewApi, knownId: string, fallbackPanelId: string): string { + if (api.groups.some((g) => g.id === knownId)) return knownId; + const pnl = api.getPanel(fallbackPanelId); + return pnl?.group?.id ?? knownId; +} +export function resolveGroupIds(api: DockviewApi): LayoutGroupIds { return { - sidebarGroupId: sidebar?.group?.id ?? SIDEBAR_GROUP, - centerGroupId: api.getPanel("chat")?.group?.id ?? CENTER_GROUP, - rightTopGroupId: changes?.group?.id ?? RIGHT_TOP_GROUP, - rightBottomGroupId: term?.group?.id ?? RIGHT_BOTTOM_GROUP, + sidebarGroupId: findGroupId(api, SIDEBAR_GROUP, "sidebar"), + centerGroupId: findGroupId(api, CENTER_GROUP, "chat"), + rightTopGroupId: findGroupId(api, RIGHT_TOP_GROUP, "changes"), + rightBottomGroupId: findGroupId(api, RIGHT_BOTTOM_GROUP, TERMINAL_DEFAULT_ID), }; } @@ -60,5 +63,19 @@ export function applyLayout( sb.group.api.setConstraints({ maximumWidth: LAYOUT_SIDEBAR_MAX_PX }); } + // Enforce max-width on other pinned columns (e.g. right panel group). + for (const col of state.columns) { + if (col.id === "sidebar" || !col.pinned || !col.maxWidth) continue; + for (const group of col.groups) { + for (const p of group.panels) { + const pnl = api.getPanel(p.id); + if (pnl) { + pnl.group.api.setConstraints({ maximumWidth: col.maxWidth }); + break; + } + } + } + } + return resolveGroupIds(api); } diff --git a/apps/web/lib/state/layout-manager/constants.ts b/apps/web/lib/state/layout-manager/constants.ts index 27475b53fa..f7d4f1231a 100644 --- a/apps/web/lib/state/layout-manager/constants.ts +++ b/apps/web/lib/state/layout-manager/constants.ts @@ -4,7 +4,7 @@ import type { LayoutPanel } from "./types"; export const LAYOUT_SIDEBAR_RATIO = 2.5 / 10; export const LAYOUT_RIGHT_RATIO = 2.5 / 10; export const LAYOUT_SIDEBAR_MAX_PX = 350; -export const LAYOUT_RIGHT_MAX_PX = 400; +export const LAYOUT_RIGHT_MAX_PX = 450; // Well-known group/panel IDs export const SIDEBAR_GROUP = "group-sidebar"; diff --git a/apps/web/lib/state/layout-manager/presets.ts b/apps/web/lib/state/layout-manager/presets.ts index 259b18cc4e..31f5a939d6 100644 --- a/apps/web/lib/state/layout-manager/presets.ts +++ b/apps/web/lib/state/layout-manager/presets.ts @@ -1,5 +1,13 @@ import type { LayoutState } from "./types"; -import { LAYOUT_SIDEBAR_MAX_PX, LAYOUT_RIGHT_MAX_PX, panel } from "./constants"; +import { + LAYOUT_SIDEBAR_MAX_PX, + LAYOUT_RIGHT_MAX_PX, + SIDEBAR_GROUP, + CENTER_GROUP, + RIGHT_TOP_GROUP, + RIGHT_BOTTOM_GROUP, + panel, +} from "./constants"; export function defaultLayout(): LayoutState { return { @@ -8,19 +16,20 @@ export function defaultLayout(): LayoutState { id: "sidebar", pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX, - groups: [{ panels: [panel("sidebar")] }], + groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { id: "center", - groups: [{ panels: [panel("chat")] }], + groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], }, { id: "right", pinned: true, - maxWidth: LAYOUT_SIDEBAR_MAX_PX, + width: 350, + maxWidth: LAYOUT_RIGHT_MAX_PX, groups: [ - { panels: [panel("files"), panel("changes")] }, - { panels: [panel("terminal-default")] }, + { id: RIGHT_TOP_GROUP, panels: [panel("files"), panel("changes")] }, + { id: RIGHT_BOTTOM_GROUP, panels: [panel("terminal-default")] }, ], }, ], @@ -34,11 +43,11 @@ export function planLayout(): LayoutState { id: "sidebar", pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX, - groups: [{ panels: [panel("sidebar")] }], + groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { id: "center", - groups: [{ panels: [panel("chat")] }], + groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], }, { id: "plan", @@ -55,11 +64,11 @@ export function previewLayout(): LayoutState { id: "sidebar", pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX, - groups: [{ panels: [panel("sidebar")] }], + groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { id: "center", - groups: [{ panels: [panel("chat")] }], + groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], }, { id: "preview", @@ -76,11 +85,11 @@ export function vscodeLayout(): LayoutState { id: "sidebar", pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX, - groups: [{ panels: [panel("sidebar")] }], + groups: [{ id: SIDEBAR_GROUP, panels: [panel("sidebar")] }], }, { id: "center", - groups: [{ panels: [panel("chat")] }], + groups: [{ id: CENTER_GROUP, panels: [panel("chat")] }], }, { id: "right", diff --git a/apps/web/lib/state/layout-manager/serializer.ts b/apps/web/lib/state/layout-manager/serializer.ts index f2a23ed73c..b68b2370a1 100644 --- a/apps/web/lib/state/layout-manager/serializer.ts +++ b/apps/web/lib/state/layout-manager/serializer.ts @@ -6,6 +6,7 @@ import { KNOWN_PANEL_IDS, STRUCTURAL_COMPONENTS, LAYOUT_SIDEBAR_MAX_PX, + LAYOUT_RIGHT_MAX_PX, } from "./constants"; // Dockview serialized grid node types (internal format) @@ -43,7 +44,7 @@ function serializeGroup( size: number, ctx: SerializationCtx, ): SerializedLeafNode { - const groupId = nextGroupId(ctx); + const groupId = group.id ?? nextGroupId(ctx); const views = group.panels.map((p) => p.id); const isSidebar = columnId === "sidebar"; return { @@ -204,7 +205,7 @@ function groupFromGridNode(node: any): LayoutGroup | null { const panels: LayoutPanel[] = (group.panels ?? []).map(panelFromDockviewPanel); if (panels.length === 0) return null; - return { panels, activePanel: group.activePanel?.id }; + return { id: group.id, panels, activePanel: group.activePanel?.id }; } /** Recursively capture a dockview grid node into a LayoutNode + flat groups. */ @@ -234,6 +235,9 @@ function captureNode( return { type: "leaf", group, size }; } +/** Panel IDs that indicate the "right" column. */ +const RIGHT_PANEL_IDS = new Set(["files", "changes"]); + /** Determine column ID and pinned status from its groups. */ function inferColumnMeta( groups: LayoutGroup[], @@ -241,11 +245,17 @@ function inferColumnMeta( ): { columnId: string; isPinned: boolean } { if (groups.length === 0) return { columnId: `col-${index}`, isPinned: false }; - const hasSidebar = groups[0].panels.some((p) => p.id === "sidebar"); - if (hasSidebar) return { columnId: "sidebar", isPinned: true }; + const allPanelIds = new Set(groups.flatMap((g) => g.panels.map((p) => p.id))); + + if (allPanelIds.has("sidebar")) return { columnId: "sidebar", isPinned: true }; + if (allPanelIds.has("chat")) return { columnId: "center", isPinned: false }; + + // Column containing files/changes panels is the "right" column + for (const id of allPanelIds) { + if (RIGHT_PANEL_IDS.has(id)) return { columnId: "right", isPinned: true }; + } const firstPanelId = groups[0].panels[0]?.id; - if (firstPanelId === "chat") return { columnId: "center", isPinned: false }; if (firstPanelId) return { columnId: firstPanelId, isPinned: false }; return { columnId: `col-${index}`, isPinned: false }; @@ -277,7 +287,12 @@ export function fromDockviewApi(api: DockviewApi): LayoutState { columns.push({ id: columnId, - ...(isPinned ? { pinned: true, maxWidth: LAYOUT_SIDEBAR_MAX_PX } : {}), + ...(isPinned + ? { + pinned: true, + maxWidth: columnId === "right" ? LAYOUT_RIGHT_MAX_PX : LAYOUT_SIDEBAR_MAX_PX, + } + : {}), width, groups: flatGroups, // Only store tree for columns with nested structure (branches) diff --git a/apps/web/lib/state/layout-manager/types.ts b/apps/web/lib/state/layout-manager/types.ts index 060cbdbb88..b19457c9f0 100644 --- a/apps/web/lib/state/layout-manager/types.ts +++ b/apps/web/lib/state/layout-manager/types.ts @@ -9,6 +9,7 @@ export type LayoutPanel = { /** A group within a column (vertical slice). Contains tabs. */ export type LayoutGroup = { + id?: string; panels: LayoutPanel[]; activePanel?: string; }; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index f1b9350d8a..ef817fa1cf 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -28,6 +28,7 @@ export type KanbanState = { state?: TaskStatus; repositoryId?: string; primarySessionId?: string | null; + primarySessionState?: string | null; sessionCount?: number | null; reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null; primaryExecutorId?: string | null; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index ca57f5361d..5110c3cf7b 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -227,6 +227,7 @@ export type Task = { priority: number; repositories?: TaskRepository[]; primary_session_id?: string | null; + primary_session_state?: TaskSessionState | null; session_count?: number | null; review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; primary_executor_id?: string | null; diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 5c022a5d15..990300b9a0 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -22,6 +22,7 @@ function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask { state: payload.state, repositoryId: withFallback(payload.repository_id, existing?.repositoryId), primarySessionId: withFallback(payload.primary_session_id, existing?.primarySessionId), + primarySessionState: withFallback(payload.primary_session_state, existing?.primarySessionState), sessionCount: withFallback(payload.session_count, existing?.sessionCount), reviewStatus: withFallback(payload.review_status, existing?.reviewStatus), primaryExecutorId: withFallback(payload.primary_executor_id, existing?.primaryExecutorId), diff --git a/apps/web/package.json b/apps/web/package.json index a070a9a0c2..c075143fe9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,7 +37,6 @@ "@pierre/diffs": "^1.0.11", "@tabler/icons-react": "^3.36.1", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.18", "@tiptap/core": "^3.19.0", "@tiptap/extension-bubble-menu": "^3.19.0", "@tiptap/extension-code": "^3.19.0", @@ -93,6 +92,7 @@ "react-dom": "19.2.3", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.4.1", + "react-virtuoso": "^4.18.1", "recharts": "2.15.4", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", diff --git a/docs/task_session_resume.md b/docs/task_session_resume.md new file mode 100644 index 0000000000..3f2f18a99f --- /dev/null +++ b/docs/task_session_resume.md @@ -0,0 +1,358 @@ +# Task Session Resume + +How sessions are recovered and resumed after a backend restart or agent failure. + +--- + +## Overview + +When the backend restarts, running agent processes are lost but all session state is persisted in SQLite (`TaskSession`, `ExecutorRunning`). The system uses a **lazy recovery** pattern: sessions are not immediately restarted. Instead, when a user navigates to a session, the frontend detects `NeedsResume` and triggers the resume flow, which relaunches agentctl + the agent process. + +Two resume strategies exist depending on agent capabilities: + +| Strategy | When used | What happens | +|----------|-----------|--------------| +| **Native resume** (ACP `session/load`) | Agent has `NativeSessionResume: true` and a stored resume token | Agent restores its own conversation context via ACP protocol | +| **History injection** (fresh start) | Agent has `HistoryContextInjection: true`, no resume token | Agent starts fresh; conversation history is injected into the first user prompt | + +Agents with neither flag simply boot idle with no context restoration. + +--- + +## Resume Flow + +``` +Frontend Backend (Orchestrator) Lifecycle Manager + | | | + | task.session.status | | + |----------------------------->| | + | { needs_resume: true } | | + |<-----------------------------| | + | | | + | task.session.resume | | + |----------------------------->| ResumeTaskSession() | + | | executor.ResumeSession() | + | |------> buildResumeRequest() | + | |------> LaunchAgent() (agentctl) | + | |------> persistResumeState() | + | |------> startAgentProcessOnResume()| + | | | | + | | | (async goroutine) | + | | |----------------------->| StartAgentProcess() + | | | | configureAndStartAgent() + | | | | initializeACPSession() + | | | | dispatchInitialPrompt() + | | | | -> (see three-way switch) + | | | | + | | | callback: restore | + | | | WAITING_FOR_INPUT | + |<--- WS events (state) ------|----------|------------------------| +``` + +--- + +## Entry Points + +### 1. Frontend auto-resume (primary) + +**`hooks/domains/session/use-session-resumption.ts`** + +When a user opens a session page, the `useSessionResumption` hook: +1. Sends `task.session.status` to backend +2. If `needs_resume && is_resumable` -> sends `task.session.resume` +3. Uses `hasAttemptedResume` ref to prevent duplicate resume requests + +### 2. `GetTaskSessionStatus` (status evaluation) + +**`internal/orchestrator/task_operations.go`** ~line 629 + +Backend method backing `task.session.status`. Evaluates resume eligibility through 4 cases: + +| Case | Condition | NeedsResume | Reason | +|------|-----------|:-----------:|--------| +| 1 | Agent is running in memory | false | Already running | +| 2 | Agent not running but was recently seen | false | Stale execution | +| 3 | Resume token exists + active state | true | `agent_not_running` | +| 4 | No token but `ExecutorRunning` exists + active state | true | `agent_not_running_fresh_start` | + +Active session states: `WAITING_FOR_INPUT`, `STARTING`, `RUNNING`. +Terminal states (block resume): `FAILED`, `COMPLETED`, `CANCELLED`. + +### 3. `ensureSessionRunning` (lazy resume on prompt) + +**`internal/orchestrator/task_operations.go`** ~line 548 + +Fallback path: when `PromptTask` is called but no in-memory execution exists (post-restart), it triggers `ResumeSession()` and polls until `WAITING_FOR_INPUT` is reached (500ms intervals, 90s timeout). + +### 4. `handlePromptWithResume` (prompt retry) + +**`internal/task/handlers/message_handlers.go`** ~line 393 + +If a prompt fails with `ErrExecutionNotFound`, the handler calls `ResumeTaskSession()`, waits for ready, and retries the prompt. + +--- + +## Resume Execution (Backend) + +### `ResumeTaskSession` + +**`internal/orchestrator/task_operations.go`** ~line 402 + +1. Load session + `ExecutorRunning` from DB +2. Validate worktree paths exist on disk +3. Reject terminal states +4. Call `executor.ResumeSession(ctx, session, startAgent=true)` +5. On failure: mark session + task `FAILED` + +### `Executor.ResumeSession` + +**`internal/orchestrator/executor/executor_resume.go`** ~line 133 + +1. **`validateAndLockResume()`** -- acquire per-session mutex, verify no existing execution +2. **`buildResumeRequest()`** -- build `LaunchAgentRequest` from stored config +3. **`LaunchAgent()`** -- create agentctl instance (runtime-level) +4. **`persistResumeState()`** -- update session state to `STARTING` in DB +5. **`startAgentProcessOnResume()`** -- async goroutine to start agent subprocess + +### `buildResumeRequest` (prompt decision) + +**`internal/orchestrator/executor/executor_resume.go`** ~line 270 + +This is where the system decides whether to auto-prompt the agent: + +```go +// Case A: Native resume -- has resume token +if running.ResumeToken != "" && startAgent { + req.ACPSessionID = running.ResumeToken // triggers session/load + req.TaskDescription = "" // don't auto-prompt +} + +// Case B: Fresh-start resume -- no token, was WAITING_FOR_INPUT +else if startAgent && session.State == WAITING_FOR_INPUT { + req.TaskDescription = "" // don't auto-prompt; boot idle +} + +// Case C: Otherwise -- TaskDescription stays populated (initial launch) +``` + +Clearing `TaskDescription` is critical: it controls whether `dispatchInitialPrompt` sends an automatic prompt or lets the agent boot idle. + +### `startAgentProcessOnResume` + +**`internal/orchestrator/executor/executor_resume.go`** ~line 488 + +Runs `StartAgentProcess` in a background goroutine. On success, the callback restores the session to `WAITING_FOR_INPUT` and the task to `REVIEW` (if the session was previously in `WAITING_FOR_INPUT`). This uses a captured `previousState` variable because `persistResumeState` mutates the session state to `STARTING` before the goroutine runs. + +--- + +## Agent Process Initialization + +### `StartAgentProcess` + +**`internal/agent/lifecycle/manager_startup.go`** ~line 22 + +1. Wait for agentctl HTTP server ready (60s timeout) +2. Configure agent (command, env, working directory) +3. Start agent subprocess via agentctl +4. Initialize ACP session (`session/new` or `session/load`) +5. Call `dispatchInitialPrompt()` + +### ACP session creation: `session/load` vs `session/new` + +**`internal/agent/lifecycle/session.go`** ~line 118 + +``` +If agent has NativeSessionResume: true AND existingSessionID is non-empty: + -> Try session/load (ACP protocol) + -> On failure (method not found / capability false): fallback to session/new +Otherwise: + -> session/new +``` + +### `dispatchInitialPrompt` (three-way switch) + +**`internal/agent/lifecycle/session.go`** ~line 338 + +| Condition | Behavior | +|-----------|----------| +| `taskDescription` is non-empty | Send it as the initial prompt (standard launch) | +| `taskDescription` empty + `shouldInjectResumeContext()` returns true | Set `needsResumeContext = true`, mark agent ready (idle). History injected on first user prompt. | +| Neither | Mark agent ready (idle, no context) | + +--- + +## History Injection + +For agents that cannot natively restore their session (no ACP `session/load`), conversation history can be injected into the first user prompt after resume. This is opt-in via the `HistoryContextInjection` flag on `SessionConfig`. + +### Recording + +**`internal/agent/lifecycle/session_history.go`** + +`SessionHistoryManager` stores history as JSONL files at `~/.kandev/sessions/{sessionID}.jsonl`. + +Entry types: `user_message`, `agent_message`, `tool_call`, `tool_result`. + +Recording happens in: +- `SendPrompt()` -- records user messages (`session.go` ~line 510) +- `handleAgentEvent()` -- records agent messages and tool calls (`manager_events.go`) + +### Injection + +When `shouldInjectResumeContext()` returns true (checks `HistoryContextInjection` flag + history file exists), the system defers injection: + +1. `dispatchInitialPrompt()` sets `execution.needsResumeContext = true` +2. Agent boots idle, session goes to `WAITING_FOR_INPUT` +3. User sends a message +4. `SendPrompt()` calls `buildEffectivePrompt()` (`session.go` ~line 382) +5. `buildEffectivePrompt()` detects `needsResumeContext = true`, calls `GenerateResumeContext()` +6. Sets `resumeContextInjected = true` (one-time injection) + +### Resume context format + +`GenerateResumeContext()` (`session_history.go` ~line 207) produces: + +``` +RESUME CONTEXT FOR CONTINUING TASK + +=== EXECUTION HISTORY === +[USER]: +[ASSISTANT]: +[TOOL CALL: tool_name] +[TOOL RESULT: tool_name] + +=== CURRENT REQUEST === + + +=== INSTRUCTIONS === +You are continuing work on the above task... +Do not repeat work that was already completed. +``` + +Content is truncated: user/agent messages to 2000 chars, tool results to 500 chars. + +### Relevant flags on `AgentExecution` + +**`internal/agent/lifecycle/types.go`** ~line 67 + +| Field | Purpose | +|-------|---------| +| `historyEnabled` | Gates both recording and injection. Set from `SessionConfig.HistoryContextInjection`. | +| `needsResumeContext` | Set true when history exists and should be injected on next prompt. | +| `resumeContextInjected` | Set true after context has been injected (prevents double injection). | + +--- + +## Backend Restart Recovery + +### Runtime-level recovery (`RecoverInstances`) + +**`internal/agent/lifecycle/manager_lifecycle.go`** ~line 21 + +On `Manager.Start()`, each runtime recovers what it can: + +| Runtime | Recovery behavior | +|---------|------------------| +| **Docker** | Lists containers with `kandev.managed=true`, recovers running ones, reconnects agentctl | +| **Standalone** | Returns nil -- processes are transient, restarted on resume | +| **Sprites** | Returns nil -- reconnected via metadata on resume | +| **Remote Docker** | Returns nil | + +### Lazy recovery pattern + +After a backend restart: +1. Docker containers may be recovered immediately (agentctl still running) +2. Standalone agent processes are NOT recovered at runtime level +3. All session state persists in SQLite (`ExecutorRunning` retains worktree paths, resume tokens) +4. When frontend connects and opens a session, `GetTaskSessionStatus()` returns `NeedsResume: true` +5. Frontend auto-triggers `task.session.resume` +6. `ResumeSession()` creates a new agentctl instance and restarts the agent process +7. Depending on agent capabilities: native resume via token, or fresh start with history injection + +--- + +## Session State Transitions + +### Normal resume (success) + +``` +Before restart: Session = WAITING_FOR_INPUT Task = REVIEW + +Resume starts: Session -> STARTING (persistResumeState) + +Agent ready: Session -> WAITING_FOR_INPUT (startAgentProcessOnResume callback) + Task -> REVIEW + +User sends msg: Session -> RUNNING Task -> IN_PROGRESS +Agent completes: Session -> WAITING_FOR_INPUT Task -> REVIEW +``` + +### Resume failure (token invalid) + +``` +Resume attempted: Session -> STARTING +Token rejected: Session -> WAITING_FOR_INPUT (not FAILED) + Task -> REVIEW + ResumeToken -> cleared + Status message shown to user +``` + +The session is NOT marked as failed -- the user can send a new message to start a fresh session. + +--- + +## Agent Resume Capabilities + +| Agent | NativeSessionResume | HistoryContextInjection | Notes | +|-------|:---:|:---:|-------| +| Claude Code | - | - | Uses `--resume` CLI flag for its own session restore | +| Codex | yes | - | ACP `session/load` restores context | +| Copilot | yes | - | ACP `session/load` restores context | +| Auggie | - | yes | Fresh start + history injection on first prompt | +| Gemini | - | - | Boots idle, no context restoration | +| Amp | - | - | Uses `ForkSessionCmd` / `ContinueSessionCmd` | +| OpenCode | yes | - | ACP `session/load` restores context | + +Agent configs are defined in `internal/agent/agents/`. + +--- + +## `ExecutorRunning` Model + +**`internal/task/models/models.go`** ~line 383 + +Persists runtime state for a session across backend restarts: + +| Field | Purpose | +|-------|---------| +| `ResumeToken` | ACP session ID for native resume (`session/load`) | +| `LastMessageUUID` | For `--resume-session-at` flag | +| `ContainerID` | Docker container ID (for recovery) | +| `AgentctlURL` / `AgentctlPort` | agentctl connection info | +| `WorktreeID` / `WorktreePath` / `WorktreeBranch` | Git worktree info (validated on resume) | +| `AgentExecutionID` | Links to in-memory `AgentExecution` | +| `Status` | Current executor status string | + +The resume token is stored/updated in `storeResumeToken()` (`internal/orchestrator/event_handlers.go` ~line 45), called from three event handlers: +- `handleACPSessionCreated` -- on initial session creation +- `handleSessionStatusEvent` -- on session status updates +- `handleCompleteStreamEvent` -- on stream completion + +If the agent's profile does NOT have `NativeSessionResume`, the token is cleared to prevent stale resume attempts. + +--- + +## Key Files + +| File | Contains | +|------|----------| +| `internal/orchestrator/task_operations.go` | `GetTaskSessionStatus`, `ResumeTaskSession`, `ensureSessionRunning` | +| `internal/orchestrator/executor/executor_resume.go` | `ResumeSession`, `buildResumeRequest`, `persistResumeState`, `startAgentProcessOnResume` | +| `internal/agent/lifecycle/session.go` | `dispatchInitialPrompt`, `shouldInjectResumeContext`, `buildEffectivePrompt`, `SendPrompt` | +| `internal/agent/lifecycle/session_history.go` | `SessionHistoryManager`, `GenerateResumeContext`, `HasHistory` | +| `internal/agent/lifecycle/manager_startup.go` | `StartAgentProcess`, `initializeAgentSession` | +| `internal/agent/lifecycle/manager_lifecycle.go` | `RecoverInstances` (runtime-level recovery) | +| `internal/agent/lifecycle/types.go` | `AgentExecution` fields (`historyEnabled`, `needsResumeContext`) | +| `internal/task/models/models.go` | `TaskSession`, `ExecutorRunning`, session state constants | +| `internal/orchestrator/event_handlers.go` | `storeResumeToken` | +| `hooks/domains/session/use-session-resumption.ts` | Frontend resume trigger |