feat: chat improvements (#280)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+14
-20
@@ -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:
|
||||
|
||||
+50
-33
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ChatInputContainerHandle | null>;
|
||||
queuedMessageRef: React.RefObject<QueuedMessageIndicatorHandle | null>;
|
||||
clarificationKey: number;
|
||||
onClarificationResolved: () => void;
|
||||
handleSubmit: (
|
||||
@@ -253,8 +251,6 @@ type ChatInputAreaProps = {
|
||||
inlineMentions?: ContextFile[],
|
||||
) => Promise<void>;
|
||||
handleCancelTurn: () => Promise<void>;
|
||||
handleCancelQueue: () => Promise<void>;
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -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<void>;
|
||||
queuedMessageRef?: React.RefObject<QueuedMessageIndicatorHandle | null>;
|
||||
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 ? (
|
||||
<QueuedMessageIndicator
|
||||
ref={queuedMessageRef}
|
||||
content={queuedMessage!}
|
||||
onCancel={onCancelQueue!}
|
||||
onUpdate={updateQueueContent!}
|
||||
isVisible={true}
|
||||
onEditComplete={onQueueEditComplete}
|
||||
/>
|
||||
) : undefined;
|
||||
const todoSlot = hasTodos ? <TodoSummary todos={todoItems} /> : undefined;
|
||||
return (
|
||||
<ContextZone items={allItems} sessionId={sessionId} queueSlot={queueSlot} todoSlot={todoSlot} />
|
||||
);
|
||||
return <ContextZone items={allItems} sessionId={sessionId} todoSlot={todoSlot} />;
|
||||
}
|
||||
|
||||
export type ChatInputBodyProps = {
|
||||
@@ -217,7 +189,6 @@ export function ChatInputBody({
|
||||
contextAreaProps,
|
||||
editorAreaProps,
|
||||
}: ChatInputBodyProps) {
|
||||
console.log("[ChatInputBody] planModeEnabled:", planModeEnabled);
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TaskCreateDialog } from "@/components/task-create-dialog";
|
||||
import type { ContextFile } from "@/lib/state/context-files-store";
|
||||
import type { Message } from "@/lib/types/http";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { QueuedMessageIndicatorHandle } from "./queued-message-indicator";
|
||||
import { useChatInputContainer } from "./use-chat-input-container";
|
||||
import {
|
||||
ChatInputBody,
|
||||
@@ -66,18 +65,12 @@ type ChatInputContainerProps = {
|
||||
submitKey?: "enter" | "cmd_enter";
|
||||
hasAgentCommands?: boolean;
|
||||
isFailed?: boolean;
|
||||
isQueued?: boolean;
|
||||
contextItems?: ContextItem[];
|
||||
planContextEnabled?: boolean;
|
||||
contextFiles?: ContextFile[];
|
||||
onToggleContextFile?: (file: ContextFile) => void;
|
||||
onAddContextFile?: (file: ContextFile) => void;
|
||||
todoItems?: TodoItem[];
|
||||
queuedMessage?: string | null;
|
||||
onCancelQueue?: () => void;
|
||||
updateQueueContent?: (content: string) => Promise<void>;
|
||||
queuedMessageRef?: React.RefObject<QueuedMessageIndicatorHandle | null>;
|
||||
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<ChatInputContainerHandle, ChatInput
|
||||
const p = {
|
||||
...props,
|
||||
isFailed: isFailed ?? false,
|
||||
isQueued: props.isQueued ?? false,
|
||||
hasAgentCommands: props.hasAgentCommands ?? false,
|
||||
submitKey: props.submitKey ?? "cmd_enter",
|
||||
planContextEnabled: props.planContextEnabled ?? false,
|
||||
@@ -225,21 +211,16 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
|
||||
isFailed: p.isFailed,
|
||||
isAgentBusy,
|
||||
hasAgentCommands: p.hasAgentCommands,
|
||||
isQueued: p.isQueued,
|
||||
placeholder: props.placeholder,
|
||||
contextItems: p.contextItems,
|
||||
pendingClarification: props.pendingClarification,
|
||||
onClarificationResolved: props.onClarificationResolved,
|
||||
pendingCommentsByFile: props.pendingCommentsByFile,
|
||||
hasContextComments: props.hasContextComments ?? false,
|
||||
queuedMessage: props.queuedMessage,
|
||||
onCancelQueue: props.onCancelQueue,
|
||||
updateQueueContent: props.updateQueueContent,
|
||||
todoItems: p.todoItems,
|
||||
showRequestChangesTooltip,
|
||||
onRequestChangesTooltipDismiss: props.onRequestChangesTooltipDismiss,
|
||||
onSubmit: props.onSubmit,
|
||||
queuedMessageRef: props.queuedMessageRef,
|
||||
});
|
||||
|
||||
if (p.isFailed) {
|
||||
|
||||
@@ -7,18 +7,16 @@ import { ContextItemRenderer } from "./context-item-renderer";
|
||||
type ContextZoneProps = {
|
||||
items: ContextItem[];
|
||||
sessionId?: string | null;
|
||||
queueSlot?: ReactNode;
|
||||
todoSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export function ContextZone({ items, sessionId, queueSlot, todoSlot }: ContextZoneProps) {
|
||||
const hasContent = !!queueSlot || !!todoSlot || items.length > 0;
|
||||
export function ContextZone({ items, sessionId, todoSlot }: ContextZoneProps) {
|
||||
const hasContent = !!todoSlot || items.length > 0;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto max-h-[40%] border-b border-border/50 shrink-0">
|
||||
<div className="px-2 pt-2 pb-1 space-y-1.5">
|
||||
{queueSlot}
|
||||
{todoSlot}
|
||||
{items.length > 0 && (
|
||||
<div className="flex items-center gap-1 flex-wrap px-0 py-0.5">
|
||||
|
||||
@@ -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<HTMLDivElement | null>,
|
||||
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<HTMLDivElement | null>,
|
||||
hasMore: boolean,
|
||||
isLoadingMore: boolean,
|
||||
loadMore: () => Promise<number>,
|
||||
) {
|
||||
const stateRef = useRef({ hasMore, isLoadingMore });
|
||||
useEffect(() => {
|
||||
stateRef.current = { hasMore, isLoadingMore };
|
||||
}, [hasMore, isLoadingMore]);
|
||||
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const sentinelNodeRef = useRef<HTMLDivElement | null>(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<HTMLDivElement | null>,
|
||||
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<HTMLDivElement>(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 (
|
||||
<SessionPanelContent ref={scrollRef} className="relative p-4 chat-message-list">
|
||||
{/* Sentinel for lazy loading older messages */}
|
||||
{hasMore && <div ref={sentinelRef} className="h-px" />}
|
||||
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
/>
|
||||
|
||||
{items.map((item) => {
|
||||
const key = getItemKey(item);
|
||||
return (
|
||||
<div key={key} id={`msg-${key}`} className="pb-2" style={{ overflowAnchor: "none" }}>
|
||||
<MessageItem
|
||||
item={item}
|
||||
sessionId={sessionId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
|
||||
isTurnActive={isRunning}
|
||||
messages={messages}
|
||||
sessionState={sessionState}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessionId && <PrepareProgress sessionId={sessionId} />}
|
||||
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
|
||||
|
||||
{/* Bottom anchor — browser keeps scroll pinned here when new content appends */}
|
||||
<div style={{ overflowAnchor: "auto", height: 1 }} />
|
||||
</SessionPanelContent>
|
||||
);
|
||||
});
|
||||
@@ -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<string, Message>;
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
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 && (
|
||||
<div className="text-center text-xs text-muted-foreground py-2">
|
||||
Loading older messages...
|
||||
</div>
|
||||
)}
|
||||
{showLoadingState && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<GridSpinner className="text-primary mr-2" />
|
||||
<span>Loading messages...</span>
|
||||
</div>
|
||||
)}
|
||||
{!messagesLoading && !isInitialLoading && messagesCount === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<span>No messages yet. Start the conversation!</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
item,
|
||||
sessionId,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
isLastGroup,
|
||||
isTurnActive,
|
||||
messages,
|
||||
sessionState,
|
||||
onScrollToMessage,
|
||||
}: {
|
||||
item: RenderItem;
|
||||
sessionId: string | null;
|
||||
permissionsByToolCallId: Map<string, Message>;
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
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 (
|
||||
<TurnGroupMessage
|
||||
group={item}
|
||||
sessionId={sessionId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isLastGroup={isLastGroup}
|
||||
isTurnActive={isTurnActive}
|
||||
allMessages={messages}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageRenderer
|
||||
comment={item.message}
|
||||
isTaskDescription={item.message.id === "task-description"}
|
||||
sessionState={sessionState}
|
||||
taskId={taskId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
worktreePath={worktreePath}
|
||||
sessionId={sessionId ?? undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
allMessages={messages}
|
||||
onScrollToMessage={onScrollToMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<number>;
|
||||
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<IndexState>(() => ({
|
||||
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<VirtuosoHandle>(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 <div />;
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<MessageItem
|
||||
item={item}
|
||||
sessionId={sessionId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isLastGroup={item.type === "turn_group" && item.id === lastTurnGroupId}
|
||||
isTurnActive={isRunning}
|
||||
messages={messages}
|
||||
sessionState={sessionState}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
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 (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
/* Suppress Virtuoso's verbose internal logging in all environments */
|
||||
logLevel={Number.MAX_SAFE_INTEGER}
|
||||
customScrollParent={scrollParent}
|
||||
totalCount={itemCount}
|
||||
firstItemIndex={firstItemIndex}
|
||||
initialTopMostItemIndex={itemCount - 1}
|
||||
computeItemKey={computeItemKey}
|
||||
itemContent={renderItem}
|
||||
followOutput={followOutput}
|
||||
startReached={handleStartReached}
|
||||
increaseViewportBy={200}
|
||||
atBottomThreshold={100}
|
||||
components={{ Header, Footer }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Defer providing scroll parent to Virtuoso until the element has non-zero size. */
|
||||
function useVisibleScrollParent() {
|
||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||
const nodeRef = useRef<HTMLDivElement | null>(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(
|
||||
() => (
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
/>
|
||||
),
|
||||
[isLoadingMore, hasMore, showLoadingState, messagesLoading, isInitialLoading, messages.length],
|
||||
);
|
||||
|
||||
const Footer = useCallback(
|
||||
() => (
|
||||
<>
|
||||
{sessionId && <PrepareProgress sessionId={sessionId} />}
|
||||
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
|
||||
</>
|
||||
),
|
||||
[sessionId, sessionState, messages],
|
||||
);
|
||||
|
||||
if (isInitialLoading || items.length === 0) {
|
||||
return (
|
||||
<SessionPanelContent className="relative p-4 chat-message-list">
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
/>
|
||||
{sessionId && <PrepareProgress sessionId={sessionId} />}
|
||||
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
|
||||
</SessionPanelContent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SessionPanelContent ref={setScrollRef} className="relative p-4 chat-message-list">
|
||||
{scrollParent && (
|
||||
<VirtuosoBody
|
||||
{...props}
|
||||
scrollParent={scrollParent}
|
||||
isRunning={isRunning}
|
||||
lastTurnGroupId={lastTurnGroupId}
|
||||
hasMore={hasMore}
|
||||
isLoadingMore={isLoadingMore}
|
||||
loadMore={loadMore}
|
||||
Header={Header}
|
||||
Footer={Footer}
|
||||
/>
|
||||
)}
|
||||
</SessionPanelContent>
|
||||
);
|
||||
});
|
||||
@@ -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<string, ComponentType<MessageListProps>> = {
|
||||
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 <Renderer {...props} />;
|
||||
});
|
||||
@@ -75,7 +75,7 @@ function renderContentWithFileRefs(content: string): React.ReactNode[] {
|
||||
parts.push(
|
||||
<code
|
||||
key={`file-ref-${keyIndex++}`}
|
||||
className="px-1 py-0.5 bg-emerald-500/20 text-emerald-400 rounded font-mono text-[0.9em]"
|
||||
className="px-1 py-0.5 bg-foreground/[0.06] text-foreground/80 rounded font-mono text-[0.9em]"
|
||||
>
|
||||
@{filePath}
|
||||
</code>,
|
||||
@@ -105,26 +105,28 @@ const markdownComponents = {
|
||||
return <InlineCode>{content}</InlineCode>;
|
||||
},
|
||||
ol: ({ children }: { children?: ReactNode }) => (
|
||||
<ol className="list-decimal pl-6 mb-2">{children}</ol>
|
||||
<ol className="list-decimal pl-5 my-4">{children}</ol>
|
||||
),
|
||||
ul: ({ children }: { children?: ReactNode }) => (
|
||||
<ul className="list-disc pl-6 mb-2">{children}</ul>
|
||||
<ul className="list-disc pl-5 my-4">{children}</ul>
|
||||
),
|
||||
li: ({ children }: { children?: ReactNode }) => <li className="my-0.5">{children}</li>,
|
||||
p: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="leading-relaxed mb-1.5">{children}</p>
|
||||
),
|
||||
p: ({ children }: { children?: ReactNode }) => <p className="leading-[1.625]">{children}</p>,
|
||||
h1: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="my-3 font-bold text-sm">{children}</p>
|
||||
<h1 className="mt-5 mb-1.5 font-bold text-xl first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="my-2 font-bold text-sm">{children}</p>
|
||||
<h2 className="mt-5 mb-1.5 font-bold text-[1.0625rem] first:mt-0">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: ReactNode }) => (
|
||||
<p className="my-2 font-bold text-sm">{children}</p>
|
||||
<h3 className="mt-5 mb-1.5 font-bold text-[0.9375rem] first:mt-0">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => (
|
||||
<h4 className="mt-5 mb-1.5 font-bold first:mt-0">{children}</h4>
|
||||
),
|
||||
h5: ({ children }: { children?: ReactNode }) => (
|
||||
<h5 className="mt-5 mb-1.5 font-bold first:mt-0">{children}</h5>
|
||||
),
|
||||
h4: ({ children }: { children?: ReactNode }) => <p className="my-2 font-bold">{children}</p>,
|
||||
h5: ({ children }: { children?: ReactNode }) => <p className="my-2 font-bold">{children}</p>,
|
||||
hr: ({ children }: { children?: ReactNode }) => <hr className="my-5">{children}</hr>,
|
||||
table: ({ children }: { children?: ReactNode }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
|
||||
@@ -30,8 +30,8 @@ export function InlineCode({ children }: InlineCodeProps) {
|
||||
<code
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"px-0.5 py-0.5 text-indigo-400 rounded font-mono",
|
||||
"cursor-pointer hover:bg-indigo-500/30 transition-colors",
|
||||
"px-1 py-0.5 bg-foreground/[0.06] text-foreground/80 rounded font-mono",
|
||||
"cursor-pointer hover:bg-foreground/10 transition-colors",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -41,14 +41,14 @@ export function InlineCode({ children }: InlineCodeProps) {
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-full left-1/2 -translate-x-1/2 mb-1",
|
||||
"px-2 py-1 text-xs text-white bg-gray-900 rounded whitespace-nowrap",
|
||||
"px-2 py-1 text-xs text-popover-foreground bg-popover border border-border rounded shadow-md whitespace-nowrap",
|
||||
"pointer-events-none transition-opacity duration-200",
|
||||
"opacity-0 group-hover/inline-code:opacity-100",
|
||||
showTooltip && "opacity-100",
|
||||
)}
|
||||
>
|
||||
{tooltipText}
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-gray-900" />
|
||||
<span className="absolute top-full left-1/2 -translate-x-1/2 -mt-px border-4 border-transparent border-t-border" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -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<string, DiffComment[]> | undefined;
|
||||
hasContextComments: boolean;
|
||||
queuedMessage: string | null | undefined;
|
||||
onCancelQueue: (() => void) | undefined;
|
||||
updateQueueContent: ((content: string) => Promise<void>) | 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<QueuedMessageIndicatorHandle | null>;
|
||||
};
|
||||
|
||||
function useInputHandle(
|
||||
@@ -80,10 +74,6 @@ function computeDerivedState(params: {
|
||||
pendingClarification: Message | null | undefined;
|
||||
onClarificationResolved: (() => void) | undefined;
|
||||
pendingCommentsByFile: Record<string, DiffComment[]> | undefined;
|
||||
isQueued: boolean;
|
||||
queuedMessage: string | null | undefined;
|
||||
onCancelQueue: (() => void) | undefined;
|
||||
updateQueueContent: ((content: string) => Promise<void>) | 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<TipTapInputHandle | null>(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,
|
||||
|
||||
@@ -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<boolean>;
|
||||
previousHeightRef: React.RefObject<number>;
|
||||
previousWidthRef: React.RefObject<number>;
|
||||
wasAtBottomRef: React.RefObject<boolean>;
|
||||
virtualizerRef: React.RefObject<MessageVirtualizer>;
|
||||
scrollSnapshot: React.RefObject<{ wasAtBottom: boolean; firstVisibleIndex: number }>;
|
||||
};
|
||||
|
||||
type MessageVirtualizer = Virtualizer<HTMLDivElement, Element>;
|
||||
|
||||
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<HTMLDivElement | null>,
|
||||
) {
|
||||
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<string, Message>;
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
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<string, Message>;
|
||||
childrenByParentToolCallId: Map<string, Message[]>;
|
||||
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 (
|
||||
<div className="relative w-full" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const item = items[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{item.type === "turn_group" ? (
|
||||
<TurnGroupMessage
|
||||
group={item}
|
||||
sessionId={sessionId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isLastGroup={item.id === lastTurnGroupId}
|
||||
isTurnActive={isRunning}
|
||||
allMessages={messages}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
) : (
|
||||
<MessageRenderer
|
||||
comment={item.message}
|
||||
isTaskDescription={item.message.id === "task-description"}
|
||||
sessionState={sessionState}
|
||||
taskId={taskId}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
worktreePath={worktreePath}
|
||||
sessionId={sessionId ?? undefined}
|
||||
onOpenFile={onOpenFile}
|
||||
allMessages={messages}
|
||||
onScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 text-xs text-muted-foreground">
|
||||
Loading older messages...
|
||||
</div>
|
||||
)}
|
||||
{showLoadingState && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<GridSpinner className="text-primary mr-2" />
|
||||
<span>Loading messages...</span>
|
||||
</div>
|
||||
)}
|
||||
{!messagesLoading && !isInitialLoading && messagesCount === 0 && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground">
|
||||
<span>No messages yet. Start the conversation!</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const VirtualizedMessageList = memo(function VirtualizedMessageList({
|
||||
items,
|
||||
messages,
|
||||
permissionsByToolCallId,
|
||||
childrenByParentToolCallId,
|
||||
taskId,
|
||||
sessionId,
|
||||
messagesLoading,
|
||||
isWorking,
|
||||
sessionState,
|
||||
worktreePath,
|
||||
onOpenFile,
|
||||
}: VirtualizedMessageListProps) {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(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 (
|
||||
<SessionPanelContent ref={messagesContainerRef} className="relative p-4 chat-message-list">
|
||||
<MessageListStatus
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
showLoadingState={showLoadingState}
|
||||
messagesLoading={messagesLoading}
|
||||
isInitialLoading={isInitialLoading}
|
||||
messagesCount={messages.length}
|
||||
/>
|
||||
<MessageListBody
|
||||
items={items}
|
||||
messages={messages}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
childrenByParentToolCallId={childrenByParentToolCallId}
|
||||
taskId={taskId}
|
||||
sessionId={sessionId}
|
||||
sessionState={sessionState}
|
||||
worktreePath={worktreePath}
|
||||
onOpenFile={onOpenFile}
|
||||
isInitialLoading={isInitialLoading}
|
||||
virtualizer={virtualizer}
|
||||
lastTurnGroupId={lastTurnGroupId}
|
||||
isRunning={isRunning}
|
||||
handleScrollToMessage={handleScrollToMessage}
|
||||
/>
|
||||
{sessionId && <PrepareProgress sessionId={sessionId} />}
|
||||
<AgentStatus sessionState={sessionState} sessionId={sessionId} messages={messages} />
|
||||
</SessionPanelContent>
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={handleSplitRight}
|
||||
>
|
||||
<IconLayoutColumns className="h-3.5 w-3.5" />
|
||||
<button type="button" className={ACTION_BTN} onClick={handleMaximize}>
|
||||
{isMaximized ? (
|
||||
<IconArrowsMinimize className="h-3 w-3" />
|
||||
) : (
|
||||
<IconArrowsMaximize className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{isMaximized ? "Restore" : "Maximize"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className={ACTION_BTN} onClick={handleSplitRight}>
|
||||
<IconLayoutColumns className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split right</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={handleSplitDown}
|
||||
>
|
||||
<IconLayoutRows className="h-3.5 w-3.5" />
|
||||
<button type="button" className={ACTION_BTN} onClick={handleSplitDown}>
|
||||
<IconLayoutRows className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Split down</TooltipContent>
|
||||
@@ -215,12 +241,8 @@ function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsP
|
||||
{!isChatGroup && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={handleCloseGroup}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
<button type="button" className={ACTION_BTN} onClick={handleCloseGroup}>
|
||||
<IconX className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close group</TooltipContent>
|
||||
@@ -308,14 +330,13 @@ function RightTopGroupActions() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 cursor-pointer"
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"
|
||||
onClick={toggleRightPanels}
|
||||
>
|
||||
<IconLayoutSidebarRightCollapse className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Hide right panels</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -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 <span className={cn("h-1.5 w-1.5 rounded-full", getGitStatusDotClass(status))} />;
|
||||
}
|
||||
|
||||
/** Delete button shown on hover for file nodes. */
|
||||
function TreeNodeDeleteButton({
|
||||
node,
|
||||
@@ -266,17 +248,14 @@ export function TreeNodeItem({
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
{!node.is_dir && (
|
||||
{!node.is_dir && onDeleteFile && (
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<TreeNodeGitStatusDot status={gitStatus} />
|
||||
{onDeleteFile && (
|
||||
<TreeNodeDeleteButton
|
||||
node={node}
|
||||
tree={tree}
|
||||
setTree={setTree}
|
||||
onDeleteFile={onDeleteFile}
|
||||
/>
|
||||
)}
|
||||
<TreeNodeDeleteButton
|
||||
node={node}
|
||||
tree={tree}
|
||||
setTree={setTree}
|
||||
onDeleteFile={onDeleteFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -365,9 +344,6 @@ export function SearchResultsList({
|
||||
{folder && <span>{folder}/</span>}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
<div className="ml-auto flex items-center">
|
||||
<TreeNodeGitStatusDot status={gitStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<FileBrowserHeader
|
||||
|
||||
@@ -4,6 +4,7 @@ import { memo, useCallback } from "react";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useFileOperations } from "@/hooks/use-file-operations";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { FileBrowser } from "@/components/task/file-browser";
|
||||
import type { OpenFileTab } from "@/lib/types/backend";
|
||||
import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context";
|
||||
@@ -14,6 +15,7 @@ type FilesPanelProps = {
|
||||
|
||||
const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
|
||||
const activeSessionId = useAppStore((state) => 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}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -64,20 +64,6 @@ function buildModelOptions(
|
||||
return options;
|
||||
}
|
||||
|
||||
function NoModelButton() {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 cursor-not-allowed opacity-50 whitespace-nowrap"
|
||||
disabled
|
||||
>
|
||||
<span className="text-xs">No model</span>
|
||||
<IconChevronDown className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
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 <NoModelButton />;
|
||||
if (!sessionId || !currentModel) return null;
|
||||
|
||||
const displayName = modelOptions.find((m) => m.id === currentModel)?.name || currentModel;
|
||||
|
||||
|
||||
@@ -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<QueuedMessageIndicatorHandle | null>;
|
||||
onCancel: () => void;
|
||||
onUpdate: (content: string) => Promise<void>;
|
||||
onEditComplete: () => void;
|
||||
};
|
||||
|
||||
function QueuedMessageOverlay({
|
||||
isQueued,
|
||||
queuedMessage,
|
||||
isArchived,
|
||||
indicatorRef,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onEditComplete,
|
||||
}: QueuedOverlayProps) {
|
||||
if (!isQueued || !queuedMessage || isArchived) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-t border-blue-400/30 bg-card px-3 py-1">
|
||||
<QueuedMessageIndicator
|
||||
ref={indicatorRef}
|
||||
content={queuedMessage.content}
|
||||
onCancel={onCancel}
|
||||
onUpdate={onUpdate}
|
||||
isVisible={true}
|
||||
onEditComplete={onEditComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<PanelRoot>
|
||||
<PanelBody padding={false}>
|
||||
<VirtualizedMessageList
|
||||
<MessageList
|
||||
items={groupedItems}
|
||||
messages={allMessages}
|
||||
permissionsByToolCallId={permissionsByToolCallId}
|
||||
@@ -92,6 +132,15 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<QueuedMessageOverlay
|
||||
isQueued={isQueued}
|
||||
queuedMessage={queuedMessage}
|
||||
isArchived={isArchived}
|
||||
indicatorRef={queuedMessageRef}
|
||||
onCancel={handleCancelQueue}
|
||||
onUpdate={updateQueueContent}
|
||||
onEditComplete={handleQueueEditComplete}
|
||||
/>
|
||||
{isArchived ? (
|
||||
<div className="bg-muted/50 flex-shrink-0 px-4 py-3 text-center text-sm text-muted-foreground border-t">
|
||||
This task is archived and read-only.
|
||||
@@ -99,13 +148,10 @@ export const TaskChatPanel = memo(function TaskChatPanel({
|
||||
) : (
|
||||
<ChatInputArea
|
||||
chatInputRef={chatInputRef}
|
||||
queuedMessageRef={queuedMessageRef}
|
||||
clarificationKey={clarificationKey}
|
||||
onClarificationResolved={handleClarificationResolved}
|
||||
handleSubmit={handleSubmit}
|
||||
handleCancelTurn={handleCancelTurn}
|
||||
handleCancelQueue={handleCancelQueue}
|
||||
handleQueueEditComplete={handleQueueEditComplete}
|
||||
showRequestChangesTooltip={showRequestChangesTooltip}
|
||||
onRequestChangesTooltipDismiss={onRequestChangesTooltipDismiss}
|
||||
isPanelFocused={isPanelFocused}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
|
||||
type TaskRenameDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => 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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename task</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSubmit();
|
||||
}}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="Task title"
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRenameDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentTitle,
|
||||
onSubmit,
|
||||
}: TaskRenameDialogProps): React.JSX.Element {
|
||||
const handleClose = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{open && (
|
||||
<TaskRenameForm currentTitle={currentTitle} onSubmit={onSubmit} onClose={handleClose} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof useAppStoreApi>) {
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(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<typeof useAppStoreApi>) {
|
||||
[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 (
|
||||
<PanelRoot>
|
||||
@@ -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}
|
||||
/>
|
||||
</PanelBody>
|
||||
<TaskRenameDialog
|
||||
open={renamingTask !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenamingTask(null);
|
||||
}}
|
||||
currentTitle={renamingTask?.title ?? ""}
|
||||
onSubmit={handleRenameSubmit}
|
||||
/>
|
||||
</PanelRoot>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-foreground/[0.03]">
|
||||
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
|
||||
{icon && <icon.Icon className={`h-3 w-3 ${icon.className}`} />}
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground/60">{count}</span>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
@@ -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<typeof useRouter>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
@@ -435,13 +430,10 @@ function TopBarRight({
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer px-2"
|
||||
onClick={() => router.push("/settings/general")}
|
||||
>
|
||||
<IconSettings className="h-4 w-4" />
|
||||
<Button size="sm" variant="outline" className="cursor-pointer px-2" asChild>
|
||||
<Link href="/settings/general">
|
||||
<IconSettings className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Settings</TooltipContent>
|
||||
|
||||
@@ -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<string>): 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, {
|
||||
|
||||
@@ -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<typeof useAppStoreApi>;
|
||||
setIsLoading: (v: boolean) => void;
|
||||
setIsWaitingForInitialMessages: (v: boolean) => void;
|
||||
initialFetchStartRef: MutableRefObject<number | null>;
|
||||
lastFetchedSessionIdRef: MutableRefObject<string | null>;
|
||||
},
|
||||
) {
|
||||
const lastFetchStateKeyRef = useRef<string | null>(null);
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskSessionId || connectionStatus !== "connected") return;
|
||||
if (!taskSessionState || hasAgentMessage) return;
|
||||
|
||||
const terminalStates = new Set<TaskSessionState>(["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<number | null>(null);
|
||||
const lastFetchedSessionIdRef = useRef<string | null>(null);
|
||||
const lastFetchStateKeyRef = useRef<string | null>(null);
|
||||
const prevSessionIdRef = useRef<string | null>(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<TaskSessionState>(["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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { FileEditorState } from "./dockview-store";
|
||||
|
||||
type StoreSet = (
|
||||
partial:
|
||||
| { openFiles: Map<string, FileEditorState> }
|
||||
| ((s: { openFiles: Map<string, FileEditorState> }) => {
|
||||
openFiles: Map<string, FileEditorState>;
|
||||
}),
|
||||
) => 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<FileEditorState>) => {
|
||||
set((prev) => {
|
||||
const e = prev.openFiles.get(path);
|
||||
if (!e) return prev as { openFiles: Map<string, FileEditorState> };
|
||||
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() });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, number>
|
||||
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<FileEditorState>) => {
|
||||
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<DockviewStore>((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<DockviewStore>((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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]: <message>
|
||||
[ASSISTANT]: <message>
|
||||
[TOOL CALL: tool_name]
|
||||
[TOOL RESULT: tool_name] <result>
|
||||
|
||||
=== CURRENT REQUEST ===
|
||||
<user's actual prompt>
|
||||
|
||||
=== 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 |
|
||||
Reference in New Issue
Block a user