From f5c73db63c00bff88e8538ea1d3dce28af0b01a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 12 Sep 2026 16:30:15 +0100 Subject: [PATCH] feat: show task attachments during workspace preparation (#3594) --- .../initial_prompt_preview_test.go | 145 +++++++++++++ .../internal/orchestrator/session_launch.go | 11 +- .../internal/orchestrator/task_operations.go | 25 +++ .../task/handlers/task_http_handlers.go | 28 +-- ...task_http_handlers_initial_preview_test.go | 46 ++++ .../task/models/initial_prompt_preview.go | 28 +++ .../models/initial_prompt_preview_test.go | 25 +++ .../task/chat/messages/chat-message.tsx | 7 +- .../task/chat/use-chat-panel-state.ts | 1 + .../e2e/helpers/preparation-attachments.ts | 204 ++++++++++++++++++ .../e2e/tests/chat/mention-recency.spec.ts | 7 +- .../mobile-preparation-attachments.spec.ts | 22 ++ .../task/preparation-attachments.spec.ts | 24 +++ apps/web/hooks/initial-prompt-preview.ts | 58 +++++ ...processed-messages-initial-preview.test.ts | 140 ++++++++++++ apps/web/hooks/use-processed-messages.ts | 51 +++-- .../preparation-attachment-previews/plan.md | 136 ++++++++++++ .../task-01-initial-attachment-previews.md | 177 +++++++++++++++ docs/public/tasks-and-workflows.md | 2 + .../tasks/requirements/prompt-attachments.md | 17 +- .../tasks/system-design/prompt-attachments.md | 89 ++++++++ 21 files changed, 1203 insertions(+), 40 deletions(-) create mode 100644 apps/backend/internal/orchestrator/initial_prompt_preview_test.go create mode 100644 apps/backend/internal/task/handlers/task_http_handlers_initial_preview_test.go create mode 100644 apps/backend/internal/task/models/initial_prompt_preview.go create mode 100644 apps/backend/internal/task/models/initial_prompt_preview_test.go create mode 100644 apps/web/e2e/helpers/preparation-attachments.ts create mode 100644 apps/web/e2e/tests/task/mobile-preparation-attachments.spec.ts create mode 100644 apps/web/e2e/tests/task/preparation-attachments.spec.ts create mode 100644 apps/web/hooks/initial-prompt-preview.ts create mode 100644 apps/web/hooks/use-processed-messages-initial-preview.test.ts create mode 100644 docs/plans/preparation-attachment-previews/plan.md create mode 100644 docs/plans/preparation-attachment-previews/task-01-initial-attachment-previews.md diff --git a/apps/backend/internal/orchestrator/initial_prompt_preview_test.go b/apps/backend/internal/orchestrator/initial_prompt_preview_test.go new file mode 100644 index 0000000000..029ddc048d --- /dev/null +++ b/apps/backend/internal/orchestrator/initial_prompt_preview_test.go @@ -0,0 +1,145 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8 +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.9 +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.10 +func TestInitialPromptPreviewPersistsOnlyInPreparedSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskRepo := newMockTaskRepo() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"})) + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + eventBus := &mockEventBus{} + svc.eventBus = eventBus + preview := models.NewInitialPromptPreview("submitted text", []v1.MessageAttachment{ + {AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", SizeBytes: 32}, + }) + response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare, + DeferredStart: true, InitialPromptPreview: preview, + }) + require.NoError(t, err) + session, err := repo.GetTaskSession(ctx, response.SessionID) + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateCreated, session.State) + encoded, err := json.Marshal(session.Metadata[models.SessionMetaKeyInitialPromptPreview]) + require.NoError(t, err) + expected, err := json.Marshal(preview) + require.NoError(t, err) + require.JSONEq(t, string(expected), string(encoded)) + published := eventBus.published() + require.NotEmpty(t, published) + foundPreview := false + for _, event := range published { + data, ok := event.Event.Data.(map[string]interface{}) + if !ok || data["new_state"] != string(models.TaskSessionStateCreated) { + continue + } + metadata, ok := data["session_metadata"].(map[string]interface{}) + require.True(t, ok) + require.Equal(t, session.Metadata[models.SessionMetaKeyInitialPromptPreview], metadata[models.SessionMetaKeyInitialPromptPreview]) + foundPreview = true + } + require.True(t, foundPreview, "created-session publication must already contain the preview") + + // A later session on the same task must not inherit submission display data. + environment, err := repo.GetTaskEnvironment(ctx, session.TaskEnvironmentID) + require.NoError(t, err) + environment.Status = models.TaskEnvironmentStatusReady + environment.MaterializationSessionID = "" + require.NoError(t, repo.UpdateTaskEnvironment(ctx, environment)) + siblingID, err := svc.PrepareTaskSession(ctx, "task1", "profile1", "", "", "", false) + require.NoError(t, err) + sibling, err := repo.GetTaskSession(ctx, siblingID) + require.NoError(t, err) + require.NotEqual(t, session.ID, sibling.ID) + require.NotContains(t, sibling.Metadata, models.SessionMetaKeyInitialPromptPreview) + + require.NoError(t, repo.SetSessionMetadataKey(ctx, session.ID, "unrelated", "kept")) + session.State = models.TaskSessionStateFailed + // Fetch the current metadata before persisting the state, as production does. + failed, err := repo.GetTaskSession(ctx, session.ID) + require.NoError(t, err) + failed.State = session.State + require.NoError(t, repo.UpdateTaskSession(ctx, failed)) + reloaded, err := repo.GetTaskSession(ctx, session.ID) + require.NoError(t, err) + require.Equal(t, session.Metadata[models.SessionMetaKeyInitialPromptPreview], reloaded.Metadata[models.SessionMetaKeyInitialPromptPreview]) + require.Equal(t, "kept", reloaded.Metadata["unrelated"]) +} + +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8 +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.10 +func TestInitialPromptPreviewPersistsThroughPassthroughPrepareUpgrade(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskRepo := newMockTaskRepo() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"})) + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"} + manager := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, manager) + + preview := models.NewInitialPromptPreview("submitted text", []v1.MessageAttachment{ + {AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", SizeBytes: 32}, + }) + response, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare, + InitialPromptPreview: preview, + }) + require.NoError(t, err) + require.NotEmpty(t, response.SessionID) + session, err := repo.GetTaskSession(ctx, response.SessionID) + require.NoError(t, err) + encoded, err := json.Marshal(session.Metadata[models.SessionMetaKeyInitialPromptPreview]) + require.NoError(t, err) + expected, err := json.Marshal(preview) + require.NoError(t, err) + require.JSONEq(t, string(expected), string(encoded)) +} + +type initialPreviewFailureStore struct { + sessionExecutorStore + sessionID string +} + +func (s *initialPreviewFailureStore) SetSessionMetadataKey(ctx context.Context, sessionID, key string, value interface{}) error { + if key == models.SessionMetaKeyInitialPromptPreview { + s.sessionID = sessionID + return errors.New("preview storage unavailable") + } + return s.sessionExecutorStore.SetSessionMetadataKey(ctx, sessionID, key, value) +} + +func TestInitialPromptPreviewWriteFailureSettlesSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + taskRepo := newMockTaskRepo() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws1", Name: "Test"})) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"})) + taskRepo.tasks["task1"] = &v1.Task{ID: "task1", WorkspaceID: "ws1", Title: "Task"} + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + failing := &initialPreviewFailureStore{sessionExecutorStore: svc.repo} + svc.repo = failing + _, err := svc.LaunchSession(ctx, &LaunchSessionRequest{ + TaskID: "task1", AgentProfileID: "profile1", Intent: IntentPrepare, + DeferredStart: true, InitialPromptPreview: models.NewInitialPromptPreview("submitted text", nil), + }) + require.ErrorContains(t, err, "preview storage unavailable") + session, err := repo.GetTaskSession(ctx, failing.sessionID) + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateFailed, session.State) +} diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index 3ed29d8485..d80ca8bcd0 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -70,8 +70,10 @@ type LaunchSessionRequest struct { // server-side coordination flag set by the deferred-start handlers, so it is // kept off the wire protocol (`json:"-"`) — a client must not be able to // suppress the upgrade and strand a passthrough session without a PTY. - DeferredStart bool `json:"-"` - Attachments []v1.MessageAttachment `json:"attachments,omitempty"` + DeferredStart bool `json:"-"` + // InitialPromptPreview is supplied only by task creation after attachment claim. + InitialPromptPreview *models.InitialPromptPreview `json:"-"` + Attachments []v1.MessageAttachment `json:"attachments,omitempty"` // SpawnOrigin identifies the agent session that requested this launch via // spawn_session_kandev, so the new session's first turn can carry spawner // attribution and reply instructions. Like DeferredStart it is kept off the @@ -236,11 +238,12 @@ func (s *Service) claimLaunchAttachments(ctx context.Context, req *LaunchSession // the prompt — eagerly launching here would spawn a promptless PTY and the // later start would be rejected against the now-running session. func (s *Service) launchPrepare(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { + prepareCtx := withInitialPromptPreview(ctx, req.InitialPromptPreview) if s.shouldUpgradePassthroughPrepare(ctx, req) { - return s.launchStart(ctx, req) + return s.launchStart(prepareCtx, req) } sessionID, err := s.PrepareTaskSession( - ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, + prepareCtx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.ExecutorProfileID, req.WorkflowStepID, req.LaunchWorkspace, ) if err != nil { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 57d33fa209..b315160e3b 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -448,6 +448,26 @@ func (s *Service) PrepareTaskSession(ctx context.Context, taskID string, agentPr return sessionID, nil } +type initialPromptPreviewContextKey struct{} + +func withInitialPromptPreview(ctx context.Context, preview *models.InitialPromptPreview) context.Context { + if preview == nil { + return ctx + } + return context.WithValue(ctx, initialPromptPreviewContextKey{}, preview) +} + +func (s *Service) persistInitialPromptPreviewFromContext(ctx context.Context, taskID, sessionID string) error { + preview, ok := ctx.Value(initialPromptPreviewContextKey{}).(*models.InitialPromptPreview) + if !ok || preview == nil { + return nil + } + if err := s.repo.SetSessionMetadataKey(ctx, sessionID, models.SessionMetaKeyInitialPromptPreview, preview); err != nil { + return s.handleSessionLaunchFailure(ctx, taskID, sessionID, fmt.Errorf("persist initial prompt preview: %w", err)) + } + return nil +} + func isInheritParentWorkspace(task *v1.Task) bool { if task == nil { return false @@ -1775,6 +1795,11 @@ func (s *Service) prepareSessionForStartWithWorkflowRoute( } return "", false, err } + if created { + if err := s.persistInitialPromptPreviewFromContext(ctx, task.ID, sessionID); err != nil { + return "", false, err + } + } return sessionID, created, nil } diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 84fb3b0555..9572b79968 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1442,13 +1442,14 @@ func (h *TaskHandlers) prepareTaskSession( // upgraded to a full launch here so the terminal has a PTY to attach to. // (Contrast the start_agent branch below, which sets DeferredStart=true.) resp, err := h.orchestrator.LaunchSession(c.Request.Context(), &orchestrator.LaunchSessionRequest{ - TaskID: taskID, - Intent: orchestrator.IntentPrepare, - AgentProfileID: body.AgentProfileID, - ExecutorID: body.ExecutorID, - ExecutorProfileID: body.ExecutorProfileID, - WorkflowStepID: resolvedStepID, - LaunchWorkspace: true, + InitialPromptPreview: models.NewInitialPromptPreview(strings.TrimSpace(body.Description), body.Attachments), + TaskID: taskID, + Intent: orchestrator.IntentPrepare, + AgentProfileID: body.AgentProfileID, + ExecutorID: body.ExecutorID, + ExecutorProfileID: body.ExecutorProfileID, + WorkflowStepID: resolvedStepID, + LaunchWorkspace: true, }) if err != nil { h.logger.Error("failed to prepare session for task", zap.Error(err), zap.String("task_id", taskID)) @@ -1476,12 +1477,13 @@ func (h *TaskHandlers) prepareStartAgentSession( resolvedStepID string, ) *startAgentDispatch { prepResp, err := h.orchestrator.LaunchSession(ctx, &orchestrator.LaunchSessionRequest{ - TaskID: taskID, - Intent: orchestrator.IntentPrepare, - AgentProfileID: body.AgentProfileID, - ExecutorID: body.ExecutorID, - ExecutorProfileID: body.ExecutorProfileID, - WorkflowStepID: resolvedStepID, + InitialPromptPreview: models.NewInitialPromptPreview(strings.TrimSpace(body.Description), body.Attachments), + TaskID: taskID, + Intent: orchestrator.IntentPrepare, + AgentProfileID: body.AgentProfileID, + ExecutorID: body.ExecutorID, + ExecutorProfileID: body.ExecutorProfileID, + WorkflowStepID: resolvedStepID, // The async IntentStartCreated dispatch below carries the prompt. Mark // this as a deferred start so a passthrough profile is not eagerly // launched here with an empty prompt (which would pre-empt that diff --git a/apps/backend/internal/task/handlers/task_http_handlers_initial_preview_test.go b/apps/backend/internal/task/handlers/task_http_handlers_initial_preview_test.go new file mode 100644 index 0000000000..dbb40b17b8 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_http_handlers_initial_preview_test.go @@ -0,0 +1,46 @@ +package handlers + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +// @covers AC-TASKS-PROMPT-ATTACHMENTS-001.8 +func TestTaskCreateInitialPreviewReachesPreparation(t *testing.T) { + orch := &captureOrchestrator{prepErr: errors.New("stop before async dispatch")} + h := &TaskHandlers{orchestrator: orch, logger: newTestLogger(t)} + body := httpCreateTaskRequest{ + StartAgent: true, AgentProfileID: "profile-1", Description: "submitted text", + Attachments: []v1.MessageAttachment{{AttachmentID: "image-1", Type: "image", MimeType: "image/png", Name: "screen.png"}}, + } + h.prepareStartAgentSession(context.Background(), &createTaskResponse{}, "task-1", body, "step-1") + require.Len(t, orch.requests, 1) + preview := orch.requests[0].InitialPromptPreview + require.NotNil(t, preview) + require.Equal(t, body.Description, preview.Content) + require.Equal(t, body.Attachments, preview.Attachments) + require.Empty(t, orch.requests[0].Prompt, "display text must not dispatch an agent prompt") + require.Empty(t, orch.requests[0].Attachments) +} + +func TestTaskCreateInitialPreviewPrepareOnly(t *testing.T) { + orch := &captureOrchestrator{prepErr: errors.New("stop before workspace launch")} + h := &TaskHandlers{orchestrator: orch, logger: newTestLogger(t)} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/tasks", nil) + body := httpCreateTaskRequest{ + PrepareSession: true, AgentProfileID: "profile-1", Description: "submitted text", + Attachments: []v1.MessageAttachment{{AttachmentID: "file-1", Type: "resource", Name: "notes.txt", MimeType: "text/plain"}}, + } + h.prepareTaskSession(c, &createTaskResponse{}, "task-1", body, "step-1", true) + require.Len(t, orch.requests, 1) + require.Equal(t, body.Attachments, orch.requests[0].InitialPromptPreview.Attachments) + require.True(t, orch.requests[0].LaunchWorkspace) + require.False(t, orch.requests[0].DeferredStart) +} diff --git a/apps/backend/internal/task/models/initial_prompt_preview.go b/apps/backend/internal/task/models/initial_prompt_preview.go new file mode 100644 index 0000000000..1c7688de4f --- /dev/null +++ b/apps/backend/internal/task/models/initial_prompt_preview.go @@ -0,0 +1,28 @@ +package models + +import v1 "github.com/kandev/kandev/pkg/api/v1" + +const SessionMetaKeyInitialPromptPreview = "initial_prompt_preview" + +// InitialPromptPreview is display-only submission data, never an agent prompt. +type InitialPromptPreview struct { + Content string `json:"content"` + Attachments []v1.MessageAttachment `json:"attachments"` +} + +// NewInitialPromptPreview copies file-backed descriptors without inline bytes. +// Callers must complete attachment claim admission before preparing the session. +func NewInitialPromptPreview(content string, attachments []v1.MessageAttachment) *InitialPromptPreview { + preview := &InitialPromptPreview{Content: content, Attachments: []v1.MessageAttachment{}} + for _, attachment := range attachments { + if attachment.AttachmentID == "" { + continue + } + preview.Attachments = append(preview.Attachments, v1.MessageAttachment{ + AttachmentID: attachment.AttachmentID, Type: attachment.Type, + Name: attachment.Name, MimeType: attachment.MimeType, + SizeBytes: attachment.SizeBytes, DeliveryMode: attachment.DeliveryMode, + }) + } + return preview +} diff --git a/apps/backend/internal/task/models/initial_prompt_preview_test.go b/apps/backend/internal/task/models/initial_prompt_preview_test.go new file mode 100644 index 0000000000..fd46d0f33e --- /dev/null +++ b/apps/backend/internal/task/models/initial_prompt_preview_test.go @@ -0,0 +1,25 @@ +package models + +import ( + "encoding/json" + "testing" + + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/stretchr/testify/require" +) + +func TestInitialPromptPreviewOmitsInlineBytes(t *testing.T) { + attachments := []v1.MessageAttachment{ + {Type: "image", Data: "legacy-bytes"}, + {AttachmentID: "image-1", Type: "image", Name: "screen.png", MimeType: "image/png", Data: "private-bytes", SizeBytes: 32}, + } + preview := NewInitialPromptPreview("prompt", attachments) + require.Len(t, preview.Attachments, 1) + require.Empty(t, preview.Attachments[0].Data) + encoded, err := json.Marshal(preview) + require.NoError(t, err) + require.NotContains(t, string(encoded), "legacy-bytes") + require.NotContains(t, string(encoded), "private-bytes") + attachments[1].Name = "changed" + require.Equal(t, "screen.png", preview.Attachments[0].Name) +} diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 5de02c46c2..059e959146 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -5,6 +5,7 @@ import { IconWand, IconMessageDots, IconFile, IconFolder } from "@tabler/icons-r import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import type { Message } from "@/lib/types/http"; +import { TASK_DESCRIPTION_SYNTHETIC_ID } from "@/hooks/initial-prompt-preview"; import { MessageActions } from "@/components/task/chat/messages/message-actions"; import { useMessageFavorite } from "@/hooks/domains/session/use-message-favorite"; import { useUserMessageNavigation } from "@/hooks/use-message-navigation"; @@ -93,7 +94,7 @@ type UserMessageProps = { }; type UserMessageMetadata = WorkflowMessageMetadata & { - attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>; + attachments?: UserMessageAttachment[]; plan_mode?: boolean; has_review_comments?: boolean; has_hidden_prompts?: boolean; @@ -335,7 +336,9 @@ function UserMessageContent({ isRawView={showRaw} onToggleRaw={onToggleRaw} isFavorite={isFavorite} - onToggleFavorite={toggleFavorite} + onToggleFavorite={ + comment.id === TASK_DESCRIPTION_SYNTHETIC_ID ? undefined : toggleFavorite + } onNavigatePrev={() => { if (userNavigation.previousId && onScrollToMessage) onScrollToMessage(userNavigation.previousId); diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 4c884fc97d..e195b76b5f 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -464,6 +464,7 @@ function useSessionData( ); const lastAgentError = useMemo(() => readLastAgentError(session?.metadata), [session?.metadata]); const processed = useProcessedMessages(messages, taskId, resolvedSessionId, taskDescription, { + initialPromptPreview: session?.metadata?.initial_prompt_preview, historyInitialized, hasOlderMessages, lastAgentError, diff --git a/apps/web/e2e/helpers/preparation-attachments.ts b/apps/web/e2e/helpers/preparation-attachments.ts new file mode 100644 index 0000000000..3481e6cf27 --- /dev/null +++ b/apps/web/e2e/helpers/preparation-attachments.ts @@ -0,0 +1,204 @@ +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { expect, type Page, type TestInfo } from "@playwright/test"; +import type { BackendContext } from "../fixtures/backend"; +import type { SeedData } from "../fixtures/test-base"; +import type { ApiClient } from "./api-client"; +import { makeGitEnv } from "./git-helper"; +import { KanbanPage } from "../pages/kanban-page"; +import { MobileKanbanPage } from "../pages/mobile-kanban-page"; + +type Options = { + testPage: Page; + apiClient: ApiClient; + backend: BackendContext; + seedData: SeedData; + testInfo: TestInfo; + mobile?: boolean; + failPreparation?: boolean; +}; + +export async function assertPreparationAttachments(options: Options) { + const { + testPage: page, + apiClient, + backend, + seedData, + testInfo, + mobile, + failPreparation, + } = options; + const fixture = fs.mkdtempSync(path.join(backend.tmpDir, "attachment-preview-")); + const repoDir = path.join(fixture, "repo"); + const entered = path.join(fixture, "entered"); + const release = path.join(fixture, "release"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + const { settings } = await apiClient.getUserSettings(); + let taskId: string | undefined; + let repositoryId: string | undefined; + const cleanupErrors: unknown[] = []; + const runCleanup = async (cleanup: () => void | Promise) => { + try { + await cleanup(); + } catch (error) { + cleanupErrors.push(error); + } + }; + try { + execFileSync("git", ["clone", "--local", seedData.repositoryPath, repoDir], { + env: makeGitEnv(backend.tmpDir), + stdio: "ignore", + }); + const repository = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: "Preparation preview repository", + pull_before_worktree: false, + }); + repositoryId = repository.id; + await apiClient.updateRepository(repository.id, { + setup_script: `touch ${quote(entered)}\nwhile [ ! -f ${quote(release)} ]; do sleep 0.1; done\n${failPreparation ? "exit 1" : "true"}`, + }); + await apiClient.saveUserSettings({ + task_create_last_used: { + repository_id: repository.id, + branch: "main", + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + workflow_ids_by_workspace: { [seedData.workspaceId]: seedData.workflowId }, + }, + }); + if (mobile) { + const kanban = new MobileKanbanPage(page); + await kanban.goto(); + await kanban.mobileFab.tap(); + } else { + const kanban = new KanbanPage(page); + await kanban.goto(); + await kanban.createTaskButton.first().click(); + } + const dialog = page.getByTestId("create-task-dialog"); + await expect(dialog).toBeVisible(); + await dialog.getByTestId("task-title-input").fill("Preparation attachment previews"); + await dialog.getByTestId("task-description-input").fill("/e2e:simple-message"); + await expect(dialog.getByTestId("repo-chip-trigger").first()).toContainText( + "Preparation preview repository", + ); + const imageBytes = fs.readFileSync( + path.join(process.cwd(), "public/web-app-manifest-192x192.png"), + ); + await dialog.locator('input[type="file"]').setInputFiles([ + { name: "screen-one.png", mimeType: "image/png", buffer: imageBytes }, + { name: "screen-two.png", mimeType: "image/png", buffer: imageBytes }, + { name: "notes.txt", mimeType: "text/plain", buffer: Buffer.from("preview file contents") }, + ]); + const submit = dialog.getByTestId("submit-start-agent"); + await expect(submit).toBeEnabled(); + const responsePromise = page.waitForResponse( + (response) => + response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST", + ); + await submit.click(); + const response = await responsePromise; + expect(response.ok()).toBeTruthy(); + const created = (await response.json()) as { id: string; session_id: string }; + taskId = created.id; + expect(created.session_id).toBeTruthy(); + await expect.poll(() => fs.existsSync(entered), { timeout: 60_000 }).toBe(true); + await page.goto(`/t/${taskId}`); + const bubble = page.getByTestId("user-message-bubble"); + await expect(bubble).toHaveCount(1); + await expect(bubble).toContainText("/e2e:simple-message"); + await expect( + bubble.getByRole("button", { name: "Open Attachment 1", exact: true }), + ).toBeVisible(); + await expect( + bubble.getByRole("button", { name: "Open Attachment 2", exact: true }), + ).toBeVisible(); + for (const index of [1, 2]) { + const trigger = bubble.getByRole("button", { name: `Open Attachment ${index}`, exact: true }); + if (mobile) await trigger.tap(); + else await trigger.click(); + await expect(page.getByRole("dialog", { name: "Image preview" })).toBeVisible(); + await page.keyboard.press("Escape"); + } + const file = bubble.getByTestId("message-file-attachment"); + await expect(file).toContainText("notes.txt"); + await page.reload(); + await expect( + bubble.getByRole("button", { name: "Open Attachment 2", exact: true }), + ).toBeVisible(); + await expect(file).toContainText("notes.txt"); + const before = await apiClient.listSessionMessages(created.session_id); + expect(before.messages.filter((message) => message.author_type === "user")).toHaveLength(0); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + await page.screenshot({ path: testInfo.outputPath("preparation-attachments.png") }); + + // An unavailable image must leave the text and remaining attachments usable. + const firstSource = await bubble + .getByRole("button", { name: "Open Attachment 1", exact: true }) + .locator("img") + .getAttribute("src"); + expect(firstSource).toBeTruthy(); + await page.route(firstSource!, (route) => route.fulfill({ status: 404, body: "not found" })); + await page.reload(); + await expect(bubble).toContainText("/e2e:simple-message"); + await expect(file).toBeVisible(); + await page.unroute(firstSource!); + + fs.writeFileSync(release, "release"); + if (failPreparation) { + const panel = page.getByTestId("prepare-progress-panel"); + await expect(panel).toHaveAttribute("data-status", "completed_with_warnings", { + timeout: 60_000, + }); + await page.reload(); + await expect( + bubble.getByRole("button", { name: "Open Attachment 2", exact: true }), + ).toBeVisible(); + } else { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(created.session_id); + return messages.filter((message) => message.author_type === "user").length; + }, + { timeout: 60_000 }, + ) + .toBe(1); + await expect(bubble).toHaveCount(1); + await page.reload(); + await expect(bubble).toHaveCount(1); + await expect( + bubble.getByRole("button", { name: "Open Attachment 2", exact: true }), + ).toBeVisible(); + } + } finally { + await runCleanup(() => fs.writeFileSync(release, "release")); + await runCleanup(async () => { + if (!taskId) return; + const response = await apiClient.rawRequest( + "DELETE", + `/api/v1/tasks/${taskId}?discard_worktree_changes=true`, + ); + expect(response.ok).toBeTruthy(); + }); + await runCleanup(async () => { + if (!repositoryId) return; + const response = await apiClient.rawRequest("DELETE", `/api/v1/repositories/${repositoryId}`); + expect(response.ok).toBeTruthy(); + }); + await runCleanup(() => + apiClient.saveUserSettings({ + task_create_last_used: settings.task_create_last_used as Parameters< + ApiClient["saveUserSettings"] + >[0]["task_create_last_used"], + }), + ); + await runCleanup(() => fs.rmSync(fixture, { recursive: true, force: true })); + if (cleanupErrors.length > 0) throw cleanupErrors[0]; + } +} diff --git a/apps/web/e2e/tests/chat/mention-recency.spec.ts b/apps/web/e2e/tests/chat/mention-recency.spec.ts index d205dc667d..8127678276 100644 --- a/apps/web/e2e/tests/chat/mention-recency.spec.ts +++ b/apps/web/e2e/tests/chat/mention-recency.spec.ts @@ -33,7 +33,12 @@ function visibleEditor(scope: Locator): Locator { } async function typeMention(editor: Locator, query: string): Promise { - await editor.fill(""); + await expect(editor).toBeEditable(); + await editor.click(); + await editor.press("ControlOrMeta+A"); + await editor.press("Backspace"); + await expect.poll(() => editor.textContent()).toBe(""); + await editor.click(); await editor.pressSequentially(`@${query}`); const menu = editor.page().getByRole("listbox", { name: /Mention tasks, files, prompts/i }); await expect(menu).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/task/mobile-preparation-attachments.spec.ts b/apps/web/e2e/tests/task/mobile-preparation-attachments.spec.ts new file mode 100644 index 0000000000..d196ab4fe8 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-preparation-attachments.spec.ts @@ -0,0 +1,22 @@ +import { test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { assertPreparationAttachments } from "../../helpers/preparation-attachments"; + +useRegularMode(); + +test("mobile initial attachments survive preparation and reload", async ({ + testPage, + apiClient, + backend, + seedData, +}, testInfo) => { + test.setTimeout(180_000); + await assertPreparationAttachments({ + testPage, + apiClient, + backend, + seedData, + testInfo, + mobile: true, + }); +}); diff --git a/apps/web/e2e/tests/task/preparation-attachments.spec.ts b/apps/web/e2e/tests/task/preparation-attachments.spec.ts new file mode 100644 index 0000000000..25abbe9fe8 --- /dev/null +++ b/apps/web/e2e/tests/task/preparation-attachments.spec.ts @@ -0,0 +1,24 @@ +import { test } from "../../fixtures/test-base"; +import { useRegularMode } from "../../helpers/regular-mode"; +import { assertPreparationAttachments } from "../../helpers/preparation-attachments"; + +useRegularMode(); + +for (const failPreparation of [false, true]) { + test(`initial attachments survive preparation ${failPreparation ? "setup error" : "completion"}`, async ({ + testPage, + apiClient, + backend, + seedData, + }, testInfo) => { + test.setTimeout(180_000); + await assertPreparationAttachments({ + testPage, + apiClient, + backend, + seedData, + testInfo, + failPreparation, + }); + }); +} diff --git a/apps/web/hooks/initial-prompt-preview.ts b/apps/web/hooks/initial-prompt-preview.ts new file mode 100644 index 0000000000..2d20aa1563 --- /dev/null +++ b/apps/web/hooks/initial-prompt-preview.ts @@ -0,0 +1,58 @@ +export const TASK_DESCRIPTION_SYNTHETIC_ID = "task-description"; + +type PreviewAttachment = { + attachment_id: string; + type: "image" | "resource"; + mime_type: string; + name: string; + size_bytes?: number; + delivery_mode?: "prompt" | "path"; +}; + +export type InitialPromptPreview = { + content: string; + attachments: PreviewAttachment[]; +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function readAttachment(value: unknown): PreviewAttachment | null { + if ( + !isRecord(value) || + typeof value.attachment_id !== "string" || + !value.attachment_id.trim() || + !["image", "resource"].includes(value.type as string) || + typeof value.mime_type !== "string" || + typeof value.name !== "string" + ) { + return null; + } + return { + attachment_id: value.attachment_id, + type: value.type as PreviewAttachment["type"], + mime_type: value.mime_type, + name: value.name, + ...(typeof value.size_bytes === "number" && + Number.isFinite(value.size_bytes) && + value.size_bytes > 0 + ? { size_bytes: value.size_bytes } + : {}), + ...(value.delivery_mode === "prompt" || value.delivery_mode === "path" + ? { delivery_mode: value.delivery_mode } + : {}), + }; +} + +export function readInitialPromptPreview(value: unknown): InitialPromptPreview | null { + if (!isRecord(value)) return null; + const content = typeof value.content === "string" ? value.content : ""; + const attachments = Array.isArray(value.attachments) + ? value.attachments + .slice(0, 10) + .map(readAttachment) + .filter((item) => item !== null) + : []; + return content.trim() || attachments.length ? { content, attachments } : null; +} diff --git a/apps/web/hooks/use-processed-messages-initial-preview.test.ts b/apps/web/hooks/use-processed-messages-initial-preview.test.ts new file mode 100644 index 0000000000..fe67b1f019 --- /dev/null +++ b/apps/web/hooks/use-processed-messages-initial-preview.test.ts @@ -0,0 +1,140 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useProcessedMessages } from "./use-processed-messages"; +import { + sessionId as toSessionId, + taskId as toTaskId, + type Message, + type MessageType, +} from "@/lib/types/http"; + +const TASK_DESCRIPTION = "task description"; +const SUBMITTED_TEXT = "submitted text"; + +function makeMessage(id: string, authorType: "agent" | "user", content: string): Message { + return { + id, + session_id: toSessionId("s1"), + task_id: toTaskId("t1"), + author_type: authorType, + content, + type: "message" satisfies MessageType, + created_at: "", + }; +} + +describe("initial prompt preview rendering", () => { + it("shows submitted attachments before the stored initial prompt arrives", () => { + const attachments = [ + { + attachment_id: "image-1", + type: "image", + mime_type: "image/png", + name: "screen.png", + size_bytes: 32, + }, + { + attachment_id: "file-1", + type: "resource", + mime_type: "text/plain", + name: "notes.txt", + size_bytes: 12, + }, + ]; + const { result } = renderHook(() => + useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, { + historyInitialized: true, + hasOlderMessages: false, + initialPromptPreview: { content: SUBMITTED_TEXT, attachments }, + }), + ); + expect(result.current.allMessages[0]).toMatchObject({ + content: SUBMITTED_TEXT, + metadata: { attachments }, + }); + }); + + it("hydrates an attachment-only preview and replaces it with stored history", () => { + const attachment = { + attachment_id: "image-1", + type: "image", + mime_type: "image/png", + name: "screen.png", + }; + const { result, rerender } = renderHook( + ({ + preview, + messages, + sessionId, + }: { + preview: unknown; + messages: Message[]; + sessionId: string; + }) => + useProcessedMessages(messages, "t1", sessionId, "", { + historyInitialized: true, + hasOlderMessages: false, + initialPromptPreview: preview, + }), + { + initialProps: { preview: undefined as unknown, messages: [] as Message[], sessionId: "s1" }, + }, + ); + expect(result.current.allMessages).toEqual([]); + const preview = { content: "", attachments: [attachment] }; + rerender({ preview, messages: [], sessionId: "s1" }); + expect(result.current.allMessages[0]).toMatchObject({ + content: "", + metadata: { attachments: [attachment] }, + }); + const stored = { + ...makeMessage("stored", "user", ""), + metadata: { attachments: [attachment] }, + }; + rerender({ preview, messages: [stored], sessionId: "s1" }); + expect(result.current.allMessages).toEqual([stored]); + rerender({ preview: undefined, messages: [], sessionId: "s2" }); + expect(result.current.allMessages).toEqual([]); + }); +}); + +describe("initial prompt preview guards", () => { + it.each([ + { historyInitialized: false, hasOlderMessages: false }, + { historyInitialized: true, hasOlderMessages: true }, + ])("does not bypass history guards with preview metadata: %j", (history) => { + const { result } = renderHook(() => + useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, { + ...history, + initialPromptPreview: { content: SUBMITTED_TEXT, attachments: [] }, + }), + ); + expect(result.current.allMessages).toEqual([]); + }); + + it("discards malformed preview entries independently and strips non-display data", () => { + const attachment = { + attachment_id: "image-1", + type: "image", + mime_type: "image/png", + name: "screen.png", + }; + const { result } = renderHook(() => + useProcessedMessages([], "t1", "s1", TASK_DESCRIPTION, { + historyInitialized: true, + hasOlderMessages: false, + initialPromptPreview: { + content: SUBMITTED_TEXT, + attachments: [ + null, + "invalid", + {}, + { ...attachment, data: "private", storage_key: "private/path" }, + { ...attachment, type: "audio" }, + ], + }, + }), + ); + expect(result.current.allMessages[0].metadata).toEqual({ attachments: [attachment] }); + }); +}); diff --git a/apps/web/hooks/use-processed-messages.ts b/apps/web/hooks/use-processed-messages.ts index 70540698fb..a82e05aef8 100644 --- a/apps/web/hooks/use-processed-messages.ts +++ b/apps/web/hooks/use-processed-messages.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { readInitialPromptPreview, TASK_DESCRIPTION_SYNTHETIC_ID } from "./initial-prompt-preview"; import { sessionId as toSessionId, taskId as toTaskId, @@ -144,6 +145,7 @@ export type GroupedRenderOptions = { }; export type ProcessedMessagesOptions = { + initialPromptPreview?: unknown; historyInitialized?: boolean; hasOlderMessages?: boolean; lastAgentError?: LastAgentError | null; @@ -481,30 +483,38 @@ export function shouldShowTaskDescriptionFallback( taskDescription: string | null, visibleMessages: Message[], options: Pick = {}, + hasInitialPreview = false, ): boolean { return ( - Boolean(taskDescription) && + (Boolean(taskDescription) || hasInitialPreview) && options.historyInitialized === true && options.hasOlderMessages === false && !visibleMessages.some((message) => message.author_type === "user") ); } -export const TASK_DESCRIPTION_SYNTHETIC_ID = "task-description"; +export { TASK_DESCRIPTION_SYNTHETIC_ID } from "./initial-prompt-preview"; function buildTaskDescriptionMessage( - showFallback: boolean, + visibleMessages: Message[], taskDescription: string | null, taskId: string | null, resolvedSessionId: string | null, + options: ProcessedMessagesOptions, ): Message | null { - if (!showFallback) return null; + const preview = readInitialPromptPreview(options.initialPromptPreview); + if ( + !shouldShowTaskDescriptionFallback(taskDescription, visibleMessages, options, preview !== null) + ) { + return null; + } return { id: TASK_DESCRIPTION_SYNTHETIC_ID, task_id: toTaskId(taskId ?? ""), session_id: toSessionId(resolvedSessionId ?? ""), author_type: "user", - content: taskDescription ?? "", + content: preview?.content ?? taskDescription ?? "", + ...(preview ? { metadata: { attachments: preview.attachments } } : {}), type: "message", created_at: "", }; @@ -537,20 +547,23 @@ export function useProcessedMessages( const visibleMessages = useVisibleMessages(messages, toolCallIds, subagentChildIds, scope); - const showTaskDescriptionFallback = useMemo( - () => shouldShowTaskDescriptionFallback(taskDescription, visibleMessages, options), - [taskDescription, visibleMessages, options.historyInitialized, options.hasOlderMessages], - ); - const taskDescriptionMessage: Message | null = useMemo( - () => - buildTaskDescriptionMessage( - showTaskDescriptionFallback, - taskDescription, - taskId, - resolvedSessionId, - ), - [showTaskDescriptionFallback, taskDescription, taskId, resolvedSessionId], - ); + const taskDescriptionMessage = useMemo(() => { + return buildTaskDescriptionMessage( + visibleMessages, + taskDescription, + taskId, + resolvedSessionId, + options, + ); + }, [ + taskId, + resolvedSessionId, + options.initialPromptPreview, + taskDescription, + visibleMessages, + options.historyInitialized, + options.hasOlderMessages, + ]); const allMessages = useMemo(() => { return taskDescriptionMessage ? [taskDescriptionMessage, ...visibleMessages] : visibleMessages; diff --git a/docs/plans/preparation-attachment-previews/plan.md b/docs/plans/preparation-attachment-previews/plan.md new file mode 100644 index 0000000000..fd93720497 --- /dev/null +++ b/docs/plans/preparation-attachment-previews/plan.md @@ -0,0 +1,136 @@ +--- +created: 2026-09-10 +status: done +requirements: + - REQ-TASKS-PROMPT-ATTACHMENTS-001 +system_design: + - ../../specs/tasks/system-design/prompt-attachments.md +legacy_specs: [] +--- + +# Implementation Plan: Preparation attachment previews + +## Overview + +Show submitted screenshots and file labels while a new task prepares its workspace. +One sequential work order carries a session preview from task creation to chat. +The task system owns this change because it owns submitted attachments and session identity. + +## Evidence and root cause + +The reported task `7a8da962-d5ba-4b6c-b9ee-92691f21e3b4` has two PNG descriptors +in its first stored user message. The supplied screenshot shows its text-only +row above worktree preparation. A read-only source trace confirms: + +- `prepareStartAgentSession` creates the session without passing attachment display data. +- `postLaunchCreated` / `postLaunchStart` call `recordInitialMessage` after launch. +- `buildTaskDescriptionMessage` creates a synthetic row without attachment metadata. +- `chat-message.tsx` renders images and file labels from that missing metadata. + +Reproduce with a new task containing text and two images while workspace setup +is held pending. The fallback has text, but no attachments. No live instance +was mutated, and no runtime reproduction or permanent test has been run yet. + +## Scope + +In scope: file-backed task-create attachments during session preparation, +prepare-only creation, reload, failure, stored-message replacement, and phone parity. + +Out of scope: upload limits, new delivery modes, queue changes, additional-session +prompt redesign, legacy inline preview backfill, and changing when the backend +records a delivered initial message. A queued task without a session is outside +this workspace-preparation view. + +## Technical approach + +Follow the existing [attachment design](../../specs/tasks/system-design/prompt-attachments.md#initial-preview-during-workspace-preparation). +Extend the internal preparation options in `session_launch.go` and +`task_operations.go`; forward display data from both task-create prepare branches +in `task_http_handlers.go`. Persist bounded `initial_prompt_preview` metadata +after attachment admission and before session publication/workspace work. + +Read the resolved session through `useSessionState` / `useSessionData`, then +normalize its preview in `useProcessedMessages`. Render with the existing +attachment components. Preserve all current history-exhaustion guards and +stored-message precedence. Keep snapshot data out of agent dispatch and turn accounting. + +## ASCII UI preview + +UI-01: Task Chat during workspace preparation, shared desktop/phone composition. + +```text +Before After ++-------------------------+ +-------------------------+ +| Submitted text | | [image 1] [image 2] | ++-------------------------+ | [notes.txt] | +Preparing environment... | Submitted text | + Create worktree +-------------------------+ + Preparing environment... + Create worktree +``` + +The user row stays above progress. Phone attachments wrap within the existing +single-column Chat view; Chat owns vertical scrolling. Image taps open the +existing viewer. Files retain their existing compact labels. Grouping and +order are required; spacing is illustrative. Reuse localized controls. + +Attachment-only: omit the text area and keep the attachment row. No attachments: +keep the current text row. Failure: keep the preview above the existing error. +After launch: show only the stored user message with its attachments. +These states cover AC-TASKS-PROMPT-ATTACHMENTS-001.8 through .11. + +## Tests + +- `.8`, `.9`: new `task_http_handlers_initial_preview_test.go` and + `initial_prompt_preview_test.go` verify the snapshot exists before background + preparation, survives a fresh session read, and cannot start an agent itself. +- `.8`, `.10`: `use-processed-messages-fallback.test.ts` covers descriptor-only + images/files, empty text, late metadata, stored-row replacement, unknown + history, older history, malformed entries, and session switches. +- `.11`: rendered checks use existing image/file controls and verify unavailable + content does not remove other content. +- Preserve claim-denial and initial-message dedup tests; a preview is not a turn. + +## E2E tests + +Add `e2e/tests/task/preparation-attachments.spec.ts` (`chromium`) and +`e2e/tests/task/mobile-preparation-attachments.spec.ts` (`mobile-chrome`). +Use shared fixtures and a controlled preparation barrier, not a fixed sleep. +On a disposable repository, a setup script can wait for a test-owned release +file; always release the barrier and remove owned fixture data in `finally`. +Verify the selected executor actually runs the barrier before asserting UI. + +Create a task with two uploaded images and a resource file through the UI. +While preparation is pending, open an image, close it, inspect the file label, +reload, and verify the same attachments. Release preparation and assert exactly +one stored initial message. Add preparation-failure and inaccessible-content +cases. Phone checks use taps and assert zero document horizontal overflow. +These scenarios cover `.8` through `.11`; capture the preparation view for comparison to UI-01. + +## Work orders + +- [x] [Task 01: Show initial attachment previews](task-01-initial-attachment-previews.md) + +## Verification results + +- `python3 scripts/lint-spec-files.py --all`: passed. +- `python3 scripts/lint-spec-files.test.py`: passed, 36 tests. +- `git diff --check`: passed. +- Worktree status confirms the requirement/design edits and new plan/work order. +- Implementation, runtime reproduction, and browser checks: not run in this design turn. + +Implementation verification: targeted backend suites passed; hook tests passed +(12); TypeScript, native builds, and the E2E web build passed. Managed desktop +tests passed (2), and managed phone tests passed (1), with retries disabled. +Both captured views show the images and file label during preparation. See the +work order for commands, fixture corrections, and detailed results. + +## Risks + +- Preview data could accidentally dispatch twice if reused as launch input. +- Session reuse or stale hydration could show another session's attachments. +- Relaxing fallback history guards could invent an initial message in old history. +- Metadata persistence must preserve unrelated keys and precede background work. + +The public task attachment guide now explains preparation previews and distinguishes +them from successful agent delivery. See the work order for implementation results. diff --git a/docs/plans/preparation-attachment-previews/task-01-initial-attachment-previews.md b/docs/plans/preparation-attachment-previews/task-01-initial-attachment-previews.md new file mode 100644 index 0000000000..7d28a06570 --- /dev/null +++ b/docs/plans/preparation-attachment-previews/task-01-initial-attachment-previews.md @@ -0,0 +1,177 @@ +--- +id: "01-initial-attachment-previews" +title: "Show initial attachment previews" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-PROMPT-ATTACHMENTS-001 +acceptance_criteria: + - AC-TASKS-PROMPT-ATTACHMENTS-001.8 + - AC-TASKS-PROMPT-ATTACHMENTS-001.9 + - AC-TASKS-PROMPT-ATTACHMENTS-001.10 + - AC-TASKS-PROMPT-ATTACHMENTS-001.11 +system_design: + - ../../specs/tasks/system-design/prompt-attachments.md +--- + +# Task 01: Show initial attachment previews + +## Summary + +Carry a validated session preview from task creation to the preparation transcript. +Reuse the existing attachment controls and replace the preview when stored history arrives. + +## In scope + +- Internal preparation option and persisted session preview before publication. +- Session metadata normalization, synthetic message composition, and attachment types. +- Focused backend, hook, desktop, and phone regression coverage from the plan. + +## Out of scope + +Agent delivery changes, new storage tables or APIs, queues, and legacy inline preview backfill. + +## Acceptance + +1. Backend preparation publishes the submitted preview before workspace work, + preserves it on fresh reads and failure, and preserves claim/session isolation. +2. Chat displays it with existing controls, handles empty text and malformed + metadata, and replaces it without duplicates under all existing history guards. +3. Desktop and phone E2E prove opening attachments during preparation, reload, + success replacement, and failure behavior without overflowing the phone viewport. + +## ASCII UI preview + +UI-01: Task Chat during workspace preparation. See the [full state preview](plan.md#ascii-ui-preview). + +```text ++--------------------------+ +| [image 1] [image 2] | +| [notes.txt] | +| Submitted text | ++--------------------------+ +Preparing environment... + Create worktree +``` + +Shared phone/desktop ordering; phone content wraps in the existing Chat scroll +region. Image tap opens the existing viewer. Failure retains this row above +the existing error; stored history replaces it. Covers `.8` through `.11`. + +## Verification + +Run from the repository root. Bootstrap dependencies once if this worktree lacks them. +Write and run the backend/hook regression before production edits. The backend +test must fail because preparation has no snapshot; the hook test must fail +because the initial row has no attachment metadata, not because a selector is missing. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/backend && go test ./internal/task/handlers -run 'Test.*InitialPreview' -count=1) +(cd apps/backend && go test ./internal/task/models -run 'TestInitialPromptPreview' -count=1) +(cd apps/backend && go test ./internal/orchestrator -run 'Test.*(InitialPromptPreview|LaunchSession|InitialPrompt)' -count=1) +(cd apps/web && pnpm exec vitest run hooks/use-processed-messages-fallback.test.ts) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm e2e:run --project chromium tests/task/preparation-attachments.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-preparation-attachments.spec.ts) +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +Run changed-file ESLint and Go formatting checks. Include any additional test +files changed by implementation in this command list before marking complete. +Managed E2E builds current assets; do not reuse a stale build. Check discovered +test counts and compare the captured phone view with UI-01. + +## Files likely touched + +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers_initial_preview_test.go` (new) +- `apps/backend/internal/orchestrator/session_launch.go` +- `apps/backend/internal/orchestrator/task_operations.go` +- `apps/backend/internal/orchestrator/initial_prompt_preview_test.go` (new) +- `apps/backend/internal/task/models/initial_prompt_preview.go` (new typed metadata helper if needed) +- `apps/web/components/task/chat/use-chat-panel-state.ts` +- `apps/web/hooks/use-processed-messages.ts` +- `apps/web/hooks/use-processed-messages-fallback.test.ts` +- `apps/web/components/task/chat/messages/chat-message.tsx` +- `apps/web/e2e/tests/task/preparation-attachments.spec.ts` (new) +- `apps/web/e2e/tests/task/mobile-preparation-attachments.spec.ts` (new) +- A shared preparation fixture helper under `apps/web/e2e/helpers/`. + +## Dependencies + +None. Use the existing attachment claim service, session metadata persistence, +history initialization state, and attachment renderer. + +## Risks + +Do not pass display snapshots as a second launch prompt. Do not attach task-wide +files to sibling sessions or change fallback history eligibility. Preserve +passthrough prepare upgrades and unrelated session metadata. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/prompt-attachments.md), `.8` through `.11`. +- [Design](../../specs/tasks/system-design/prompt-attachments.md#initial-preview-during-workspace-preparation). +- Existing `use-processed-messages-fallback.test.ts` and `mixed-attachment-layout.ts` patterns. +- Root and scoped AGENTS.md, `/tdd`, `/e2e`, and `/mobile-parity`. + +## Results + +Implementation adds a display-only session snapshot before preparation publication. +The frontend validates descriptors and reuses the existing image controls and file +labels. History guards, session isolation, and stored-message precedence remain intact. +Synthetic rows cannot be favorited. Public task documentation explains the preview. + +- Behavioral RED: the hook omitted attachment metadata; the task-create handler + omitted the preview from its preparation request. Both regressions passed after wiring. +- Hook regression suite: 12 tests passed, including late hydration, attachment-only + prompts, malformed descriptors, history guards, and stored-message replacement. +- TypeScript checking passed. Changed-file ESLint reported no errors. +- Model descriptor test passed. Public docs tests passed (62 tests); validation + passed for 46 pages. Specification lint and whitespace checks passed. +- Desktop and phone E2E scenarios were added. The initial managed browser run + stopped during backend build because the shared disk filled; no browser assertion ran. +- Backend verification retries encountered disappearing shared cache and temporary + build files. A retry uses a private cache and build directory. + +Final targeted backend checks passed for models, handlers, and orchestrator. +The orchestrator command includes existing initial-prompt and launch-session tests. +The preview-write failure test first demonstrated a stranded CREATED session, then +passed with the existing launch-failure handler. The sibling-session fixture now +sets its workspace ready before creating the sibling, as the product requires. +The final hook run passed all 12 tests with `--silent`, after a console-forwarding +teardown failure on a prior run. TypeScript and changed-hook lint passed again. + +Final browser verification passed with fresh host-only artifacts. Build commands: + +```bash +(cd apps/backend && make build-agentctl build-kandev build-mock-agent e2e-plugin-package GOFLAGS=-p=1) +(cd apps/web && pnpm build:e2e) +(cd apps/web && pnpm e2e:run --host --no-build --project chromium tests/task/preparation-attachments.spec.ts --retries=0) +(cd apps/web && pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-preparation-attachments.spec.ts --retries=0) +``` + +- Desktop: 2 passed. Phone: 1 passed. Retries were disabled; cleanup also passed. +- Screenshots were inspected against UI-01. Both layouts retain the image controls, + file label, and prompt. The phone view has no document horizontal overflow. +- Browser coverage uses a controlled setup barrier and tests image opening, + reload, unavailable image content, and replacement by one saved user message. + The second desktop scenario checks non-fatal setup warnings. Terminal failure + persistence is covered at the backend boundary. +- Initial browser failures were fixture issues: cleanup needed explicit consent + to discard attachment-staging files; fetch responses expose `ok` as a property; + setup-script failures produce `completed_with_warnings`, not a terminal error. +- The hook and handler provided behavioral RED evidence before implementation. + Browser checks were added after the minimal fix to verify the rendered data path. +- Final changed-file ESLint: no errors; two test-file size/duplication warnings. + Go formatting and whitespace checks passed. + +No production agent-delivery behavior or remote executor build was changed. diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index ecc72f84b3..424a119655 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -302,6 +302,8 @@ Folders are live host paths and are available only to **Local/Local PC** and **W The task prompt supports image, audio, and resource attachments. Kandev accepts at most 10 files per submission, with a 100 MiB raw limit per file and a 100 MiB raw aggregate limit. Files are uploaded over authenticated HTTP before the task or message is submitted, so the task-create JSON and WebSocket frames carry attachment descriptors rather than base64 file contents. An upload that is still in progress or has failed must finish or be retried before the prompt can be sent. Removing a staged attachment discards its private upload; unclaimed uploads expire automatically after 24 hours. This prompt-attachment limit does not change the separate 10 MB task-document upload contract. +During workspace preparation, the initial message shows your uploaded screenshots and file labels. You can open image previews before the agent starts. The preview survives a page reload and remains visible if preparation fails. It does not indicate successful delivery to the agent. + Creating a fresh local branch is available only with the local executor. If the checkout is dirty, Kandev lists the affected paths and requires explicit consent before discarding those local changes. If another path becomes dirty after the warning, creation fails with a conflict and asks for consent again. Save or commit work before approving this operation. diff --git a/docs/specs/tasks/requirements/prompt-attachments.md b/docs/specs/tasks/requirements/prompt-attachments.md index c15b0f8b4f..6dcfdb354a 100644 --- a/docs/specs/tasks/requirements/prompt-attachments.md +++ b/docs/specs/tasks/requirements/prompt-attachments.md @@ -2,7 +2,7 @@ status: draft system: tasks created: 2026-08-04 -updated: 2026-09-02 +updated: 2026-09-10 owners: - Kandev team --- @@ -41,6 +41,21 @@ This document is the migrated task-system source for the capability. The source message delivered into a generating turn, the system shall surface a durable error for that message and shall not deliver an attachment reference whose bytes are absent from the session. +- **AC-TASKS-PROMPT-ATTACHMENTS-001.8:** While a newly created task session + prepares its workspace, its initial message preview shall show the submitted + text, image previews, and file labels before the agent starts. An attachment-only + submission shall show its attachments without an empty text bubble. +- **AC-TASKS-PROMPT-ATTACHMENTS-001.9:** Reloading that session during + preparation, or after preparation fails, shall preserve its initial preview. + The preview shall not imply successful agent delivery or hide preparation errors. +- **AC-TASKS-PROMPT-ATTACHMENTS-001.10:** When a stored user message becomes + visible, the transcript shall replace the initial preview without duplicate + messages or attachments. A different session shall not inherit that preview. + Unloaded older history shall not cause the preview to reappear. +- **AC-TASKS-PROMPT-ATTACHMENTS-001.11:** Desktop and phone users shall be + able to open preparation image previews and inspect file labels through the + existing attachment controls. Preview failures shall not hide prompt text + or other attachments, and the phone transcript shall remain within the viewport. ## Migrated source detail diff --git a/docs/specs/tasks/system-design/prompt-attachments.md b/docs/specs/tasks/system-design/prompt-attachments.md index a05050b247..25dea4bf76 100644 --- a/docs/specs/tasks/system-design/prompt-attachments.md +++ b/docs/specs/tasks/system-design/prompt-attachments.md @@ -4,6 +4,7 @@ system: tasks requirements: - REQ-TASKS-PROMPT-ATTACHMENTS-001 created: 2026-09-01 +updated: 2026-09-10 owners: - Kandev team --- @@ -111,6 +112,94 @@ backend derives those values from authenticated and persisted state. Task-scoped idempotency applies only when the stored task matches. Session scoping remains strict when the stored claim contains a session identity. +## Initial preview during workspace preparation + +This proposed extension covers AC-TASKS-PROMPT-ATTACHMENTS-001.8 through +AC-TASKS-PROMPT-ATTACHMENTS-001.11. The task system owns the submitted +attachment set and its session binding. Transcript history still follows the +[UI history design](../../ui/system-design/task-prompt-transcript-visibility.md). + +The task-create handlers currently prepare a session synchronously, then start +it asynchronously. `prepareStartAgentSession` does not forward the submitted +text or attachments. `postLaunchCreated` and `postLaunchStart` record the +initial user message after launch. Until then, `useProcessedMessages` builds a +text-only task-description fallback. + +Pass an internal, display-only initial-preview value through the task-create +prepare path, including prepare-only creation. Persist it in the target +session's metadata as `initial_prompt_preview` before publishing the created +session or starting workspace preparation. Use an internal preparation option; +do not make preview data a second agent-dispatch input or change passthrough +prepare upgrades. The value contains `content` and `attachments`, using the +existing display descriptor fields: `attachment_id`, `type`, `name`, +`mime_type`, `size_bytes`, and `delivery_mode`. Only validated file-backed +descriptors from the successful task attachment claim enter this value. +Do not copy inline bytes, storage paths, hidden prompts, or arbitrary metadata. + +Use the existing session metadata persistence and session publication path. +No new endpoint or table is needed. An atomic metadata-key update must preserve +other session keys. A failed preview write must stop this preparation path +before background work starts and use its existing failure handling. +Reused sessions must not acquire another initial preview. Legacy inline-only +attachments keep their existing post-launch behavior; this extension targets +the file-backed web submission path. + +The snapshot remains session-owned across reload and preparation failure. It +is display data, not evidence of delivery, a turn, a prompt ordinal, or a retry +queue. Retain it with the session so a delayed metadata hydration cannot revive +a deleted key. Existing session deletion removes it. Stored user messages are +always authoritative once loaded. Retries must not copy the snapshot into a +different session or alter attachment claim/delivery semantics. + +`useSessionState` already provides the resolved session. Pass its validated +preview through `useSessionData` to `useProcessedMessages`. Validate unknown +metadata before mapping it to `Message.metadata.attachments`; reject malformed +entries independently. Never combine task-wide attachment inventory with the +current session or read the transient `deferred_launch` metadata as display state. + +Render one synthetic user row only when history is initialized, no older +history remains, and no stored user row is visible. Prefer the session preview +when it has text or valid attachments; otherwise preserve the existing legacy +task-description fallback. Keep the synthetic row's existing identity and +missing-timestamp behavior. Include the preview in memo dependencies so late +session hydration updates the row. Do not insert it into persisted message +state or enable persisted-message mutations for it. + +Reuse `chat-message.tsx` image and resource attachment rendering, including +`attachmentContentUrl` and the existing image dialog. Correct its local +attachment type to represent optional inline bytes and file-backed IDs. +Continue to authorize content reads through the existing attachment service. +An inaccessible image must not remove the surrounding message or siblings. + +### Desktop and phone composition + +Both surfaces show the initial user row above existing preparation progress. +Images and compact file labels wrap inside that row, above its text. The primary +action is opening an image. No additional navigation or toolbar is introduced. +The phone entry is the existing task Chat view. Reuse the dedicated phone +composition in `components/task/task-layout.tsx`, the attachment controls in +`chat-message.tsx`, and the mixed-attachment mobile E2E exemplar. + +The transcript remains the single vertical scroll owner. Existing full-height +layout, safe-area handling, image-dialog dismissal, focus return, and touch +targets remain in effect. Inline attachment content fits this brief review +task without an intermediate picker. Desktop and phone share the preview data +and replacement logic; no responsive preference is persisted. + +### Verification boundaries + +Backend tests pause before workspace launch and verify persisted preview +metadata, fresh reads, prepare-only behavior, claim rejection, and unrelated +session isolation. Frontend tests cover descriptor mapping, attachment-only +content, malformed metadata, late hydration, history guards, and replacement. +Desktop and mobile browser tests open both attachments during preparation, +reload, then observe one stored initial message after launch. Include failure +and unavailable-content cases without suppressing existing progress errors. + +## Implementation plans + +- [Preparation attachment previews](../../../plans/preparation-attachment-previews/plan.md) + ## Observability Existing launch request diagnostics correlate claim errors with their task and