feat: improve passthrough composer and session recovery (#1411)

This commit is contained in:
Carlos Florêncio
2026-06-17 19:01:42 +01:00
committed by GitHub
parent 1d61727df3
commit d8436dfd8f
36 changed files with 2139 additions and 449 deletions
@@ -4,10 +4,13 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
agentctlshared "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/shared"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
"go.uber.org/zap"
@@ -161,7 +164,7 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt
// dispatchOnly is intentionally not forwarded: PTY writes are inherently
// fire-and-forget, so the flag has no analogue in passthrough mode.
if e.agentManager.IsPassthroughSession(ctx, sessionID) {
return e.promptPassthrough(ctx, taskID, sessionID, prompt, attachments)
return e.promptPassthrough(ctx, taskID, session, prompt, attachments)
}
result, err := e.agentManager.PromptAgent(ctx, executionID, prompt, attachments, dispatchOnly)
@@ -175,9 +178,9 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt
}
// promptPassthrough delivers a user prompt to a passthrough (PTY) agent session.
// Passthrough mode has no protocol channel for attachments: when the caller
// supplies any, we log a warning so an operator can see they were dropped
// (image-only messages are rejected outright since there is nothing to write).
// Passthrough mode has no structured protocol channel for attachments, so we
// save them into the session workspace and append path instructions to the
// stdin prompt.
//
// The submit sequence is resolved per-agent via ResolvePassthroughConfig — most
// TUI CLIs use "\r" but the config field lets a future agent override it
@@ -187,15 +190,14 @@ func (e *Executor) Prompt(ctx context.Context, taskID, sessionID string, prompt
// as an error so Service.handlePromptError can revert session state and surface
// the failure to the user. A MarkPassthroughRunning failure is non-fatal — the
// data is already in the PTY; only the AgentRunning event is missed.
func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) {
if prompt == "" {
return nil, fmt.Errorf("passthrough prompt cannot be empty (attachments are not supported in CLI passthrough mode)")
func (e *Executor) promptPassthrough(ctx context.Context, taskID string, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (*PromptResult, error) {
sessionID := session.ID
promptWithAttachments, err := e.buildPassthroughPromptWithAttachments(ctx, session, prompt, attachments)
if err != nil {
return nil, err
}
if len(attachments) > 0 {
e.logger.Warn("dropping attachments on passthrough prompt; CLI passthrough mode has no attachment channel",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Int("attachments_count", len(attachments)))
if strings.TrimSpace(promptWithAttachments) == "" {
return nil, fmt.Errorf("passthrough prompt cannot be empty")
}
pt, err := e.agentManager.ResolvePassthroughConfig(ctx, sessionID)
if err != nil {
@@ -214,7 +216,7 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, pro
zap.String("session_id", sessionID),
zap.Error(err))
}
for _, chunk := range agents.PlanPassthroughStdinChunks(prompt, pt) {
for _, chunk := range agents.PlanPassthroughStdinChunks(promptWithAttachments, pt) {
if chunk.DelayBefore > 0 {
time.Sleep(chunk.DelayBefore)
}
@@ -225,6 +227,82 @@ func (e *Executor) promptPassthrough(ctx context.Context, taskID, sessionID, pro
return &PromptResult{StopReason: stopReasonPassthrough}, nil
}
func (e *Executor) buildPassthroughPromptWithAttachments(ctx context.Context, session *models.TaskSession, prompt string, attachments []v1.MessageAttachment) (string, error) {
if len(attachments) == 0 {
return prompt, nil
}
workDir := e.passthroughAttachmentWorkspace(ctx, session)
if workDir == "" {
return "", fmt.Errorf("passthrough attachments require a session workspace path")
}
attachMgr := agentctlshared.NewAttachmentManager(workDir, e.logger.Zap())
attachMgr.SetSessionID(session.ID)
saved, err := attachMgr.SaveAttachments(attachments)
if err != nil {
return "", fmt.Errorf("save passthrough attachments: %w", err)
}
if len(saved) == 0 {
if strings.TrimSpace(prompt) != "" {
e.logger.Warn("no attachments were saved for passthrough prompt; delivering text-only",
zap.String("session_id", session.ID),
zap.Int("attachments_submitted", len(attachments)))
return prompt, nil
}
return "", fmt.Errorf("passthrough prompt has no usable attachments")
}
attachmentPrompt := strings.TrimSpace(agentctlshared.BuildAttachmentPrompt(saved, true))
if strings.TrimSpace(prompt) == "" {
return attachmentPrompt, nil
}
return prompt + "\n\n" + attachmentPrompt, nil
}
func (e *Executor) passthroughAttachmentWorkspace(ctx context.Context, session *models.TaskSession) string {
if workDir := strings.TrimSpace(session.WorkspacePath); workDir != "" {
return workDir
}
if workDir := workspaceFromSessionWorktrees(session); workDir != "" {
return workDir
}
if session.TaskEnvironmentID != "" {
if env, err := e.repo.GetTaskEnvironment(ctx, session.TaskEnvironmentID); err == nil {
if workDir := workspaceFromTaskEnvironment(env); workDir != "" {
return workDir
}
}
}
if env, err := e.repo.GetTaskEnvironmentByTaskID(ctx, session.TaskID); err == nil {
if workDir := workspaceFromTaskEnvironment(env); workDir != "" {
return workDir
}
}
return ""
}
func workspaceFromSessionWorktrees(session *models.TaskSession) string {
if len(session.Worktrees) == 0 {
return ""
}
first := strings.TrimSpace(session.Worktrees[0].WorktreePath)
if first == "" {
return ""
}
if len(session.Worktrees) == 1 {
return first
}
return filepath.Dir(first)
}
func workspaceFromTaskEnvironment(env *models.TaskEnvironment) string {
if env == nil {
return ""
}
if workDir := strings.TrimSpace(env.WorkspacePath); workDir != "" {
return workDir
}
return strings.TrimSpace(env.WorktreePath)
}
// SwitchModel switches the model for a running session. It first attempts an
// in-place switch via ACP model selection (instant, no process restart). If
// the agent doesn't support in-place switching, it falls back to stopping and
@@ -2,7 +2,10 @@ package executor
import (
"context"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -127,21 +130,25 @@ func TestExecutor_Prompt_PassthroughMarkRunningErrorIsNonFatal(t *testing.T) {
}
}
func TestExecutor_Prompt_PassthroughWithAttachmentsDropsAttachmentsAndSucceeds(t *testing.T) {
// Attachments have no place in passthrough mode (no ACP channel for binary
// payloads). When prompt text is present we still deliver the text; the
// caller (logs) records that the attachments were dropped.
func TestExecutor_Prompt_PassthroughWithAttachmentsSavesFilesAndReferencesPaths(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := t.TempDir()
repo.sessions["sess-1"].WorkspacePath = workDir
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{Type: "image", MimeType: "image/png", Name: "screenshot.png"}}
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "../notes.txt",
Data: base64.StdEncoding.EncodeToString([]byte("passthrough attachment body")),
}}
result, err := exec.Prompt(context.Background(), "task-1", "sess-1", "look at this", atts, false)
if err != nil {
t.Fatalf("Prompt with attachments should still succeed (attachments dropped); got error: %v", err)
t.Fatalf("Prompt with attachments should succeed: %v", err)
}
if result == nil || result.StopReason != "passthrough_dispatched" {
t.Fatalf("expected passthrough_dispatched result, got %+v", result)
@@ -149,8 +156,215 @@ func TestExecutor_Prompt_PassthroughWithAttachmentsDropsAttachmentsAndSucceeds(t
if got := len(agentManager.writePassthroughStdinCalls); got != 1 {
t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got)
}
if !strings.HasPrefix(agentManager.writePassthroughStdinCalls[0].Data, "look at this") {
t.Errorf("PTY write should carry the typed text, got %q", agentManager.writePassthroughStdinCalls[0].Data)
data := agentManager.writePassthroughStdinCalls[0].Data
relPath := filepath.Join(".kandev", "attachments", "sess-1", "notes.txt")
if !strings.Contains(data, "look at this") {
t.Errorf("PTY write should carry the typed text, got %q", data)
}
if !strings.Contains(data, relPath) {
t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, data)
}
contents, err := os.ReadFile(filepath.Join(workDir, relPath))
if err != nil {
t.Fatalf("expected saved attachment file: %v", err)
}
if string(contents) != "passthrough attachment body" {
t.Fatalf("saved attachment contents = %q", string(contents))
}
}
func TestExecutor_Prompt_PassthroughAttachmentOnlyMessageSendsPathPrompt(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := t.TempDir()
repo.sessions["sess-1"].WorkspacePath = workDir
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "only.txt",
Data: base64.StdEncoding.EncodeToString([]byte("attachment-only")),
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "", atts, false); err != nil {
t.Fatalf("attachment-only passthrough prompt should send path instructions: %v", err)
}
if got := len(agentManager.writePassthroughStdinCalls); got != 1 {
t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got)
}
if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, filepath.Join(".kandev", "attachments", "sess-1", "only.txt")) {
t.Errorf("PTY write should reference only attachment, got %q", agentManager.writePassthroughStdinCalls[0].Data)
}
}
func TestExecutor_Prompt_PassthroughInvalidAttachmentsDeliverTextOnlyPrompt(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
repo.sessions["sess-1"].WorkspacePath = t.TempDir()
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "broken.txt",
Data: "not-base64!!!",
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "keep this text", atts, false); err != nil {
t.Fatalf("text prompt should still send when attachments are unusable: %v", err)
}
if got := len(agentManager.writePassthroughStdinCalls); got != 1 {
t.Fatalf("expected 1 WritePassthroughStdin call, got %d", got)
}
want := "keep this text\r"
if agentManager.writePassthroughStdinCalls[0].Data != want {
t.Fatalf("PTY write = %q, want text-only payload %q", agentManager.writePassthroughStdinCalls[0].Data, want)
}
}
func TestExecutor_Prompt_PassthroughAttachmentsUseSessionWorktreeFallback(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := t.TempDir()
repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{{
SessionID: "sess-1",
RepositoryID: "repo-1",
WorktreePath: workDir,
}}
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "fallback.txt",
Data: base64.StdEncoding.EncodeToString([]byte("worktree-fallback")),
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with fallback", atts, false); err != nil {
t.Fatalf("Prompt with worktree fallback should succeed: %v", err)
}
relPath := filepath.Join(".kandev", "attachments", "sess-1", "fallback.txt")
if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, relPath) {
t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, agentManager.writePassthroughStdinCalls[0].Data)
}
contents, err := os.ReadFile(filepath.Join(workDir, relPath))
if err != nil {
t.Fatalf("expected saved attachment file: %v", err)
}
if string(contents) != "worktree-fallback" {
t.Fatalf("saved attachment contents = %q", string(contents))
}
}
func TestExecutor_Prompt_PassthroughAttachmentsUseMultiWorktreeRootFallback(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := filepath.Join(t.TempDir(), "repo-one")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatalf("create worktree dir: %v", err)
}
repo.sessions["sess-1"].Worktrees = []*models.TaskSessionWorktree{
{SessionID: "sess-1", RepositoryID: "repo-1", WorktreePath: workDir},
{SessionID: "sess-1", RepositoryID: "repo-2", WorktreePath: filepath.Join(t.TempDir(), "repo-two")},
}
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "multi.txt",
Data: base64.StdEncoding.EncodeToString([]byte("multi-worktree-root")),
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with multi", atts, false); err != nil {
t.Fatalf("Prompt with multi-worktree fallback should succeed: %v", err)
}
relPath := filepath.Join(".kandev", "attachments", "sess-1", "multi.txt")
contents, err := os.ReadFile(filepath.Join(filepath.Dir(workDir), relPath))
if err != nil {
t.Fatalf("expected saved attachment under shared worktree root: %v", err)
}
if string(contents) != "multi-worktree-root" {
t.Fatalf("saved attachment contents = %q", string(contents))
}
}
func TestExecutor_Prompt_PassthroughAttachmentsUseTaskEnvironmentFallback(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := t.TempDir()
repo.sessions["sess-1"].TaskEnvironmentID = "env-1"
repo.taskEnvironments["env-1"] = &models.TaskEnvironment{
ID: "env-1",
TaskID: "task-1",
WorkspacePath: workDir,
}
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "env.txt",
Data: base64.StdEncoding.EncodeToString([]byte("env-fallback")),
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with env", atts, false); err != nil {
t.Fatalf("Prompt with task environment fallback should succeed: %v", err)
}
relPath := filepath.Join(".kandev", "attachments", "sess-1", "env.txt")
if !strings.Contains(agentManager.writePassthroughStdinCalls[0].Data, relPath) {
t.Errorf("PTY write should reference saved attachment path %q, got %q", relPath, agentManager.writePassthroughStdinCalls[0].Data)
}
contents, err := os.ReadFile(filepath.Join(workDir, relPath))
if err != nil {
t.Fatalf("expected saved attachment file: %v", err)
}
if string(contents) != "env-fallback" {
t.Fatalf("saved attachment contents = %q", string(contents))
}
}
func TestExecutor_Prompt_PassthroughAttachmentsUseTaskEnvironmentByTaskIDFallback(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
workDir := t.TempDir()
repo.taskEnvironments["env-1"] = &models.TaskEnvironment{
ID: "env-1",
TaskID: "task-1",
WorkspacePath: workDir,
}
exec := newTestExecutor(t, agentManager, repo)
atts := []v1.MessageAttachment{{
Type: "resource",
MimeType: "text/plain",
Name: "env-by-task.txt",
Data: base64.StdEncoding.EncodeToString([]byte("env-by-task-fallback")),
}}
if _, err := exec.Prompt(context.Background(), "task-1", "sess-1", "with env by task", atts, false); err != nil {
t.Fatalf("Prompt with task environment by task fallback should succeed: %v", err)
}
relPath := filepath.Join(".kandev", "attachments", "sess-1", "env-by-task.txt")
contents, err := os.ReadFile(filepath.Join(workDir, relPath))
if err != nil {
t.Fatalf("expected saved attachment file: %v", err)
}
if string(contents) != "env-by-task-fallback" {
t.Fatalf("saved attachment contents = %q", string(contents))
}
}
@@ -171,6 +385,23 @@ func TestExecutor_Prompt_PassthroughEmptyPromptReturnsError(t *testing.T) {
}
}
func TestExecutor_Prompt_PassthroughWhitespaceOnlyPromptReturnsError(t *testing.T) {
repo := newMockRepository()
agentManager := &mockAgentManager{
isPassthroughSessionFunc: func(_ context.Context, _ string) bool { return true },
}
seedPassthroughSession(t, repo, agentManager, "task-1", "sess-1", "exec-1")
exec := newTestExecutor(t, agentManager, repo)
_, err := exec.Prompt(context.Background(), "task-1", "sess-1", " \n\t ", nil, false)
if err == nil {
t.Fatal("expected error for whitespace-only passthrough prompt, got nil")
}
if got := len(agentManager.writePassthroughStdinCalls); got != 0 {
t.Errorf("WritePassthroughStdin must not be called for whitespace-only prompts; got %d call(s)", got)
}
}
// TestExecutor_Prompt_PassthroughPreservesUnicodeAndMultiline verifies the PTY
// write payload preserves UTF-8 and embedded newlines verbatim. The submit
// suffix is appended at the END so the agent's TUI sees one logical "submit"
@@ -2,6 +2,7 @@ package service
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
@@ -12,9 +13,21 @@ import (
"github.com/kandev/kandev/internal/task/models"
)
const (
messageCreateMaxRetries = 5
messageCreateRetryDelay = 50 * time.Millisecond
)
// CreateMessage creates a new message on an agent session
func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) (*models.Message, error) {
session, err := s.sessions.GetTaskSession(ctx, req.TaskSessionID)
messageID := uuid.New().String()
session, err := s.getSessionWithRetry(
ctx,
req.TaskSessionID,
messageID,
messageCreateMaxRetries,
messageCreateRetryDelay,
)
if err != nil {
return nil, err
}
@@ -37,19 +50,25 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest)
// Ensure we have a turn ID - get active turn or start a new one
turnID := req.TurnID
if turnID == "" {
turn, err := s.getOrStartTurn(ctx, req.TaskSessionID)
turn, err := s.getOrStartTurnWithRetry(
ctx,
req.TaskSessionID,
messageID,
messageCreateMaxRetries,
messageCreateRetryDelay,
)
if err != nil {
s.logger.Warn("failed to get or start turn for message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
// Continue with empty turn ID - will fail on foreign key if turn is required
return nil, fmt.Errorf("failed to get or start turn: %w", err)
} else if turn != nil {
turnID = turn.ID
}
}
message := &models.Message{
ID: uuid.New().String(),
ID: messageID,
TaskSessionID: req.TaskSessionID,
TaskID: taskID,
TurnID: turnID,
@@ -83,17 +102,17 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest)
// It includes retry logic to handle transient database errors and ensure
// message chunks are not lost during streaming.
func (s *Service) CreateMessageWithID(ctx context.Context, id string, req *CreateMessageRequest) (*models.Message, error) {
const maxRetries = 5
const retryDelay = 50 * time.Millisecond
session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, maxRetries, retryDelay)
session, err := s.getSessionWithRetry(ctx, req.TaskSessionID, id, messageCreateMaxRetries, messageCreateRetryDelay)
if err != nil {
return nil, err
}
message := s.buildMessage(ctx, id, req, session)
message, err := s.buildMessage(ctx, id, req, session)
if err != nil {
return nil, err
}
if err := s.createMessageWithRetry(ctx, message, maxRetries, retryDelay); err != nil {
if err := s.createMessageWithRetry(ctx, message, messageCreateMaxRetries, messageCreateRetryDelay); err != nil {
return nil, err
}
@@ -137,8 +156,33 @@ func (s *Service) getSessionWithRetry(ctx context.Context, sessionID, messageID
return nil, err
}
// getOrStartTurnWithRetry fetches or starts a turn, retrying short-lived FK/session visibility races.
func (s *Service) getOrStartTurnWithRetry(ctx context.Context, sessionID, messageID string, maxRetries int, retryDelay time.Duration) (*models.Turn, error) {
var turn *models.Turn
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
turn, err = s.getOrStartTurn(ctx, sessionID)
if err == nil {
return turn, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if attempt < maxRetries-1 {
s.logger.Debug("failed to get or start turn for message, retrying",
zap.String("session_id", sessionID),
zap.String("message_id", messageID),
zap.Int("attempt", attempt+1),
zap.Int("max_retries", maxRetries),
zap.Error(err))
time.Sleep(retryDelay)
}
}
return nil, err
}
// buildMessage constructs a Message model from a CreateMessageRequest and resolved session.
func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) *models.Message {
func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessageRequest, session *models.TaskSession) (*models.Message, error) {
authorType := models.MessageAuthorUser
if req.AuthorType == "agent" {
authorType = models.MessageAuthorAgent
@@ -156,10 +200,17 @@ func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessag
turnID := req.TurnID
if turnID == "" {
if turn, err := s.getOrStartTurn(ctx, req.TaskSessionID); err != nil {
if turn, err := s.getOrStartTurnWithRetry(
ctx,
req.TaskSessionID,
id,
messageCreateMaxRetries,
messageCreateRetryDelay,
); err != nil {
s.logger.Warn("failed to get or start turn for streaming message",
zap.String("session_id", req.TaskSessionID),
zap.Error(err))
return nil, fmt.Errorf("failed to get or start turn: %w", err)
} else if turn != nil {
turnID = turn.ID
}
@@ -177,7 +228,7 @@ func (s *Service) buildMessage(ctx context.Context, id string, req *CreateMessag
Metadata: req.Metadata,
RequestsInput: req.RequestsInput,
CreatedAt: time.Now().UTC(),
}
}, nil
}
// createMessageWithRetry persists a message with retry logic for transient DB errors.
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
@@ -807,6 +808,59 @@ func TestService_CreateAgentMessage(t *testing.T) {
}
}
type failingTurnCreator struct {
repository.TurnRepository
err error
}
func (f failingTurnCreator) CreateTurn(context.Context, *models.Turn) error {
return f.err
}
func TestService_CreateMessageReturnsTurnStartError(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
sessionID := setupTestSession(t, repo)
turnErr := errors.New("turn table unavailable")
svc.turns = failingTurnCreator{TurnRepository: repo, err: turnErr}
_, err := svc.CreateMessage(ctx, &CreateMessageRequest{
TaskSessionID: sessionID,
Content: "new prompt",
AuthorType: "user",
})
if !errors.Is(err, turnErr) {
t.Fatalf("CreateMessage error = %v, want wrapped turn error", err)
}
if strings.Contains(strings.ToLower(err.Error()), "foreign key") {
t.Fatalf("CreateMessage returned masked FK error: %v", err)
}
}
func TestService_CreateMessageWithIDReturnsTurnStartError(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
setupTestTask(t, repo)
sessionID := setupTestSession(t, repo)
turnErr := errors.New("turn table unavailable")
svc.turns = failingTurnCreator{TurnRepository: repo, err: turnErr}
_, err := svc.CreateMessageWithID(ctx, "message-123", &CreateMessageRequest{
TaskSessionID: sessionID,
Content: "streamed output",
AuthorType: "agent",
})
if !errors.Is(err, turnErr) {
t.Fatalf("CreateMessageWithID error = %v, want wrapped turn error", err)
}
if strings.Contains(strings.ToLower(err.Error()), "foreign key") {
t.Fatalf("CreateMessageWithID returned masked FK error: %v", err)
}
}
func TestService_CreateMessageSessionNotFound(t *testing.T) {
svc, _, _ := createTestService(t)
ctx := context.Background()
@@ -41,7 +41,7 @@ import type { useChatPanelState } from "./use-chat-panel-state";
const PLAN_CONTEXT_PATH = "plan:context";
function buildSubmitMessage(
export function buildSubmitMessage(
message: string,
reviewComments: DiffComment[] | undefined,
pendingPRFeedback: import("@/lib/state/slices/comments").PRFeedbackComment[],
@@ -37,6 +37,7 @@ export type ChatInputEditorAreaProps = {
showRequestChangesTooltip: boolean;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
hideAgentControls?: boolean;
hidePlanMode?: boolean;
isAgentBusy: boolean;
onPlanModeChange: (enabled: boolean) => void;
@@ -126,7 +127,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
p;
const { isSending, onCancel, contextCount, contextPopoverOpen, setContextPopoverOpen } = p;
const { contextFiles, onImplementPlan, onEnhancePrompt, isEnhancingPrompt } = p;
const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hidePlanMode } = p;
const { isUtilityConfigured, hideSessionsDropdown, minimalToolbar, hideAgentControls } = p;
const { hidePlanMode } = p;
const { onVoiceTranscript, onVoiceAutoSend } = p;
// Exclude auto-added plan context from the count — it's always present in plan mode
// and shouldn't by itself enable the send button.
@@ -195,6 +197,7 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
onVoiceAutoSend={onVoiceAutoSend}
hideSessionsDropdown={hideSessionsDropdown}
minimalToolbar={minimalToolbar}
hideAgentControls={hideAgentControls}
hidePlanMode={hidePlanMode}
/>
</div>
@@ -91,6 +91,7 @@ type ChatInputContainerProps = {
onImplementPlan?: (fresh: boolean) => void;
hideSessionsDropdown?: boolean;
minimalToolbar?: boolean;
hideAgentControls?: boolean;
/** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */
hidePlanMode?: boolean;
};
@@ -304,6 +305,7 @@ function buildEditorAreaProps(
onVoiceAutoSend: extras.onVoiceAutoSend,
hideSessionsDropdown: p.hideSessionsDropdown,
minimalToolbar: p.minimalToolbar,
hideAgentControls: p.hideAgentControls,
hidePlanMode: p.hidePlanMode,
};
}
@@ -78,6 +78,8 @@ export type ChatInputToolbarProps = {
hideSessionsDropdown?: boolean;
/** When true, only render the submit/cancel button — no other controls */
minimalToolbar?: boolean;
/** Hide ACP/session-specific controls while keeping Plan, attachment, and context controls */
hideAgentControls?: boolean;
/** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */
hidePlanMode?: boolean;
};
@@ -376,7 +378,9 @@ function buildCollapsibleItems(props: {
onEnhancePrompt?: () => void;
isEnhancingPrompt: boolean;
isUtilityConfigured: boolean;
hideAgentControls?: boolean;
}): ToolbarItemConfig[] {
if (props.hideAgentControls) return [];
return [
{
id: "mcp",
@@ -456,6 +460,7 @@ function AttachFilesButton({ onClick }: { onClick: () => void }) {
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40"
onClick={onClick}
data-testid="chat-attachments-button"
>
<IconPaperclip className="h-4 w-4" />
</Button>
@@ -513,6 +518,7 @@ const toolbarDefaults = {
contextFiles: [] as ContextFile[],
isEnhancingPrompt: false,
isUtilityConfigured: false,
hideAgentControls: false,
hidePlanMode: false,
};
@@ -571,6 +577,7 @@ export const ChatInputToolbar = memo(function ChatInputToolbar(rawProps: ChatInp
variant="ghost"
size="sm"
className="h-7 gap-1.5 px-2 cursor-pointer hover:bg-muted/40 relative"
data-testid="chat-context-button"
>
<IconAt className="h-4 w-4" />
{props.contextCount > 0 && !isCollapsed && (
@@ -551,6 +551,7 @@ function useEditorImperativeHandle(
const { kind, path, label } = node.attrs;
if (kind === "file") mentions.push({ path, name: label, pinned: false });
else if (kind === "prompt") mentions.push({ path, name: label, pinned: false });
else if (kind === "plan") mentions.push({ path, name: label, pinned: false });
}
});
return mentions;
@@ -109,8 +109,8 @@ describe("useSessionContextChange", () => {
});
it("summarizes sessions into prompt text", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue("summary text");
const promptRef = { current: { value: "stale prompt" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue({ summary: "summary text" });
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
@@ -136,7 +136,7 @@ describe("useSessionContextChange", () => {
it("handles summarize failures by clearing context and showing toast", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue(null);
const summarize = vi.fn().mockResolvedValue({ summary: null, error: "connection refused" });
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
@@ -157,16 +157,16 @@ describe("useSessionContextChange", () => {
expect(setContextValue).toHaveBeenCalledWith("blank");
expect(mockToast).toHaveBeenCalledWith({
title: "Summarize failed",
description:
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
description: "connection refused",
variant: "error",
});
expect(setHasPrompt).not.toHaveBeenCalled();
expect(promptRef.current.value).toBe("");
expect(setHasPrompt).toHaveBeenCalledWith(false);
});
it("does not toast when summarize succeeds after prompt ref unmounts", async () => {
const promptRef = { current: null as HTMLTextAreaElement | null };
const summarize = vi.fn().mockResolvedValue("summary text");
const summarize = vi.fn().mockResolvedValue({ summary: "summary text" });
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
@@ -192,7 +192,7 @@ describe("useSessionContextChange", () => {
it("sanitizes unsafe characters from summarize result before setting prompt", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue("line1\r\n<unsafe>\nline2");
const summarize = vi.fn().mockResolvedValue({ summary: "line1\r\n<unsafe>\nline2" });
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
@@ -4,26 +4,18 @@ import { buildStartRequest } from "@/lib/services/session-launch-helpers";
import { toMessageAttachments } from "@/components/task-create-dialog-helpers";
import type { FileAttachment } from "./chat/file-attachment";
import type { AgentProfileOption } from "@/lib/state/slices";
type ToastFn = (opts: {
title: string;
description?: string;
variant?: "error" | "default";
}) => void;
import type { SummarizeSessionResult } from "@/hooks/use-summarize-session";
import { applySummarizeSessionResult, type SummaryToastFn } from "./session-context-summary";
type SessionContextChangeOpts = {
promptRef: RefObject<HTMLTextAreaElement | null>;
initialPrompt: string | null;
summarize: (sessionId: string) => Promise<string | null>;
toast: ToastFn;
summarize: (sessionId: string) => Promise<SummarizeSessionResult>;
toast: SummaryToastFn;
setContextValue: (v: string) => void;
setHasPrompt: (v: boolean) => void;
};
function sanitizePromptText(value: string): string {
return value.replace(/\r/g, "").replace(/[<>]/g, " ");
}
export function useSessionContextChange(opts: SessionContextChangeOpts) {
const { promptRef, initialPrompt, summarize, toast, setContextValue, setHasPrompt } = opts;
return useCallback(
@@ -39,18 +31,7 @@ export function useSessionContextChange(opts: SessionContextChangeOpts) {
} else if (value.startsWith("summarize:")) {
const sessionId = value.slice("summarize:".length);
const result = await summarize(sessionId);
if (result === null) {
setContextValue("blank");
toast({
title: "Summarize failed",
description:
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
variant: "error",
});
} else if (promptRef.current) {
promptRef.current.value = sanitizePromptText(result);
setHasPrompt(true);
}
applySummarizeSessionResult({ result, promptRef, setContextValue, setHasPrompt, toast });
}
},
[initialPrompt, promptRef, summarize, setContextValue, setHasPrompt, toast],
@@ -83,7 +64,7 @@ export function useSessionLaunchSubmit({
groupId?: string;
attachments: FileAttachment[];
onClose: () => void;
toast: ToastFn;
toast: SummaryToastFn;
setActiveSession: (taskId: string, sessionId: string) => void;
activateSession: (
sessionId: string,
@@ -17,7 +17,7 @@ import {
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useSummarizeSession } from "@/hooks/use-summarize-session";
import { useSummarizeSession, type SummarizeSessionResult } from "@/hooks/use-summarize-session";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
@@ -29,6 +29,7 @@ import {
useSubtaskFormState,
} from "./new-subtask-form-state";
import { PromptZone, SubtaskFormBody } from "./new-subtask-form-parts";
import { applySummarizeSessionResult, type SummaryToastFn } from "./session-context-summary";
import { useSubtaskPromptZone, useSubtaskSubmit } from "./use-subtask-submit";
type NewSubtaskDialogProps = {
@@ -166,9 +167,10 @@ function useContextChangeHandler(opts: {
setHasPrompt: (v: boolean) => void;
promptRef: React.RefObject<HTMLTextAreaElement | null>;
initialPrompt: string | null;
summarize: (sessionId: string) => Promise<string | null>;
summarize: (sessionId: string) => Promise<SummarizeSessionResult>;
toast: SummaryToastFn;
}) {
const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize } = opts;
const { setContextValue, setHasPrompt, promptRef, initialPrompt, summarize, toast } = opts;
return useCallback(
async (value: string) => {
if (!value) return;
@@ -187,13 +189,10 @@ function useContextChangeHandler(opts: {
}
if (value.startsWith("summarize:")) {
const result = await summarize(value.slice("summarize:".length));
if (result && promptRef.current) {
promptRef.current.value = result;
setHasPrompt(true);
}
applySummarizeSessionResult({ result, promptRef, setContextValue, setHasPrompt, toast });
}
},
[setContextValue, setHasPrompt, promptRef, initialPrompt, summarize],
[setContextValue, setHasPrompt, promptRef, initialPrompt, summarize, toast],
);
}
@@ -268,6 +267,7 @@ function NewSubtaskForm({
promptRef: promptZone.promptRef,
initialPrompt,
summarize,
toast,
});
const { handleSubmit } = useSubtaskSubmit({
fs,
@@ -0,0 +1,228 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { DiffComment } from "@/lib/diff/types";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import {
buildContextFilesMeta,
buildPassthroughFinalMessage,
buildPassthroughPlanContext,
clearPassthroughComposerContext,
formatPassthroughBaseMessage,
} from "./passthrough-chat-composer";
const mockGetTaskPlan = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/domains/plan-api", () => ({
getTaskPlan: mockGetTaskPlan,
}));
const SESSION_ID = "session-1";
const TASK_ID = "task-1";
const PLAN_CONTEXT_PATH = "plan:context";
function file(path: string, name = path): ContextFile {
return { path, name };
}
function comment(id = "comment-1"): DiffComment {
return {
id,
source: "diff",
sessionId: SESSION_ID,
filePath: "src/app.ts",
startLine: 10,
endLine: 11,
side: "additions",
codeContent: "const value = 1;",
text: "Please fix this",
status: "pending",
createdAt: "2026-06-17T00:00:00.000Z",
};
}
function panelState(overrides: Record<string, unknown> = {}) {
return {
resolvedSessionId: SESSION_ID,
taskId: TASK_ID,
contextFiles: [],
prompts: [],
pendingPRFeedback: [],
planComments: [],
planModeEnabled: false,
handleClearPRFeedback: vi.fn(),
clearSessionPlanComments: vi.fn(),
clearEphemeral: vi.fn(),
addContextFile: vi.fn(),
...overrides,
} as never;
}
beforeEach(() => {
mockGetTaskPlan.mockReset();
});
describe("passthrough chat composer metadata helpers", () => {
it("filters virtual prompt and plan context files from context_files metadata", () => {
expect(
buildContextFilesMeta([
file("src/app.ts", "app.ts"),
file("prompt:review", "Review prompt"),
file(PLAN_CONTEXT_PATH, "Plan"),
]),
).toEqual([{ path: "src/app.ts", name: "app.ts" }]);
expect(buildContextFilesMeta([file("prompt:review"), file(PLAN_CONTEXT_PATH)])).toBeUndefined();
});
it("prepends pending review comments when no structured comment payload is supplied", () => {
const result = formatPassthroughBaseMessage("Ship it", undefined, [comment()], panelState());
expect(result.commentsToSend).toHaveLength(1);
expect(result.formatted).toContain("### Review Comments");
expect(result.formatted).toContain("Please fix this");
expect(result.formatted).toContain("Ship it");
});
it("merges selected context files with inline file, prompt, and task mentions", async () => {
const inlineTask: TaskMentionData = {
taskId: "task-2",
title: "Follow-up task",
workflowId: "workflow-1",
workflowStepId: "step-1",
state: "in_progress",
};
const result = await buildPassthroughFinalMessage({
taskId: TASK_ID,
content: "Please check this",
pendingComments: [],
panelState: panelState({
contextFiles: [file("src/existing.ts", "existing.ts")],
prompts: [{ id: "prompt-1", name: "Review", content: "Look carefully." }],
}),
inlineMentions: [file("src/inline.ts", "inline.ts"), file("prompt:prompt-1", "Review")],
inlineTaskMentions: [inlineTask],
getState: () =>
({
kanban: { steps: [{ id: "step-1", title: "Review" }] },
kanbanMulti: { snapshots: {} },
taskPlans: { byTaskId: {} },
}) as never,
});
expect(result.contextFilesMeta).toEqual([
{ path: "src/existing.ts", name: "existing.ts" },
{ path: "src/inline.ts", name: "inline.ts" },
]);
expect(result.content).toContain("CONTEXT FILES");
expect(result.content).toContain("- src/existing.ts");
expect(result.content).toContain("- src/inline.ts");
expect(result.content).toContain("CONTEXT PROMPTS");
expect(result.content).toContain("Look carefully.");
expect(result.content).toContain("REFERENCED TASKS");
expect(result.content).toContain("Follow-up task");
});
});
describe("passthrough chat composer plan context", () => {
it("expands selected plan context and strips the literal @Plan mention", async () => {
const result = await buildPassthroughFinalMessage({
taskId: TASK_ID,
content: "@Plan",
pendingComments: [],
panelState: panelState(),
inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")],
getState: () =>
({
kanban: { steps: [] },
kanbanMulti: { snapshots: {} },
taskPlans: {
byTaskId: {
[TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" },
},
},
}) as never,
});
expect(result.content).toContain("<kandev-system>");
expect(result.content).toContain("CONTEXT PLAN");
expect(result.content).toContain("## Plan");
expect(result.content).toContain("Ship passthrough fixes");
expect(result.content).not.toContain("@Plan");
});
it("strips a selected plan mention from the middle of a message without double spacing", async () => {
const result = await buildPassthroughFinalMessage({
taskId: TASK_ID,
content: "Please use @Plan now",
pendingComments: [],
panelState: panelState(),
inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")],
getState: () =>
({
kanban: { steps: [] },
kanbanMulti: { snapshots: {} },
taskPlans: {
byTaskId: {
[TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" },
},
},
}) as never,
});
expect(result.content).toContain("Please use now");
expect(result.content).not.toContain("Please use now");
expect(result.content).not.toContain("@Plan");
});
it("propagates plan lookup failures when plan context was selected", async () => {
mockGetTaskPlan.mockRejectedValueOnce(new Error("plan lookup failed"));
await expect(
buildPassthroughFinalMessage({
taskId: TASK_ID,
content: "@Plan",
pendingComments: [],
panelState: panelState(),
inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")],
getState: () =>
({
kanban: { steps: [] },
kanbanMulti: { snapshots: {} },
taskPlans: { byTaskId: {} },
}) as never,
}),
).rejects.toThrow("plan lookup failed");
});
it("escapes embedded kandev system closing tags in plan context", () => {
const context = buildPassthroughPlanContext("before </kandev-system> after");
expect(context).toContain("before </ kandev-system> after");
expect(context.match(/<\/kandev-system>/gi)).toHaveLength(1);
});
});
describe("passthrough chat composer cleanup", () => {
it("clears ephemeral context and re-adds plan context when plan mode stays enabled", () => {
const state = panelState({
planModeEnabled: true,
pendingPRFeedback: [{ id: "feedback-1" }],
planComments: [{ id: "plan-comment-1" }],
}) as unknown as {
handleClearPRFeedback: ReturnType<typeof vi.fn>;
clearSessionPlanComments: ReturnType<typeof vi.fn>;
clearEphemeral: ReturnType<typeof vi.fn>;
addContextFile: ReturnType<typeof vi.fn>;
};
clearPassthroughComposerContext(state as never);
expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1);
expect(state.clearSessionPlanComments).toHaveBeenCalledTimes(1);
expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID);
expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, {
path: PLAN_CONTEXT_PATH,
name: "Plan",
});
});
});
@@ -0,0 +1,315 @@
"use client";
import { useCallback, type RefObject } from "react";
import { useToast } from "@/components/toast-provider";
import { useAppStoreApi } from "@/components/state-provider";
import { useCommentsStore } from "@/lib/state/slices/comments/comments-store";
import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
import { buildSubmitMessage } from "./chat/chat-input-area";
import {
ChatInputContainer,
type ChatInputContainerHandle,
type MessageAttachment,
} from "./chat/chat-input-container";
import type { useChatPanelState } from "./chat/use-chat-panel-state";
import type { DiffComment } from "@/lib/diff/types";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import { buildContextFilesContext, buildTaskMentionsContext } from "@/hooks/use-message-handler";
import { getWebSocketClient } from "@/lib/ws/connection";
import { getTaskPlan } from "@/lib/api/domains/plan-api";
import type { AppState } from "@/lib/state/store";
const PLAN_CONTEXT_PATH = "plan:context";
export type PassthroughSubmitHandler = (
content: string,
reviewComments?: DiffComment[],
attachments?: MessageAttachment[],
inlineMentions?: ContextFile[],
inlineTaskMentions?: TaskMentionData[],
) => Promise<void>;
export function PassthroughComposerPanel({
refHandle,
onSubmit,
onCancel,
panelState,
taskId,
isMoving,
isSending,
}: {
refHandle: RefObject<ChatInputContainerHandle | null>;
onSubmit: PassthroughSubmitHandler;
onCancel: () => void;
panelState: ReturnType<typeof useChatPanelState>;
taskId: string | null;
isMoving: boolean;
isSending: boolean;
}) {
const hasContextComments =
panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0;
return (
<div
data-testid="passthrough-composer"
onKeyDownCapture={(event) => {
if (event.key === "Escape") onCancel();
}}
>
<ChatInputContainer
ref={refHandle}
onSubmit={onSubmit}
sessionId={panelState.resolvedSessionId}
taskId={taskId}
taskTitle={panelState.task?.title}
taskDescription={panelState.taskDescription ?? ""}
planModeEnabled={panelState.planModeEnabled}
planModeAvailable={panelState.planModeAvailable}
mcpServers={panelState.mcpServers}
onPlanModeChange={panelState.handlePlanModeChange}
isAgentBusy={false}
isStarting={panelState.isStarting}
isPreparingEnvironment={panelState.isPreparingEnvironment}
isMoving={isMoving}
isSending={isSending}
onCancel={onCancel}
placeholder="Type a message, @mention files or prompts, Shift+Enter for newline"
pendingCommentsByFile={panelState.pendingCommentsByFile}
hasContextComments={hasContextComments}
submitKey={panelState.chatSubmitKey}
hasAgentCommands={false}
contextItems={panelState.contextItems}
planContextEnabled={panelState.planContextEnabled}
contextFiles={panelState.contextFiles}
onToggleContextFile={panelState.handleToggleContextFile}
onAddContextFile={panelState.handleAddContextFile}
hideSessionsDropdown
hideAgentControls
/>
</div>
);
}
type PassthroughFinalMessage = {
content: string;
commentsToSend: DiffComment[];
contextFilesMeta?: Array<{ path: string; name: string }>;
};
export function formatPassthroughBaseMessage(
content: string,
reviewComments: DiffComment[] | undefined,
pendingComments: DiffComment[],
panelState: ReturnType<typeof useChatPanelState>,
) {
const commentsToSend = reviewComments ?? pendingComments;
const hasStructuredComments =
!!reviewComments ||
panelState.pendingPRFeedback.length > 0 ||
panelState.planComments.length > 0;
if (hasStructuredComments) {
return {
formatted: buildSubmitMessage(
content,
commentsToSend.length > 0 ? commentsToSend : undefined,
panelState.pendingPRFeedback,
panelState.planComments,
),
commentsToSend,
};
}
if (pendingComments.length > 0) {
return {
formatted: formatReviewCommentsAsMarkdown(pendingComments) + content,
commentsToSend,
};
}
return { formatted: content, commentsToSend };
}
function hasPlanContext(files: ContextFile[]) {
return files.some((file) => file.path === PLAN_CONTEXT_PATH);
}
function stripSelectedPlanMentions(content: string, files: ContextFile[]) {
if (!hasPlanContext(files)) return content;
return content.replace(/\s*@Plan(?=\s|$)/g, "").trim();
}
function sanitizeSystemBlockContent(content: string) {
return content.replace(/<\/kandev-system>/gi, "</ kandev-system>");
}
export function buildPassthroughPlanContext(planContent: string | undefined | null) {
const trimmed = planContent?.trim();
if (!trimmed) return "";
return (
`\n\n<kandev-system>\n` +
`CONTEXT PLAN: The user has attached the current task plan as context. ` +
`Use this plan content to understand what they mean by the plan:\n` +
`${sanitizeSystemBlockContent(trimmed)}\n` +
`</kandev-system>`
);
}
function cachedTaskPlanContent(taskId: string, state: AppState) {
return state.taskPlans.byTaskId[taskId]?.content;
}
async function loadTaskPlanContent(taskId: string | null, getState: () => AppState) {
if (!taskId) return "";
const cached = cachedTaskPlanContent(taskId, getState());
if (cached !== undefined) return cached;
const plan = await getTaskPlan(taskId);
return plan?.content ?? "";
}
export async function buildPassthroughFinalMessage({
taskId,
content,
reviewComments,
pendingComments,
panelState,
inlineMentions,
inlineTaskMentions,
getState,
}: {
taskId: string | null;
content: string;
reviewComments?: DiffComment[];
pendingComments: DiffComment[];
panelState: ReturnType<typeof useChatPanelState>;
inlineMentions?: ContextFile[];
inlineTaskMentions?: TaskMentionData[];
getState: ReturnType<typeof useAppStoreApi>["getState"];
}): Promise<PassthroughFinalMessage> {
const { formatted, commentsToSend } = formatPassthroughBaseMessage(
content,
reviewComments,
pendingComments,
panelState,
);
const allContextFiles = [...panelState.contextFiles, ...(inlineMentions ?? [])];
const visibleContent = stripSelectedPlanMentions(formatted, allContextFiles);
const contextFilesContext = buildContextFilesContext(allContextFiles, panelState.prompts);
const planContext = hasPlanContext(allContextFiles)
? buildPassthroughPlanContext(await loadTaskPlanContent(taskId, getState))
: "";
const taskMentionsContext =
inlineTaskMentions && inlineTaskMentions.length > 0
? buildTaskMentionsContext(inlineTaskMentions, getState())
: "";
return {
content: visibleContent + contextFilesContext + planContext + taskMentionsContext,
commentsToSend,
contextFilesMeta: buildContextFilesMeta(allContextFiles),
};
}
export function buildContextFilesMeta(files: ContextFile[]) {
const realContextFiles = files.filter(
(f) => !f.path.startsWith("prompt:") && f.path !== PLAN_CONTEXT_PATH,
);
if (realContextFiles.length === 0) return undefined;
return realContextFiles.map((f) => ({ path: f.path, name: f.name }));
}
async function requestPassthroughMessage({
taskId,
sessionId,
message,
attachments,
}: {
taskId: string;
sessionId: string;
message: PassthroughFinalMessage;
attachments?: MessageAttachment[];
}) {
const client = getWebSocketClient();
if (!client) throw new Error("WebSocket client not available");
const hasAttachments = !!(attachments && attachments.length > 0);
await client.request(
"message.add",
{
task_id: taskId,
session_id: sessionId,
content: message.content,
...(hasAttachments && { attachments }),
...(message.contextFilesMeta && { context_files: message.contextFilesMeta }),
},
hasAttachments ? 30_000 : 10_000,
);
}
export function clearPassthroughComposerContext(panelState: ReturnType<typeof useChatPanelState>) {
if (panelState.pendingPRFeedback.length > 0) {
panelState.handleClearPRFeedback();
}
if (panelState.planComments.length > 0) {
panelState.clearSessionPlanComments();
}
if (!panelState.resolvedSessionId) return;
panelState.clearEphemeral(panelState.resolvedSessionId);
if (panelState.planModeEnabled) {
panelState.addContextFile(panelState.resolvedSessionId, {
path: "plan:context",
name: "Plan",
});
}
}
export function useSendPassthroughMessage({
taskId,
sessionId,
pendingComments,
panelState,
onSent,
}: {
taskId: string | null;
sessionId: string | null | undefined;
pendingComments: DiffComment[];
panelState: ReturnType<typeof useChatPanelState>;
onSent: () => void;
}) {
const { toast } = useToast();
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
const storeApi = useAppStoreApi();
return useCallback(
async (
content: string,
reviewComments?: DiffComment[],
attachments?: MessageAttachment[],
inlineMentions?: ContextFile[],
inlineTaskMentions?: TaskMentionData[],
) => {
if (!taskId || !sessionId) {
toast({ title: "Session not ready", variant: "error" });
throw new Error("Session not ready");
}
try {
const message = await buildPassthroughFinalMessage({
taskId,
content,
reviewComments,
pendingComments,
panelState,
inlineMentions,
inlineTaskMentions,
getState: storeApi.getState,
});
await requestPassthroughMessage({ taskId, sessionId, message, attachments });
if (message.commentsToSend.length > 0) {
markCommentsSent(message.commentsToSend.map((c) => c.id));
}
clearPassthroughComposerContext(panelState);
onSent();
} catch (error) {
console.error("Failed to send passthrough message:", error);
toast({ title: "Failed to send message", variant: "error" });
throw error;
}
},
[taskId, sessionId, toast, pendingComments, panelState, storeApi, markCommentsSent, onSent],
);
}
@@ -1,124 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
} from "react";
import { IconSend } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
// Cap matches the `max-h-32` Tailwind class on the textarea below — 128 px.
const COMPOSER_MAX_HEIGHT_PX = 128;
/**
* PassthroughComposer is the kandev-controlled compose box rendered alongside
* the PTY in passthrough mode. Submitting forwards the typed text via the
* onSubmit prop (which posts `message.add` over WS); the backend's
* `Executor.Prompt` routes passthrough sessions to PTY stdin so the CLI agent
* actually receives it. Enter submits; Shift+Enter inserts a newline.
*/
export function PassthroughComposer({
onSubmit,
onCancel,
autoFocus = false,
placeholder = "Message the CLI agent (Enter to send, Shift+Enter for newline)",
header,
}: {
onSubmit: (content: string) => Promise<void>;
onCancel?: () => void;
autoFocus?: boolean;
placeholder?: string;
/** Optional content rendered above the textarea — e.g. a pending-comments preview. */
header?: React.ReactNode;
}) {
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const trimmed = value.trim();
const canSubmit = trimmed.length > 0 && !isSending;
// Auto-grow the textarea with content (Shift+Enter newlines, long pastes)
// up to the max-h-32 cap. Done in JS so it works in browsers without
// CSS field-sizing support.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, COMPOSER_MAX_HEIGHT_PX)}px`;
}, [value]);
useEffect(() => {
if (autoFocus) {
textareaRef.current?.focus();
}
}, [autoFocus]);
const submit = useCallback(async () => {
if (!canSubmit) return;
setIsSending(true);
try {
await onSubmit(trimmed);
setValue(""); // only clear on success — preserve text so user can retry on send failure
} catch {
// onSubmit already surfaced the error via toast; the user's typed value
// stays in the textarea intentionally so they can retry without retyping.
} finally {
setIsSending(false);
}
}, [canSubmit, onSubmit, trimmed]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
if (e.nativeEvent.isComposing) return;
e.preventDefault();
void submit();
return;
}
if (e.key === "Escape" && onCancel) {
e.preventDefault();
onCancel();
}
},
[submit, onCancel],
);
return (
<div
className="flex flex-col flex-shrink-0 border-t bg-card"
data-testid="passthrough-composer"
>
{header}
<div className="flex items-end gap-2 px-2 py-2">
<Textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
disabled={isSending}
className="min-h-9 max-h-32 flex-1 resize-none overflow-y-auto"
data-testid="passthrough-composer-textarea"
/>
<Button
type="button"
size="sm"
variant="default"
onClick={() => void submit()}
disabled={!canSubmit}
className="cursor-pointer h-9 shrink-0"
data-testid="passthrough-composer-submit"
aria-label="Send message to CLI agent"
>
<IconSend className="h-4 w-4" />
</Button>
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// --- Constants declared before vi.mock so factories can reference them ---
const TASK_ID = "task-1";
const SESSION_ID = "session-1";
const SRC_FILE = "src/foo.ts";
// data-testid constants to avoid sonarjs/no-duplicate-string warnings
const TID_TOOLBAR = "passthrough-toolbar";
@@ -19,9 +20,14 @@ const TID_COMMENT_REMOVE = "passthrough-comment-remove";
const TID_COMMENT_FILE_REF = "passthrough-comment-file-ref";
const TID_PENDING_COUNT = "passthrough-pending-count";
const TID_PENDING_BANNER = "passthrough-pending-comments-banner";
const TID_PLAN_TOGGLE = "plan-mode-toggle-button";
const TID_ATTACHMENTS = "chat-attachments-button";
const TID_CONTEXT = "chat-context-button";
// --- Mutable state for per-test overrides ---
let mockSessionState: string | null = null;
let mockKeyboardShortcuts: Record<string, { key: string; modifiers?: Record<string, boolean> }> =
{};
let mockPendingByFile: Record<string, import("@/lib/state/slices/comments").DiffComment[]> = {};
let mockNextStep: {
proceedStepName: string | null;
@@ -35,6 +41,11 @@ const mockUpdateComment = vi.fn();
const mockRemoveComment = vi.fn();
const mockOpenFile = vi.fn();
let mockWsRequestFn = vi.fn();
const chatInputMock = vi.hoisted(() => ({
renderProps: vi.fn(),
focusInput: vi.fn(),
getTextareaElement: vi.fn(() => null as HTMLElement | null),
}));
// --- Module mocks (hoisted by Vitest) ---
@@ -46,7 +57,14 @@ vi.mock("@/components/state-provider", () => ({
? { [SESSION_ID]: { id: SESSION_ID, state: mockSessionState } }
: {},
},
userSettings: { keyboardShortcuts: mockKeyboardShortcuts, chatSubmitKey: "enter" },
}),
useAppStoreApi: () => ({
getState: () => ({
kanban: { steps: [] },
kanbanMulti: { snapshots: {} },
}),
}),
}));
vi.mock("@/components/toast-provider", () => ({
@@ -109,6 +127,87 @@ vi.mock("@kandev/ui/tooltip", () => ({
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("./chat/use-chat-panel-state", () => ({
useChatPanelState: () => ({
resolvedSessionId: SESSION_ID,
taskId: TASK_ID,
task: { id: TASK_ID, title: "Task title" },
taskDescription: "Task description",
planModeEnabled: false,
planModeAvailable: true,
mcpServers: ["kandev"],
handlePlanModeChange: vi.fn(),
isStarting: false,
isPreparingEnvironment: false,
contextItems: [{ id: "file:src/foo.ts", kind: "file", label: "foo.ts", filePath: SRC_FILE }],
contextFiles: [{ path: SRC_FILE, name: "foo.ts" }],
handleToggleContextFile: vi.fn(),
handleAddContextFile: vi.fn(),
addContextFile: vi.fn(),
clearEphemeral: vi.fn(),
planContextEnabled: false,
chatSubmitKey: "enter",
prompts: [{ id: "prompt-1", name: "Prompt", content: "Prompt content" }],
planComments: [],
pendingPRFeedback: [],
pendingCommentsByFile: mockPendingByFile,
}),
}));
vi.mock("./chat/chat-input-container", async () => {
const React = await vi.importActual<typeof import("react")>("react");
const ChatInputContainer = React.forwardRef<Record<string, unknown>, Record<string, unknown>>(
function MockChatInputContainer(props, ref) {
const [value, setValue] = React.useState("");
chatInputMock.renderProps(props);
React.useImperativeHandle(ref, () => ({
focusInput: chatInputMock.focusInput,
getTextareaElement: chatInputMock.getTextareaElement,
getValue: () => value,
getSelectionStart: () => value.length,
insertText: vi.fn(),
clear: vi.fn(),
getAttachments: () => [],
}));
return (
<div data-testid="mock-chat-input-container">
{props.hasContextComments ? <div data-testid={TID_PENDING_BANNER} /> : null}
<button type="button" data-testid={TID_PLAN_TOGGLE}>
Plan
</button>
<button type="button" data-testid={TID_ATTACHMENTS}>
Attach
</button>
<button type="button" data-testid={TID_CONTEXT}>
Context
</button>
<textarea
data-testid={TID_TEXTAREA}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
(props.onCancel as () => void)?.();
return;
}
if (event.key === "Enter") {
event.preventDefault();
const result = (props.onSubmit as (message: string) => Promise<void> | void)?.(
value,
);
if (result && typeof result.then === "function") {
void result.catch(() => undefined);
}
}
}}
/>
</div>
);
},
);
return { ChatInputContainer };
});
// Import after mocks
import { PassthroughToolbar } from "./passthrough-toolbar";
@@ -118,7 +217,7 @@ function makeDiffComment(id: string): import("@/lib/state/slices/comments").Diff
id,
source: "diff",
sessionId: SESSION_ID,
filePath: "src/foo.ts",
filePath: SRC_FILE,
startLine: 1,
endLine: 1,
side: "additions",
@@ -140,12 +239,19 @@ async function openComposer() {
function resetMocks() {
mockSessionState = null;
mockKeyboardShortcuts = {};
mockPendingByFile = {};
mockNextStep = { proceedStepName: null, proceed: vi.fn(), isMoving: false };
mockWsRequestFn = vi.fn().mockResolvedValue(undefined);
chatInputMock.renderProps.mockClear();
vi.clearAllMocks();
}
function latestChatInputProps(): Record<string, unknown> {
const calls = chatInputMock.renderProps.mock.calls;
return (calls[calls.length - 1]?.[0] ?? {}) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Default / idle state
// ---------------------------------------------------------------------------
@@ -179,7 +285,7 @@ describe("PassthroughToolbar – composer toggle", () => {
beforeEach(resetMocks);
afterEach(cleanup);
it("clicking Chat toggle opens the composer and sets aria-pressed=true", async () => {
it("clicking Chat toggle opens and closes the composer", async () => {
renderToolbar();
const toggle = screen.getByTestId(TID_TOGGLE);
expect(toggle.getAttribute("aria-pressed")).toBe("false");
@@ -188,6 +294,11 @@ describe("PassthroughToolbar – composer toggle", () => {
await waitFor(() => expect(screen.getByTestId(TID_COMPOSER)).toBeTruthy());
expect(toggle.getAttribute("aria-pressed")).toBe("true");
fireEvent.click(toggle);
await waitFor(() => expect(screen.queryByTestId(TID_COMPOSER)).toBeNull());
expect(toggle.getAttribute("aria-pressed")).toBe("false");
});
it("pressing Escape inside the composer closes it", async () => {
@@ -198,6 +309,102 @@ describe("PassthroughToolbar – composer toggle", () => {
await waitFor(() => expect(screen.queryByTestId(TID_COMPOSER)).toBeNull());
});
it("wires passthrough composer controls and disables ACP-only controls", async () => {
renderToolbar();
await openComposer();
expect(screen.getByTestId(TID_PLAN_TOGGLE)).toBeTruthy();
expect(screen.getByTestId(TID_ATTACHMENTS)).toBeTruthy();
expect(screen.getByTestId(TID_CONTEXT)).toBeTruthy();
const props = latestChatInputProps();
expect(props.hasAgentCommands).toBe(false);
expect(props.hideAgentControls).toBe(true);
expect(props.contextItems).toEqual(
expect.arrayContaining([expect.objectContaining({ kind: "file", label: "foo.ts" })]),
);
expect(props.contextFiles).toEqual([{ path: SRC_FILE, name: "foo.ts" }]);
});
it("uses the passthrough-specific focus shortcut instead of the global slash shortcut", async () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
};
renderToolbar();
fireEvent.keyDown(window, { key: "/", code: "Slash" });
expect(screen.queryByTestId(TID_COMPOSER)).toBeNull();
fireEvent.keyDown(window, { key: "y", code: "KeyY", ctrlKey: true, shiftKey: true });
await waitFor(() => expect(screen.getByTestId(TID_COMPOSER)).toBeTruthy());
expect(chatInputMock.focusInput).toHaveBeenCalled();
});
it("shows the passthrough chat shortcut in the Chat tooltip", () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
};
renderToolbar();
expect(screen.getByText(/Ctrl\+Shift\+Y|Cmd\+Shift\+Y/)).toBeTruthy();
});
it("focus shortcut closes the composer when the composer textarea has focus", async () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
};
renderToolbar();
fireEvent.keyDown(window, { key: "y", code: "KeyY", ctrlKey: true, shiftKey: true });
await waitFor(() => expect(screen.getByTestId(TID_COMPOSER)).toBeTruthy());
const textarea = screen.getByTestId(TID_TEXTAREA);
textarea.focus();
fireEvent.keyDown(window, { key: "y", code: "KeyY", ctrlKey: true, shiftKey: true });
await waitFor(() => expect(screen.queryByTestId(TID_COMPOSER)).toBeNull());
});
it("does not steal focus from another editable field with the focus shortcut", () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
};
renderToolbar();
const input = document.createElement("input");
document.body.appendChild(input);
try {
input.focus();
fireEvent.keyDown(window, { key: "y", code: "KeyY", ctrlKey: true, shiftKey: true });
expect(screen.queryByTestId(TID_COMPOSER)).toBeNull();
expect(chatInputMock.focusInput).not.toHaveBeenCalled();
} finally {
input.remove();
}
});
it("focus shortcut works when xterm helper textarea owns focus", async () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
};
renderToolbar();
const xterm = document.createElement("div");
xterm.className = "xterm";
const textarea = document.createElement("textarea");
xterm.appendChild(textarea);
document.body.appendChild(xterm);
try {
textarea.focus();
fireEvent.keyDown(window, { key: "y", code: "KeyY", ctrlKey: true, shiftKey: true });
await waitFor(() => expect(screen.getByTestId(TID_COMPOSER)).toBeTruthy());
expect(chatInputMock.focusInput).toHaveBeenCalled();
} finally {
xterm.remove();
}
});
});
// ---------------------------------------------------------------------------
@@ -218,7 +425,12 @@ describe("PassthroughToolbar – send message", () => {
await waitFor(() =>
expect(mockWsRequestFn).toHaveBeenCalledWith(
"message.add",
{ task_id: TASK_ID, session_id: SESSION_ID, content: "hello" },
expect.objectContaining({
task_id: TASK_ID,
session_id: SESSION_ID,
content: expect.stringContaining("hello"),
context_files: [{ path: SRC_FILE, name: "foo.ts" }],
}),
10_000,
),
);
@@ -227,7 +439,7 @@ describe("PassthroughToolbar – send message", () => {
});
it("send with pending comments prepends review markdown and calls markCommentsSent", async () => {
mockPendingByFile = { "src/foo.ts": [makeDiffComment("c1")] };
mockPendingByFile = { [SRC_FILE]: [makeDiffComment("c1")] };
renderToolbar();
await openComposer();
@@ -238,7 +450,8 @@ describe("PassthroughToolbar – send message", () => {
const content = mockWsRequestFn.mock.calls[0][1].content as string;
expect(content).toMatch(/^### Review Comments\n/);
expect(content).toMatch(/ship it$/);
expect(content).toContain("ship it");
expect(content).toContain("CONTEXT FILES");
await waitFor(() => expect(mockMarkCommentsSent).toHaveBeenCalledWith(["c1"]));
});
@@ -273,7 +486,7 @@ describe("PassthroughToolbar – pending comment indicators", () => {
it("shows a numeric chip when the composer is collapsed and comments are pending", () => {
mockPendingByFile = {
"src/foo.ts": [makeDiffComment("c1"), makeDiffComment("c2"), makeDiffComment("c3")],
[SRC_FILE]: [makeDiffComment("c1"), makeDiffComment("c2"), makeDiffComment("c3")],
};
renderToolbar();
@@ -282,12 +495,11 @@ describe("PassthroughToolbar – pending comment indicators", () => {
});
it("renders the pending-comments banner inside the open composer when comments are pending", async () => {
mockPendingByFile = { "src/foo.ts": [makeDiffComment("c1"), makeDiffComment("c2")] };
mockPendingByFile = { [SRC_FILE]: [makeDiffComment("c1"), makeDiffComment("c2")] };
renderToolbar();
await openComposer();
const banner = screen.getByTestId(TID_PENDING_BANNER);
expect(banner.textContent).toMatch(/2/);
expect(latestChatInputProps().pendingCommentsByFile).toEqual(mockPendingByFile);
});
});
@@ -301,7 +513,7 @@ describe("PassthroughToolbar – Comments panel", () => {
it("clicking Comments opens a panel with one card per pending comment", async () => {
mockPendingByFile = {
"src/foo.ts": [makeDiffComment("c1"), makeDiffComment("c2")],
[SRC_FILE]: [makeDiffComment("c1"), makeDiffComment("c2")],
};
renderToolbar();
@@ -312,7 +524,7 @@ describe("PassthroughToolbar – Comments panel", () => {
});
it("editing a comment textarea calls updateComment with the new text", async () => {
mockPendingByFile = { "src/foo.ts": [makeDiffComment("c1")] };
mockPendingByFile = { [SRC_FILE]: [makeDiffComment("c1")] };
renderToolbar();
fireEvent.click(screen.getByTestId(TID_TOGGLE_COMMENTS));
@@ -326,7 +538,7 @@ describe("PassthroughToolbar – Comments panel", () => {
});
it("clicking the file reference opens the file in an editor", async () => {
mockPendingByFile = { "src/foo.ts": [makeDiffComment("c1")] };
mockPendingByFile = { [SRC_FILE]: [makeDiffComment("c1")] };
renderToolbar();
fireEvent.click(screen.getByTestId(TID_TOGGLE_COMMENTS));
@@ -334,11 +546,11 @@ describe("PassthroughToolbar – Comments panel", () => {
fireEvent.click(screen.getByTestId(TID_COMMENT_FILE_REF));
expect(mockOpenFile).toHaveBeenCalledWith("src/foo.ts");
expect(mockOpenFile).toHaveBeenCalledWith(SRC_FILE);
});
it("clicking the remove button on a comment calls removeComment", async () => {
mockPendingByFile = { "src/foo.ts": [makeDiffComment("c1")] };
mockPendingByFile = { [SRC_FILE]: [makeDiffComment("c1")] };
renderToolbar();
fireEvent.click(screen.getByTestId(TID_TOGGLE_COMMENTS));
+106 -82
View File
@@ -1,6 +1,14 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import {
IconArrowRight,
IconMessageCircle,
@@ -12,20 +20,54 @@ import {
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useToast } from "@/components/toast-provider";
import { PRStatusChip } from "@/components/github/pr-status-chip";
import { PassthroughComposer } from "./passthrough-composer";
import { PRMergedBanner } from "./chat/chat-input-area";
import { type ChatInputContainerHandle } from "./chat/chat-input-container";
import { useChatPanelState } from "./chat/use-chat-panel-state";
import { useAppStore } from "@/components/state-provider";
import { useNextWorkflowStep } from "@/hooks/domains/kanban/use-plan-actions";
import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut";
import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments";
import { useCommentsStore } from "@/lib/state/slices/comments/comments-store";
import { useFileEditors } from "@/hooks/use-file-editors";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format";
import { getShortcut, isUnboundShortcut } from "@/lib/keyboard/shortcut-overrides";
import { formatShortcut } from "@/lib/keyboard/utils";
import type { KeyboardShortcut } from "@/lib/keyboard/constants";
import type { DiffComment } from "@/lib/diff/types";
import { getWebSocketClient } from "@/lib/ws/connection";
import { PassthroughTerminal } from "./passthrough-terminal";
import { PassthroughComposerPanel, useSendPassthroughMessage } from "./passthrough-chat-composer";
function isEditableElement(element: Element | null) {
if (element instanceof HTMLElement && element.closest(".xterm")) return false;
return (
element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement ||
element instanceof HTMLSelectElement ||
(element instanceof HTMLElement && element.isContentEditable)
);
}
function usePassthroughComposerShortcut({
focusShortcut,
setComposerOpen,
}: {
focusShortcut: KeyboardShortcut;
setComposerOpen: Dispatch<SetStateAction<boolean>>;
}) {
useKeyboardShortcut(
focusShortcut,
useCallback(() => {
const activeElement = document.activeElement;
setComposerOpen((open) => {
if (open) return false;
if (isEditableElement(activeElement)) return open;
return true;
});
}, [setComposerOpen]),
{ capture: true, enabled: !isUnboundShortcut(focusShortcut) },
);
}
/**
* PassthroughToolbar wraps the PTY terminal with the kandev surface that the
@@ -49,10 +91,14 @@ export function PassthroughToolbar({
}) {
const [composerOpen, setComposerOpen] = useState(false);
const [commentsOpenState, setCommentsOpen] = useState(false);
const [isSending, setIsSending] = useState(false);
const chatInputRef = useRef<ChatInputContainerHandle | null>(null);
const sessionState = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId]?.state ?? null) : null,
);
const keyboardShortcuts = useAppStore((state) => state.userSettings?.keyboardShortcuts);
const focusShortcut = getShortcut("FOCUS_PASSTHROUGH_INPUT", keyboardShortcuts);
const isAgentBusy = sessionState === "RUNNING" || sessionState === "STARTING";
const nextStep = useNextWorkflowStep(taskId);
@@ -61,19 +107,40 @@ export function PassthroughToolbar({
const { pendingComments, pendingCount } = usePendingPassthroughComments(sessionId);
const { isMobile } = useResponsiveBreakpoint();
const { openFile } = useFileEditors();
const panelState = useChatPanelState({ sessionId: sessionId ?? null, onOpenFile: openFile });
// Derive open state: auto-close when pending comments are cleared
const commentsOpen = commentsOpenState && pendingCount > 0;
const handleSendMessage = useSendPassthroughMessage({
const sendPassthroughMessage = useSendPassthroughMessage({
taskId,
sessionId,
pendingComments,
panelState,
onSent: () => {
setComposerOpen(false);
setCommentsOpen(false);
},
});
const handleSendMessage = useCallback(
async (...args: Parameters<typeof sendPassthroughMessage>) => {
if (isSending) return;
setIsSending(true);
try {
await sendPassthroughMessage(...args);
} finally {
setIsSending(false);
}
},
[isSending, sendPassthroughMessage],
);
useEffect(() => {
if (!composerOpen) return;
requestAnimationFrame(() => chatInputRef.current?.focusInput());
}, [composerOpen]);
usePassthroughComposerShortcut({ focusShortcut, setComposerOpen });
return (
<div className="flex h-full flex-col bg-card" data-testid="passthrough-toolbar">
@@ -90,12 +157,14 @@ export function PassthroughToolbar({
)}
{composerOpen && (
<PassthroughComposer
<PassthroughComposerPanel
refHandle={chatInputRef}
onSubmit={handleSendMessage}
onCancel={() => setComposerOpen(false)}
autoFocus
placeholder="Type a message, Shift+Enter for newline, Esc to close"
header={pendingCount > 0 ? <PendingCommentsHint count={pendingCount} /> : null}
panelState={panelState}
taskId={taskId}
isMoving={nextStep.isMoving}
isSending={isSending}
/>
)}
@@ -106,6 +175,7 @@ export function PassthroughToolbar({
isMoving={nextStep.isMoving}
showProceed={showProceed}
composerOpen={composerOpen}
focusShortcut={focusShortcut}
onToggleComposer={() => setComposerOpen((open) => !open)}
commentsOpen={commentsOpen}
onToggleComments={() => setCommentsOpen((open) => !open)}
@@ -130,64 +200,13 @@ function usePendingPassthroughComments(sessionId: string | null | undefined) {
return { pendingComments, pendingCount: pendingComments.length };
}
function useSendPassthroughMessage({
taskId,
sessionId,
pendingComments,
onSent,
}: {
taskId: string | null;
sessionId: string | null | undefined;
pendingComments: DiffComment[];
onSent: () => void;
}) {
const { toast } = useToast();
const markCommentsSent = useCommentsStore((s) => s.markCommentsSent);
return useCallback(
async (content: string) => {
if (!taskId || !sessionId) {
toast({ title: "Session not ready", variant: "error" });
throw new Error("Session not ready");
}
const client = getWebSocketClient();
if (!client) {
toast({ title: "Not connected — please reload to retry", variant: "error" });
throw new Error("WebSocket client not available");
}
// Format pending review comments as markdown and prepend so they reach
// the agent's stdin alongside the user's typed prompt. Mark them sent
// only after the WS request resolves so a failed send doesn't drop the
// user's queued comments.
const formatted =
pendingComments.length > 0
? formatReviewCommentsAsMarkdown(pendingComments) + content
: content;
try {
await client.request(
"message.add",
{ task_id: taskId, session_id: sessionId, content: formatted },
10_000,
);
if (pendingComments.length > 0) {
markCommentsSent(pendingComments.map((c) => c.id));
}
onSent();
} catch (error) {
console.error("Failed to send passthrough message:", error);
toast({ title: "Failed to send message", variant: "error" });
throw error;
}
},
[taskId, sessionId, toast, pendingComments, markCommentsSent, onSent],
);
}
function ChatToggleButton({
composerOpen,
focusShortcut,
onToggle,
}: {
composerOpen: boolean;
focusShortcut: KeyboardShortcut;
onToggle: () => void;
}) {
return (
@@ -212,16 +231,20 @@ function ChatToggleButton({
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{composerOpen ? (
<p>
Close the compose box (or press <kbd>Esc</kbd> inside it). The CLI agent terminal keeps
focus.
</p>
<div className="space-y-1">
<p>
Close the compose box (or press <kbd>Esc</kbd> inside it). The CLI agent terminal
keeps focus.
</p>
<PassthroughChatShortcutHint shortcut={focusShortcut} />
</div>
) : (
<div className="space-y-1">
<p>
Open a kandev-controlled compose box above the terminal to type a follow-up message.
Press <kbd>Enter</kbd> to send, <kbd>Shift+Enter</kbd> for a newline.
</p>
<PassthroughChatShortcutHint shortcut={focusShortcut} />
<p className="text-muted-foreground">
The text is delivered straight to the CLI agent&apos;s stdin — pending review comments
(if any) are prepended automatically.
@@ -233,6 +256,15 @@ function ChatToggleButton({
);
}
function PassthroughChatShortcutHint({ shortcut }: { shortcut: KeyboardShortcut }) {
if (isUnboundShortcut(shortcut)) return null;
return (
<p className="text-muted-foreground">
Shortcut: <kbd>{formatShortcut(shortcut)}</kbd> toggles this chat input.
</p>
);
}
function commentsToggleClassName(count: number, commentsOpen: boolean): string {
// Vivid amber when there are pending comments so the user sees "something
// to do" — washes back to plain outline once they're cleared / sent.
@@ -321,20 +353,6 @@ function CommentsTooltipBody({ commentsOpen, count }: { commentsOpen: boolean; c
);
}
function PendingCommentsHint({ count }: { count: number }) {
return (
<div
data-testid="passthrough-pending-comments-banner"
className="flex items-center gap-1.5 border-b bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300"
>
<IconMessageDots className="h-3.5 w-3.5" />
<span>
{count} pending review comment{count === 1 ? "" : "s"} will be attached to this message.
</span>
</div>
);
}
function CommentsPanel({
comments,
openFile,
@@ -471,6 +489,7 @@ type StatusRowProps = {
isMoving: boolean;
showProceed: boolean;
composerOpen: boolean;
focusShortcut: KeyboardShortcut;
onToggleComposer: () => void;
commentsOpen: boolean;
onToggleComments: () => void;
@@ -484,6 +503,7 @@ function PassthroughStatusRow({
isMoving,
showProceed,
composerOpen,
focusShortcut,
onToggleComposer,
commentsOpen,
onToggleComments,
@@ -494,7 +514,11 @@ function PassthroughStatusRow({
data-testid="passthrough-status-row"
className="flex flex-shrink-0 items-center gap-1.5 border-t bg-card px-2 py-1 text-xs text-muted-foreground"
>
<ChatToggleButton composerOpen={composerOpen} onToggle={onToggleComposer} />
<ChatToggleButton
composerOpen={composerOpen}
focusShortcut={focusShortcut}
onToggle={onToggleComposer}
/>
<CommentsToggleButton
commentsOpen={commentsOpen}
onToggle={onToggleComments}
@@ -1,101 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, fireEvent, screen, cleanup, waitFor } from "@testing-library/react";
import { PassthroughComposer } from "./passthrough-composer";
const TEXTAREA_TID = "passthrough-composer-textarea";
const SUBMIT_TID = "passthrough-composer-submit";
describe("PassthroughComposer", () => {
afterEach(() => {
cleanup();
});
it("submits trimmed text on Enter and clears the textarea", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: " hello agent " } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(onSubmit).toHaveBeenCalledWith("hello agent");
await waitFor(() => expect(textarea.value).toBe(""));
});
it("submits when the send button is clicked", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
fireEvent.change(screen.getByTestId(TEXTAREA_TID), {
target: { value: "ping" },
});
fireEvent.click(screen.getByTestId(SUBMIT_TID));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith("ping"));
});
it("Shift+Enter inserts a newline and does not submit", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID);
fireEvent.change(textarea, { target: { value: "line1" } });
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true });
expect(onSubmit).not.toHaveBeenCalled();
});
it("does not submit empty or whitespace-only input", () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID);
fireEvent.keyDown(textarea, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
fireEvent.change(textarea, { target: { value: " \n " } });
fireEvent.keyDown(textarea, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
expect(submitBtn.disabled).toBe(true);
});
it("preserves the typed text when onSubmit rejects so the user can retry", async () => {
const onSubmit = vi.fn().mockRejectedValue(new Error("network down"));
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "important request" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
// Composer must NOT clear on failure — losing typed text on a flaky WS
// disconnect was a real UX regression (issue #989 review feedback).
expect(textarea.value).toBe("important request");
// Composer must re-enable so the user can resubmit.
await waitFor(() => expect(textarea.disabled).toBe(false));
});
it("disables the composer while a submission is in flight", async () => {
let resolveSubmit!: () => void;
const submitPromise = new Promise<void>((resolve) => {
resolveSubmit = resolve;
});
const onSubmit = vi.fn().mockReturnValue(submitPromise);
render(<PassthroughComposer onSubmit={onSubmit} />);
const textarea = screen.getByTestId(TEXTAREA_TID) as HTMLTextAreaElement;
const submitBtn = screen.getByTestId(SUBMIT_TID) as HTMLButtonElement;
fireEvent.change(textarea, { target: { value: "hi" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => expect(textarea.disabled).toBe(true));
expect(submitBtn.disabled).toBe(true);
resolveSubmit();
await waitFor(() => expect(textarea.disabled).toBe(false));
expect(textarea.value).toBe("");
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it, vi } from "vitest";
import { applySummarizeSessionResult } from "./session-context-summary";
describe("applySummarizeSessionResult", () => {
it("sanitizes unsafe characters before writing the prompt", () => {
const promptRef = { current: { value: "" } as HTMLTextAreaElement };
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const toast = vi.fn();
applySummarizeSessionResult({
result: { summary: "line1\r\n<unsafe>\nline2" },
promptRef,
setContextValue,
setHasPrompt,
toast,
});
expect(promptRef.current.value).toBe("line1\n unsafe \nline2");
expect(setHasPrompt).toHaveBeenCalledWith(true);
expect(toast).not.toHaveBeenCalled();
});
it("treats an empty summary string as a successful summary", () => {
const promptRef = { current: { value: "existing" } as HTMLTextAreaElement };
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const toast = vi.fn();
applySummarizeSessionResult({
result: { summary: "" },
promptRef,
setContextValue,
setHasPrompt,
toast,
});
expect(promptRef.current.value).toBe("");
expect(setHasPrompt).toHaveBeenCalledWith(true);
expect(toast).not.toHaveBeenCalled();
});
it("resets context and prompt state when summary is null", () => {
const promptRef = { current: { value: "stale prompt" } as HTMLTextAreaElement };
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const toast = vi.fn();
applySummarizeSessionResult({
result: { summary: null, error: "connection refused" },
promptRef,
setContextValue,
setHasPrompt,
toast,
});
expect(setContextValue).toHaveBeenCalledWith("blank");
expect(promptRef.current.value).toBe("");
expect(setHasPrompt).toHaveBeenCalledWith(false);
expect(toast).toHaveBeenCalledWith({
title: "Summarize failed",
description: "connection refused",
variant: "error",
});
});
});
@@ -0,0 +1,47 @@
import type { RefObject } from "react";
import type { SummarizeSessionResult } from "@/hooks/use-summarize-session";
export type SummaryToastFn = (opts: {
title: string;
description?: string;
variant?: "error" | "default";
}) => void;
export function sanitizePromptText(value: string): string {
return value.replace(/\r/g, "").replace(/[<>]/g, " ");
}
export function applySummarizeSessionResult({
result,
promptRef,
setContextValue,
setHasPrompt,
toast,
}: {
result: SummarizeSessionResult;
promptRef: RefObject<HTMLTextAreaElement | null>;
setContextValue: (v: string) => void;
setHasPrompt: (v: boolean) => void;
toast: SummaryToastFn;
}) {
if (result.summary === null) {
setContextValue("blank");
if (promptRef.current) {
promptRef.current.value = "";
}
setHasPrompt(false);
toast({
title: "Summarize failed",
description:
result.error ??
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
variant: "error",
});
return;
}
if (promptRef.current) {
promptRef.current.value = sanitizePromptText(result.summary);
setHasPrompt(true);
}
}
+14 -11
View File
@@ -1222,17 +1222,20 @@ export class ApiClient {
* Used by tests that need a second session on a task with an existing
* environment (multi-session reuse, recovery scenarios).
*/
async launchSession(payload: {
task_id: string;
agent_profile_id: string;
executor_id?: string;
executor_profile_id?: string;
prompt: string;
intent?: string;
workflow_step_id?: string;
auto_start?: boolean;
}): Promise<{ session_id: string; agent_execution_id: string; state: string }> {
return this.wsRequest("session.launch", payload);
async launchSession(
payload: {
task_id: string;
agent_profile_id: string;
executor_id?: string;
executor_profile_id?: string;
prompt: string;
intent?: string;
workflow_step_id?: string;
auto_start?: boolean;
},
timeoutMs = 30_000,
): Promise<{ session_id: string; agent_execution_id: string; state: string }> {
return this.wsRequest("session.launch", payload, timeoutMs);
}
/** Stop a running session via WS `session.stop` — same path the UI uses. */
+24
View File
@@ -4,6 +4,7 @@ type E2EStoreWindow = Window & {
__KANDEV_E2E_STORE__?: {
getState: () => {
taskSessions: { items: Record<string, Record<string, unknown>> };
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
};
setState: (
updater: (state: {
@@ -13,6 +14,12 @@ type E2EStoreWindow = Window & {
};
};
type AvailableCommand = {
name: string;
description?: string;
input_hint?: string;
};
/**
* Simulate a lean session-list / partial WS update: preserve `is_passthrough`
* but drop `agent_profile_snapshot` from the client store.
@@ -42,3 +49,20 @@ export async function stripSessionProfileSnapshot(page: Page, sessionId: string)
}
}, sessionId);
}
export async function seedAvailableCommands(
page: Page,
sessionId: string,
commands: AvailableCommand[],
): Promise<void> {
await page.evaluate(
({ sid, commandList }) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) {
throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?");
}
store.getState().setAvailableCommands(sid, commandList);
},
{ sid: sessionId, commandList: commands },
);
}
+11 -1
View File
@@ -284,9 +284,19 @@ function generateKeypair(identityFile: string): void {
* launch. Throws when the command exits non-zero.
*/
export function execInContainer(handle: SSHServerHandle, argv: string[]): string {
const res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
let res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
encoding: "utf8",
});
const transientUpgradeFailure =
res.status !== 0 &&
(res.stderr || res.stdout).includes("unable to upgrade to tcp") &&
(res.stderr || res.stdout).includes("409");
if (transientUpgradeFailure) {
sleep(500);
res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
encoding: "utf8",
});
}
if (res.status !== 0) {
throw new Error(
`docker exec ${argv.join(" ")} failed (status=${res.status}): ${res.stderr || res.stdout}`,
@@ -91,9 +91,9 @@ test.describe("CLI mode: message.add submits to PTY", () => {
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
const followUp = "passthrough message.add submit e2e";
const textarea = testPage.getByTestId("passthrough-composer-textarea");
await textarea.fill(followUp);
await textarea.press("Enter");
const editor = testPage.getByTestId("passthrough-composer").locator(".tiptap.ProseMirror");
await editor.fill(followUp);
await testPage.getByTestId("submit-message-button").click();
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText(`Processed: ${followUp}`, 20_000);
@@ -0,0 +1,139 @@
// Filename starts with "mobile-" so this runs in the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { seedAvailableCommands } from "../../helpers/session-store";
import { SessionPage } from "../../pages/session-page";
async function createPassthroughProfile(apiClient: ApiClient, name: string): Promise<string> {
const { agents } = await apiClient.listAgents();
if (agents.length === 0) throw new Error("no agents registered in this e2e profile");
const profile = await apiClient.createAgentProfile(agents[0].id, name, {
model: "mock-fast",
auto_approve: true,
cli_passthrough: true,
});
return profile.id;
}
async function openMobilePassthroughTask(
testPage: import("@playwright/test").Page,
apiClient: ApiClient,
seedData: {
workspaceId: string;
workflowId: string;
startStepId: string;
repositoryId: string;
},
profileName: string,
taskTitle: string,
) {
const profileId = await createPassthroughProfile(apiClient, profileName);
const task = await apiClient.createTaskWithAgent(seedData.workspaceId, taskTitle, profileId, {
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
if (!task.session_id) throw new Error("expected passthrough task to start a session");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForPassthroughLoad(20_000);
await session.waitForPassthroughLoaded(20_000);
await session.expectPassthroughHasText("Processed:", 20_000);
return { task, session };
}
test.describe("mobile CLI mode: passthrough composer", () => {
test("slash remains literal and only passthrough composer controls are available", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const profileId = await createPassthroughProfile(apiClient, "Mobile CLI Commands");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile CLI Commands Task",
profileId,
{
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected passthrough task to start a session");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForPassthroughLoad(20_000);
await session.waitForPassthroughLoaded(20_000);
await session.expectPassthroughHasText("Processed:", 20_000);
await seedAvailableCommands(testPage, task.session_id, [
{ name: "slow", description: "Run slowly" },
{ name: "error", description: "Trigger an error" },
]);
await testPage.getByTestId("passthrough-toggle-composer").tap();
const composer = testPage.getByTestId("passthrough-composer");
const editor = composer.locator(".tiptap.ProseMirror");
await expect(editor).toBeVisible({ timeout: 5_000 });
await expect(testPage.getByTestId("plan-mode-toggle-button")).toBeVisible();
await expect(testPage.getByTestId("chat-context-button")).toBeVisible();
await expect(testPage.getByTestId("chat-attachments-button")).toBeVisible();
await expect(testPage.getByTestId("toolbar-item-mcp")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-mode")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-model")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-reset-context")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-enhance")).toHaveCount(0);
await editor.fill("/s");
await expect(testPage.getByRole("listbox", { name: "Command suggestions" })).toHaveCount(0);
await expect(editor).toHaveText("/s");
});
test("context prompt selection creates a chip on mobile", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const promptName = `mobile-pt-prompt-${Date.now()}`;
const promptContent = "MOBILE_PASSTHROUGH_PROMPT_MARKER";
await apiClient.createPrompt(promptName, promptContent);
const { session } = await openMobilePassthroughTask(
testPage,
apiClient,
seedData,
"Mobile CLI Context",
"Mobile CLI Context Task",
);
await testPage.getByTestId("passthrough-toggle-composer").tap();
const composer = testPage.getByTestId("passthrough-composer");
const editor = composer.locator(".tiptap.ProseMirror");
await expect(editor).toBeVisible({ timeout: 5_000 });
await testPage.getByTestId("chat-context-button").tap();
const searchInput = testPage.getByPlaceholder("Search files and prompts...");
await expect(searchInput).toBeVisible({ timeout: 5_000 });
await searchInput.fill(promptName);
await testPage.getByText(promptName, { exact: true }).tap();
await expect(composer.getByText(promptName, { exact: true })).toBeVisible({
timeout: 5_000,
});
await editor.fill("mobile context e2e");
await testPage.getByTestId("submit-message-button").tap();
await expect(composer).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText("mobile context e2e", 15_000);
await session.expectPassthroughHasText("CONTEXT PROMPTS", 15_000);
await session.expectPassthroughHasText(promptContent, 15_000);
});
});
@@ -1,8 +1,19 @@
import { expect } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { test } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
import { seedAvailableCommands } from "../../helpers/session-store";
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
type PersistedAttachment = {
type?: string;
name?: string;
mime_type?: string;
delivery_mode?: string;
};
/**
* CLI-mode parity: PassthroughToolbar mounts above the PTY in passthrough
@@ -46,6 +57,56 @@ test.describe("CLI mode: passthrough toolbar", () => {
await session.waitForPassthroughLoaded(20_000);
}
function passthroughEditor(testPage: import("@playwright/test").Page) {
return testPage.getByTestId("passthrough-composer").locator(".tiptap.ProseMirror");
}
function passthroughComposer(testPage: import("@playwright/test").Page) {
return testPage.getByTestId("passthrough-composer");
}
async function waitForPassthroughPromptable(
apiClient: ApiClient,
taskId: string,
sessionId: string,
) {
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(taskId);
return sessions.find((s) => s.id === sessionId)?.state ?? null;
},
{ timeout: 20_000, message: "Wait for passthrough session to accept a follow-up" },
)
.toBe("WAITING_FOR_INPUT");
}
async function waitForPersistedAttachment(
apiClient: ApiClient,
sessionId: string,
content: string,
): Promise<PersistedAttachment> {
await expect
.poll(
async () => {
const { messages } = await apiClient.listSessionMessages(sessionId);
const message = messages.find(
(m) => m.author_type === "user" && m.content.includes(content),
);
const attachments = message?.metadata?.attachments;
return Array.isArray(attachments) ? attachments.length : 0;
},
{ timeout: 15_000, message: `Wait for persisted attachment on "${content}"` },
)
.toBeGreaterThan(0);
const { messages } = await apiClient.listSessionMessages(sessionId);
const message = messages.find((m) => m.author_type === "user" && m.content.includes(content));
const attachments = message?.metadata?.attachments;
if (!Array.isArray(attachments)) throw new Error("Persisted user message has no attachments");
return attachments[0] as PersistedAttachment;
}
test("toolbar renders for passthrough sessions on the task page", async ({
testPage,
apiClient,
@@ -70,6 +131,25 @@ test.describe("CLI mode: passthrough toolbar", () => {
await expect(testPage.getByTestId("passthrough-toggle-composer")).toBeVisible({
timeout: 5_000,
});
const chatToggle = testPage.getByTestId("passthrough-toggle-composer");
await chatToggle.hover();
const tooltip = testPage.getByRole("tooltip").filter({ hasText: "Shortcut:" });
await expect(tooltip).toContainText(/Ctrl\+Shift\+Y|Cmd\+Shift\+Y/);
await chatToggle.click();
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
await expect(testPage.getByTestId("plan-mode-toggle-button")).toBeVisible();
await expect(testPage.getByTestId("chat-attachments-button")).toBeVisible();
await expect(testPage.getByTestId("chat-context-button")).toBeVisible();
await expect(testPage.getByTestId("toolbar-item-mcp")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-mode")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-model")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-reset-context")).toHaveCount(0);
await expect(testPage.getByTestId("toolbar-item-enhance")).toHaveCount(0);
await chatToggle.click();
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 5_000 });
});
test("composer toggle + send forwards message to the agent", async ({
@@ -99,9 +179,9 @@ test.describe("CLI mode: passthrough toolbar", () => {
await testPage.getByTestId("passthrough-toggle-composer").click();
await expect(testPage.getByTestId("passthrough-composer")).toBeVisible({ timeout: 5_000 });
const textarea = testPage.getByTestId("passthrough-composer-textarea");
await textarea.fill("hello from e2e");
await textarea.press("Enter");
const editor = passthroughEditor(testPage);
await editor.fill("hello from e2e");
await testPage.getByTestId("submit-message-button").click();
// The composer closes on successful send
await expect(testPage.getByTestId("passthrough-composer")).toBeHidden({ timeout: 10_000 });
@@ -110,6 +190,267 @@ test.describe("CLI mode: passthrough toolbar", () => {
await session.expectPassthroughHasText("hello from e2e", 15_000);
});
test("composer keeps slash literal and does not show command suggestions", async ({
testPage,
apiClient,
seedData,
}) => {
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Commands");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Toolbar Commands Task",
profileId,
{
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected passthrough task to start a session");
const kanban = new KanbanPage(testPage);
await kanban.goto();
const session = new SessionPage(testPage);
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Commands Task");
await session.expectPassthroughHasText("Processed:", 20_000);
await seedAvailableCommands(testPage, task.session_id, [
{ name: "slow", description: "Run slowly" },
{ name: "error", description: "Trigger an error" },
]);
await testPage.getByTestId("passthrough-toggle-composer").click();
const editor = passthroughEditor(testPage);
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.fill("/s");
await expect(testPage.getByRole("listbox", { name: "Command suggestions" })).toHaveCount(0);
await expect(editor).toHaveText("/s");
});
test("supports @ prompt/file lookup chips and sends context to passthrough stdin", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(90_000);
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
const git = new GitHelper(repoDir, makeGitEnv(backend.tmpDir));
const contextFile = `ctx/passthrough-context-${Date.now()}.ts`;
git.createFile(contextFile, "export const passthroughContextMarker = true;\n");
git.stageAll();
git.commit("seed passthrough context lookup");
const promptName = `pt-prompt-${Date.now()}`;
const promptContent = "PASSTHROUGH_PROMPT_CONTEXT_MARKER";
await apiClient.createPrompt(promptName, promptContent);
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Context");
await apiClient.createTaskWithAgent(seedData.workspaceId, "Toolbar Context Task", profileId, {
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const session = new SessionPage(testPage);
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Context Task");
await session.expectPassthroughHasText("Processed:", 20_000);
await testPage.getByTestId("passthrough-toggle-composer").click();
const editor = passthroughEditor(testPage);
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.pressSequentially(`context lookup e2e @${promptName}`);
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
timeout: 5_000,
});
await testPage.getByRole("button", { name: new RegExp(promptName) }).click();
await expect(editor).toContainText(promptName);
const contextFileName = path.basename(contextFile);
await editor.pressSequentially(` @${contextFileName}`);
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
timeout: 10_000,
});
await testPage.getByRole("button", { name: new RegExp(contextFileName) }).click();
await expect(editor).toContainText(contextFileName);
await testPage.getByTestId("submit-message-button").click();
await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText("context lookup e2e", 15_000);
await session.expectPassthroughHasText("CONTEXT PROMPTS", 15_000);
await session.expectPassthroughHasText(promptContent, 15_000);
await session.expectPassthroughHasText("CONTEXT FILES", 15_000);
await session.expectPassthroughHasText(contextFile, 15_000);
});
test("expands @ Plan context into passthrough stdin instead of sending a literal mention", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Plan Context");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Toolbar Plan Context Task",
profileId,
{
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected passthrough task to start a session");
const planContent = "PASSTHROUGH_PLAN_CONTEXT_MARKER";
await apiClient.wsRequest("task.plan.create", {
task_id: task.id,
title: "Plan",
content: `## Plan\n\n${planContent}`,
created_by: "user",
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const session = new SessionPage(testPage);
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Plan Context Task");
await session.expectPassthroughHasText("Processed:", 20_000);
await testPage.getByTestId("passthrough-toggle-composer").click();
const editor = passthroughEditor(testPage);
await expect(editor).toBeVisible({ timeout: 5_000 });
await editor.click();
await editor.pressSequentially("@Plan");
await expect(testPage.getByText("Mention tasks, files, prompts")).toBeVisible({
timeout: 5_000,
});
await testPage.getByRole("button", { name: /^Plan Include the plan as context$/ }).click();
await expect(editor).toContainText("Plan");
await testPage.getByTestId("submit-message-button").click();
await expect(passthroughComposer(testPage)).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText("CONTEXT PLAN", 15_000);
await session.expectPassthroughHasText(planContent, 15_000);
});
test("uploads attachments and delivers them as workspace file paths", async ({
testPage,
apiClient,
seedData,
}, testInfo) => {
test.setTimeout(90_000);
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Attachment");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Toolbar Attachment Task",
profileId,
{
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected passthrough task to start a session");
const kanban = new KanbanPage(testPage);
await kanban.goto();
const session = new SessionPage(testPage);
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Attachment Task");
await session.expectPassthroughHasText("Processed:", 20_000);
await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
fs.mkdirSync(testInfo.outputDir, { recursive: true });
const attachmentPath = path.join(testInfo.outputDir, "passthrough-upload.txt");
fs.writeFileSync(attachmentPath, "passthrough attachment body");
await testPage.getByTestId("passthrough-toggle-composer").click();
const composer = passthroughComposer(testPage);
await composer.locator('input[type="file"]').setInputFiles(attachmentPath);
await expect(composer.getByText("passthrough-upload.txt", { exact: true })).toBeVisible({
timeout: 10_000,
});
await passthroughEditor(testPage).fill("read the attached file");
await testPage.getByTestId("submit-message-button").click();
await expect(composer).toBeHidden({ timeout: 10_000 });
await session.expectPassthroughHasText("read the attached file", 15_000);
await session.expectPassthroughHasText("passthrough-upload.txt", 15_000);
const attachment = await waitForPersistedAttachment(
apiClient,
task.session_id,
"read the attached file",
);
expect(attachment).toMatchObject({
type: "resource",
name: "passthrough-upload.txt",
mime_type: "text/plain",
delivery_mode: "path",
});
});
test("slash stays in the terminal while the dedicated shortcut focuses the composer", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
const profileId = await createPassthroughProfile(apiClient, "CLI Toolbar Focus Shortcut");
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Toolbar Focus Shortcut Task",
profileId,
{
description: "initial prompt",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("expected passthrough task to start a session");
const kanban = new KanbanPage(testPage);
await kanban.goto();
const session = new SessionPage(testPage);
await openTaskAndWaitForTerminal(testPage, kanban, session, "Toolbar Focus Shortcut Task");
await session.expectPassthroughHasText("Processed:", 20_000);
await waitForPassthroughPromptable(apiClient, task.id, task.session_id);
await session.passthroughTerminal.locator(".xterm").click();
await testPage.keyboard.type("/terminal-slash-e2e");
await expect(passthroughComposer(testPage)).toHaveCount(0);
await session.expectPassthroughHasText("/terminal-slash-e2e", 5_000);
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+Shift+y`);
const composer = passthroughComposer(testPage);
await expect(composer).toBeVisible({ timeout: 5_000 });
await expect(passthroughEditor(testPage)).toBeFocused();
});
test("toolbar omits a Stop button; cancel is via Ctrl-C in the terminal", async ({
testPage,
apiClient,
@@ -77,13 +77,16 @@ test.describe("Docker executor — launch + reuse + recovery", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
const launchPromise = apiClient.launchSession({
task_id: task.id,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: profile.id,
workflow_step_id: seedData.startStepId,
prompt: "/e2e:simple-message",
});
const launchPromise = apiClient.launchSession(
{
task_id: task.id,
agent_profile_id: seedData.agentProfileId,
executor_profile_id: profile.id,
workflow_step_id: seedData.startStepId,
prompt: "/e2e:simple-message",
},
90_000,
);
const panel = testPage.getByTestId("prepare-progress-panel");
await expect(panel).toBeVisible({ timeout: 15_000 });
@@ -21,6 +21,8 @@ test.describe("Keyboard Shortcuts Settings", () => {
await expect(testPage.getByTestId("shortcut-recorder-COMMAND_PANEL")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-NEW_TASK")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-FOCUS_INPUT")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-FOCUS_PASSTHROUGH_INPUT")).toBeVisible();
await expect(testPage.getByText("Focus CLI Chat Input")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TOGGLE_PLAN_MODE")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER")).toBeVisible();
await expect(testPage.getByTestId("shortcut-recorder-TASK_SWITCHER_REVERSE")).toBeVisible();
+6 -3
View File
@@ -13,6 +13,8 @@ export type KeyboardShortcutOptions = {
preventDefault?: boolean;
/** Whether to stop event propagation (default: false) */
stopPropagation?: boolean;
/** Whether to listen during capture phase (default: false) */
capture?: boolean;
/** Target element (default: window) */
target?: HTMLElement | Window | null;
};
@@ -34,6 +36,7 @@ export function useKeyboardShortcut(
enabled = true,
preventDefault = true,
stopPropagation = false,
capture = false,
target = typeof window !== "undefined" ? window : null,
} = options;
@@ -61,12 +64,12 @@ export function useKeyboardShortcut(
useEffect(() => {
if (!enabled || !target) return;
target.addEventListener("keydown", handleKeyDown as EventListener);
target.addEventListener("keydown", handleKeyDown as EventListener, { capture });
return () => {
target.removeEventListener("keydown", handleKeyDown as EventListener);
target.removeEventListener("keydown", handleKeyDown as EventListener, { capture });
};
}, [enabled, target, handleKeyDown]);
}, [enabled, target, handleKeyDown, capture]);
}
/**
+4 -1
View File
@@ -78,7 +78,10 @@ export function buildTaskMentionsContext(tasks: TaskMentionData[], state: AppSta
);
}
function buildContextFilesContext(contextFiles: ContextFile[], prompts: CustomPrompt[]): string {
export function buildContextFilesContext(
contextFiles: ContextFile[],
prompts: CustomPrompt[],
): string {
const files = contextFiles.filter(
(f) => !f.path.startsWith("prompt:") && f.path !== "plan:context",
);
@@ -0,0 +1,51 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useSummarizeSession } from "./use-summarize-session";
const mockListMessages = vi.fn();
const mockExecuteUtilityPrompt = vi.fn();
vi.mock("@/lib/api/domains/session-api", () => ({
listTaskSessionMessages: (...args: unknown[]) => mockListMessages(...args),
}));
vi.mock("@/lib/api/domains/utility-api", () => ({
executeUtilityPrompt: (...args: unknown[]) => mockExecuteUtilityPrompt(...args),
}));
describe("useSummarizeSession", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the generated summary", async () => {
mockListMessages.mockResolvedValue({
messages: [{ type: "message", author_type: "user", content: "hello" }],
});
mockExecuteUtilityPrompt.mockResolvedValue({ success: true, response: "summary" });
const { result } = renderHook(() => useSummarizeSession());
let summary;
await act(async () => {
summary = await result.current.summarize("session-1");
});
expect(summary).toEqual({ summary: "summary" });
});
it("returns backend execution errors instead of swallowing them", async () => {
mockListMessages.mockResolvedValue({
messages: [{ type: "message", author_type: "user", content: "hello" }],
});
mockExecuteUtilityPrompt.mockRejectedValue(new Error("connection refused"));
const { result } = renderHook(() => useSummarizeSession());
let summary;
await act(async () => {
summary = await result.current.summarize("session-1");
});
expect(summary).toEqual({ summary: null, error: "connection refused" });
expect(result.current.isSummarizing).toBe(false);
});
});
+17 -6
View File
@@ -3,6 +3,11 @@ import { executeUtilityPrompt } from "@/lib/api/domains/utility-api";
import { listTaskSessionMessages } from "@/lib/api/domains/session-api";
import type { Message } from "@/lib/types/http";
export type SummarizeSessionResult = {
summary: string | null;
error?: string;
};
function formatTranscript(messages: Message[]): string {
return messages
.filter((m) => m.type === "message" || m.type === "content")
@@ -16,16 +21,16 @@ function formatTranscript(messages: Message[]): string {
export function useSummarizeSession() {
const [isSummarizing, setIsSummarizing] = useState(false);
const summarize = useCallback(async (sessionId: string): Promise<string | null> => {
const summarize = useCallback(async (sessionId: string): Promise<SummarizeSessionResult> => {
setIsSummarizing(true);
try {
// Fetch messages from API — they may not be in the store for non-active sessions
const resp = await listTaskSessionMessages(sessionId, { sort: "asc" });
const messages = resp.messages ?? [];
if (!messages.length) return null;
if (!messages.length) return { summary: null };
const transcript = formatTranscript(messages);
if (!transcript) return null;
if (!transcript) return { summary: null };
// Sessionless: handoff often runs against a completed session whose
// agentctl is gone. Host utility executes the builtin summarize agent.
@@ -33,9 +38,15 @@ export function useSummarizeSession() {
utility_agent_id: "builtin-summarize-session",
conversation_history: transcript,
});
return result.success ? (result.response ?? null) : null;
} catch {
return null;
if (!result.success) {
return { summary: null, error: result.error || "Summarize utility returned no result" };
}
return { summary: result.response ?? null };
} catch (error) {
return {
summary: null,
error: error instanceof Error ? error.message : "Could not generate a summary",
};
} finally {
setIsSummarizing(false);
}
+4
View File
@@ -121,6 +121,10 @@ export const SHORTCUTS = {
FOCUS_INPUT: {
key: KEYS.SLASH,
},
FOCUS_PASSTHROUGH_INPUT: {
key: KEYS.Y,
modifiers: { ctrlOrCmd: true, shift: true },
},
COMMAND_PANEL: {
key: KEYS.P,
modifiers: { ctrlOrCmd: true },
@@ -20,12 +20,13 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(ids).toContain("COMMAND_PANEL");
expect(ids).toContain("NEW_TASK");
expect(ids).toContain("FOCUS_INPUT");
expect(ids).toContain("FOCUS_PASSTHROUGH_INPUT");
expect(ids).toContain("TOGGLE_PLAN_MODE");
expect(ids).toContain("TASK_SWITCHER");
expect(ids).toContain("TASK_SWITCHER_REVERSE");
expect(ids).toContain("VOICE_INPUT_TOGGLE");
expect(ids).toContain("REVERSE_SEARCH");
expect(ids).toHaveLength(13);
expect(ids).toHaveLength(14);
});
it("each entry has a label and default matching SHORTCUTS", () => {
@@ -44,6 +45,11 @@ describe("CONFIGURABLE_SHORTCUTS", () => {
expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.label).toBe("Focus Chat Input");
expect(CONFIGURABLE_SHORTCUTS.FOCUS_INPUT.default).toBe(SHORTCUTS.FOCUS_INPUT);
expect(CONFIGURABLE_SHORTCUTS.FOCUS_PASSTHROUGH_INPUT.label).toBe("Focus CLI Chat Input");
expect(CONFIGURABLE_SHORTCUTS.FOCUS_PASSTHROUGH_INPUT.default).toBe(
SHORTCUTS.FOCUS_PASSTHROUGH_INPUT,
);
expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.label).toBe("Toggle Plan Mode");
expect(CONFIGURABLE_SHORTCUTS.TOGGLE_PLAN_MODE.default).toBe(SHORTCUTS.TOGGLE_PLAN_MODE);
@@ -9,6 +9,7 @@ export type ConfigurableShortcutId =
| "COMMAND_PANEL"
| "NEW_TASK"
| "FOCUS_INPUT"
| "FOCUS_PASSTHROUGH_INPUT"
| "TOGGLE_PLAN_MODE"
| "TASK_SWITCHER"
| "TASK_SWITCHER_REVERSE"
@@ -43,6 +44,10 @@ export const CONFIGURABLE_SHORTCUTS: Record<
COMMAND_PANEL: { label: "Command Panel (Alt)", default: SHORTCUTS.COMMAND_PANEL },
NEW_TASK: { label: "New Task", default: SHORTCUTS.NEW_TASK },
FOCUS_INPUT: { label: "Focus Chat Input", default: SHORTCUTS.FOCUS_INPUT },
FOCUS_PASSTHROUGH_INPUT: {
label: "Focus CLI Chat Input",
default: SHORTCUTS.FOCUS_PASSTHROUGH_INPUT,
},
TOGGLE_PLAN_MODE: { label: "Toggle Plan Mode", default: SHORTCUTS.TOGGLE_PLAN_MODE },
TASK_SWITCHER: { label: "Recent Task Switcher", default: SHORTCUTS.TASK_SWITCHER },
TASK_SWITCHER_REVERSE: {
+25 -15
View File
@@ -38,11 +38,11 @@ No per-agent pattern matchers. The existing idle window is the only readiness si
For the Claude case: `claude_acp.go` sets `AutoInjectPrompt: true`, `DisableBracketedPaste: true` (Claude Code already enables bracketed-paste *mode* in its Ink TUI — injecting `ESC[200~`…`ESC[201~` delimiters breaks the prompt), and `SubmitViaBackslashEnter: true` (PTY writes: prompt, then `\`, then `\r` per [Claude terminal docs](https://code.claude.com/docs/en/terminal-config)). Ink may still treat programmatic Enter as newline only ([anthropics/claude-code#15553](https://github.com/anthropics/claude-code/issues/15553)) — if auto-submit fails, the user confirms with Enter. Other passthrough-capable agents use bracketed-paste delimiters for multi-line prompts and stay default-off for auto-inject unless configured.
### Follow-up prompts via PTY stdin (backend route landed; UI surface deferred)
### Follow-up prompts via PTY stdin
The orchestrator's `PromptTask` handler branches on `IsPassthroughSession(sessionID)` and writes the prompt text + `SubmitSequence` to the agent's PTY stdin instead of sending over ACP. This is the same path used by auto-injection and is reachable today from any caller that hits `agent.prompt` for a passthrough session.
**UI gap (deferred):** kandev's chat compose box is rendered by `ChatInputArea`, which is replaced entirely by `PassthroughTerminal` when `session.is_passthrough` is true. There is no compose surface in passthrough mode today. Users send follow-ups by typing directly into the terminal (xterm forwards each keystroke to the PTY) — which works perfectly for the Claude CLI use case. A dedicated kandev compose box that drives the PTY (without replacing the terminal) is a follow-up.
`PassthroughToolbar` provides the UI surface for this route. Its chat control opens `PassthroughComposerPanel`, which wraps the shared `ChatInputContainer` so passthrough follow-ups use the same rich input, context chips, attachment control, and plan-mode toggle as ACP task chat. Submissions go through `message.add` for the current passthrough session and are written to PTY stdin by the backend.
### Kandev MCP tools are wired for CLI mode
@@ -50,11 +50,11 @@ When a passthrough agent supports an MCP config flag, kandev generates a per-ses
For the Claude case, the passthrough command includes `--mcp-config <generated-file>` with an `mcpServers.kandev` HTTP entry for the local agentctl `/mcp` endpoint.
### Stop sends Ctrl-C (backend route landed; UI surface deferred)
### Stop sends Ctrl-C
The orchestrator's `CancelAgent` handler branches on `IsPassthroughSession(sessionID)` and writes `\x03` to the PTY's stdin instead of sending an ACP cancel. DB reconciliation still runs so the UI unsticks regardless of the write outcome.
**UI gap (deferred):** Same root cause as the compose-box gap — `ChatInputArea` (which hosts the Stop button) is not rendered in passthrough mode. Users today press Ctrl-C directly in the xterm terminal, which travels through the same PTY stdin path. A dedicated Stop button overlay in `PassthroughTerminal` is a follow-up.
Users can still press Ctrl-C directly inside the xterm terminal. A dedicated toolbar button that calls the same cancel route remains a follow-up.
## Scenarios
@@ -108,7 +108,7 @@ The orchestrator's `CancelAgent` handler branches on `IsPassthroughSession(sessi
`PassthroughToolbar` (`apps/web/components/task/passthrough-toolbar.tsx`) wraps `PassthroughTerminal` with the same kandev controls that ACP sessions get from `ChatStatusBar` + `ChatInputArea`. It is mounted:
- Full-page task view: `dockview-shared.tsx` (`DockviewSharedContent`) renders it for passthrough sessions in place of the chat input area; the dockview desktop layout in `task-center-panel.tsx` uses the same shared content.
- Kanban Preview tab: `preview-session-tabs.tsx` renders `PassthroughComposer` directly (compose-only, no terminal wrap) for passthrough sessions in the side-peek surface.
- Kanban Preview tab: `preview-session-tabs.tsx` renders `PassthroughToolbar` for passthrough sessions in the side-peek surface, so the preview and full-page task views share the same terminal, status row, and composer behavior.
### What it surfaces
@@ -117,31 +117,42 @@ The status row at the bottom of `PassthroughToolbar` contains, left to right:
- `PRStatusChip` — PR open/merged/draft indicator for the task.
- `PRMergedBanner` — banner shown when the PR is merged.
- `passthrough-proceed-next-step` button — moves the task to the next workflow step. Only shown when `nextStepName` is non-null and the agent is not busy (`sessionState` is not `RUNNING` or `STARTING`).
- `ChatToggleButton` — toggles the `PassthroughComposer` open and closed. Shows `variant="default"` when the composer is open or when there are pending review comments; shows a numeric chip (`data-testid="passthrough-pending-count"`) when collapsed with pending comments.
- Stop button (`data-testid="passthrough-stop"`) — disabled when the session is not `RUNNING` or `STARTING`.
- `ChatToggleButton` — toggles `PassthroughComposerPanel` open and closed. Shows `variant="default"` when the composer is open or when there are pending review comments; shows a numeric chip (`data-testid="passthrough-pending-count"`) when collapsed with pending comments.
### Default focus contract
The terminal (`PassthroughTerminal`) renders above the status row and fills the remaining height. The compose box is collapsed by default so xterm retains keyboard focus for raw PTY interaction. The user opts in to the kandev composer explicitly via the Chat button.
Passthrough sessions have a dedicated configurable focus shortcut, `FOCUS_PASSTHROUGH_INPUT` (`Focus CLI Chat Input`, default `Cmd/Ctrl+Shift+Y`). It opens the composer and focuses the rich input when the user is not already typing in another input. The global ACP chat focus shortcut remains separate so pressing `/` while the terminal has focus continues to go to the PTY.
### Composer assistance
`PassthroughComposerPanel` delegates authoring to `ChatInputContainer` with passthrough-specific command behavior:
- Slash command suggestions are disabled (`hasAgentCommands={false}`) because passthrough sessions do not receive ACP-reported slash-command capabilities. A literal `/` typed in the composer remains text, and a `/` typed while the terminal has focus goes to the PTY.
- `@` file, prompt, plan, and task mentions use the shared rich-input lookup. Selected items render as the same context chips used by ACP chat.
- The top chip bar shows selected context items. The bottom toolbar provides plan mode, attachment upload, and context-item add controls.
- Uploaded attachments are sent through `message.add`; for passthrough sessions the backend materializes them under `.kandev/attachments/<session>/...` and appends their file paths to the PTY prompt.
- File and prompt context items are expanded into `<kandev-system>` context blocks before sending. Real filesystem-backed files are also sent as `context_files` metadata.
### How the composer formats and sends pending review comments
When `PassthroughComposer` submits:
When `PassthroughComposerPanel` submits:
1. `usePendingDiffCommentsByFile(sessionId)` provides comments grouped by file path.
2. Non-empty `pendingComments` are formatted via `formatReviewCommentsAsMarkdown` (produces `### Review Comments\n…`) and prepended to the typed text.
3. The combined string is sent via `client.request("message.add", { task_id, session_id, content }, 10_000)`.
4. `markCommentsSent(ids)` is called only on WS success — a failed send leaves both the composer open (typed text preserved for retry) and the comments pending.
5. A `PendingCommentsBanner` (`data-testid="passthrough-pending-comments-banner"`) renders inside the open composer when `pendingCount > 0`.
2. Non-empty pending diff comments are formatted via `formatReviewCommentsAsMarkdown` or the shared `buildSubmitMessage` path, then included with the typed text.
3. Selected context files, prompt mentions, and task mentions are appended as system context blocks. Real context files are included in `context_files`; uploaded attachments are included in `attachments`.
4. The combined payload is sent via `client.request("message.add", { task_id, session_id, content, attachments?, context_files? }, timeout)`.
5. `markCommentsSent(ids)` is called only on WS success — a failed send leaves both the composer open (typed text preserved for retry) and the comments pending.
6. A `PendingCommentsBanner` (`data-testid="passthrough-pending-comments-banner"`) renders inside the open composer when `pendingCount > 0`.
### How Stop maps to Ctrl-C
Clicking the Stop button calls `client.request("agent.cancel", { session_id }, 10_000)` over WS. The WS handler calls `Service.CancelAgent` → `agentManager.CancelAgentBySessionID` in `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go`, which branches on `execution.PassthroughProcessID != ""` and writes `\x03` to PTY stdin. DB reconciliation completes regardless of the write outcome so the UI unsticks.
Pressing Ctrl-C inside the passthrough terminal writes `\x03` to PTY stdin. A future Stop affordance in `PassthroughTerminal` can call `client.request("agent.cancel", { session_id }, 10_000)` over WS; that route reaches `Service.CancelAgent` -> `agentManager.CancelAgentBySessionID` in `apps/backend/internal/agent/runtime/lifecycle/manager_interaction.go`, which branches on `execution.PassthroughProcessID != ""` and writes `\x03` to PTY stdin. DB reconciliation completes regardless of the write outcome so the UI unsticks.
### Not included
- `TodoIndicator` — ACP-only (requires message stream); not rendered in `PassthroughToolbar`.
- Plan-comments and PR-feedback attachment — same pattern as diff comments if needed in future; deferred.
- Per-message model/mode picker — ACP-only feature; not applicable to PTY sessions.
## Out of scope
@@ -155,7 +166,6 @@ Clicking the Stop button calls `client.request("agent.cancel", { session_id }, 1
## Follow-ups
- **Compose box in passthrough mode.** Surface a dedicated kandev compose input (alongside the PTY terminal) that writes to PTY stdin via the now-wired backend route. Today users type into xterm directly, which works but is inconsistent with kandev's ACP UX.
- **Stop button overlay in `PassthroughTerminal`.** Small affordance that calls the existing cancel route. Today users press Ctrl-C inside xterm directly.
- **AutoInjectPrompt on other passthrough agents** (Codex CLI, OpenCode TUI, etc.) — only Claude is opted in for v1. Adding others requires verifying their submit sequence and idle behavior.