feat: chat improvements (#280)

This commit is contained in:
Carlos Florêncio
2026-02-25 02:10:16 +00:00
committed by GitHub
parent 3a95a43990
commit 829236f1be
56 changed files with 1898 additions and 868 deletions
@@ -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
}
+4 -1
View File
@@ -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)
}
}
}
+14 -20
View File
@@ -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
View File
@@ -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>
);
})}
+22 -2
View File
@@ -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
+3
View File
@@ -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,
+1 -15
View File
@@ -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;
+52 -6
View File
@@ -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>
);
});
+29 -5
View File
@@ -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}
+4 -12
View File
@@ -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,
+10
View File
@@ -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;
}
+34 -35
View File
@@ -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,
},
};
}
+6 -2
View File
@@ -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 };
}
+1
View File
@@ -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,
+40
View File
@@ -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() });
},
};
}
+18 -1
View File
@@ -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);
}
+69 -52
View File
@@ -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 -8
View File
@@ -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";
+21 -12
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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),
+1 -1
View File
@@ -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",
+358
View File
@@ -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 |