diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index 316bac7701..781df7b417 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -28,6 +28,18 @@ const overloaded529Message = "Internal error: API Error: 529 Overloaded. This is // through to the red recovery banner. var overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`) +const changesWalkthroughPromptMarker = "Please create an agent-authored walkthrough of the current changes" + +func isChangesWalkthroughRequest(prompt string) bool { + cmd := stripKandevSystem(strings.TrimSpace(prompt)) + legacyPrompt := strings.Contains(cmd, changesWalkthroughPromptMarker) && + strings.Contains(cmd, "show_walkthrough_kandev") && + strings.Contains(cmd, "Available changed files:") + promptReference := strings.Contains(cmd, "@changes-walkthrough") && + strings.Contains(cmd, "Changed files:") + return legacyPrompt || promptReference +} + // parseOverloadedCmd reports whether the prompt is the /overloaded command and, // if so, how many consecutive prompts it should fail before recovering // (default 1). Returns ok=false for any other prompt. @@ -215,6 +227,10 @@ func handlePrompt(e *emitter, prompt, model string) { emitBulk(e, count) return } + if isChangesWalkthroughRequest(cmd) { + scenarioWalkthroughRequested(e) + return + } switch { case strings.EqualFold(cmd, "all") || strings.EqualFold(cmd, "/all"): diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index 79832383d7..e6c3a92713 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -111,6 +111,39 @@ func TestStripKandevSystem(t *testing.T) { } } +func TestIsChangesWalkthroughRequest(t *testing.T) { + legacyPrompt := strings.Join([]string{ + "Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`.", + "", + "Walkthrough requirements:", + "- Anchor steps to changed lines or changed line ranges whenever possible.", + "", + "Available changed files:", + "- src/app.ts [uncommitted]", + }, "\n") + promptReference := strings.Join([]string{ + "@changes-walkthrough", + "", + "Changed files:", + "- src/app.ts [uncommitted]", + "", + "", + "EXPANDED PROMPT REFERENCES", + "### @changes-walkthrough", + "Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`.", + "", + }, "\n") + + for _, prompt := range []string{legacyPrompt, promptReference} { + if !isChangesWalkthroughRequest(prompt) { + t.Fatalf("expected generated changes walkthrough prompt to be detected:\n%s", prompt) + } + } + if isChangesWalkthroughRequest("show_walkthrough_kandev without the generated prompt shape") { + t.Fatal("expected unrelated prompt not to be detected") + } +} + func TestDelayRange(t *testing.T) { tests := []struct { model string diff --git a/apps/backend/cmd/mock-agent/scenarios.go b/apps/backend/cmd/mock-agent/scenarios.go index 22ed3dfb62..0ff7de86a1 100644 --- a/apps/backend/cmd/mock-agent/scenarios.go +++ b/apps/backend/cmd/mock-agent/scenarios.go @@ -36,6 +36,9 @@ var scenarioRegistry = map[string]func(e *emitter){ "multi-permission": scenarioMultiPermission, "kandev-mcp-permission": scenarioKandevMCPPermission, "review-cumulative-setup": scenarioReviewCumulativeSetup, + "walkthrough-setup": scenarioWalkthroughSetup, + "walkthrough-basic": scenarioWalkthroughBasic, + "walkthrough-reemit": scenarioWalkthroughReemit, "symlink-file-setup": scenarioSymlinkFileSetup, "markdown-table": scenarioMarkdownTable, "empty-turn": scenarioEmptyTurn, @@ -775,6 +778,187 @@ func scenarioReviewCumulativeSetup(e *emitter) { e.text("review-cumulative-setup complete: " + filePath + " has COMMITTED_CHANGE and UNCOMMITTED_CHANGE") } +// walkthroughDemoArgs is the show_walkthrough_kandev payload used by +// scenarioWalkthroughBasic. task_id is resolved server-side from the session's +// env (KANDEV_TASK_ID), mirroring the clarification path, so it is omitted here. +// Walkthrough step JSON keys, hoisted to constants to satisfy goconst. +const ( + wtKeyTitle = "title" + wtKeyFile = "file" + wtKeyLine = "line" + wtKeyLineEnd = "line_end" + wtKeyText = "text" + wtKeySteps = "steps" +) + +// wtStep builds one show_walkthrough step map. Keeping the JSON keys in one +// place avoids repeating string literals (goconst). +func wtStep(title, file, text string, line, lineEnd int) map[string]interface{} { + m := map[string]interface{}{wtKeyTitle: title, wtKeyFile: file, wtKeyLine: line, wtKeyText: text} + if lineEnd > 0 { + m[wtKeyLineEnd] = lineEnd + } + return m +} + +// wtArgs assembles show_walkthrough_kandev args from a title and steps. +func wtArgs(title string, steps ...map[string]interface{}) map[string]interface{} { + return map[string]interface{}{wtKeyTitle: title, wtKeySteps: steps} +} + +// scenarioWalkthroughReemit emits one walkthrough, waits, then emits a second +// (different) one — exercising the live `task.walkthrough.updated` path so the +// UI must reflect the re-emit without a page reload. +func scenarioWalkthroughReemit(e *emitter) { + fixedDelay(50) + wd, err := os.Getwd() + if err != nil { + e.text("walkthrough-reemit: getwd failed: " + err.Error()) + return + } + runGitCmd := makeGitRunner(wd) + if !writeWalkthroughFile(e, runGitCmd, "walkthrough-reemit", "reemit.txt", "line 1\nline 2\n", "line 1\nREEMIT_CHANGE\n") { + return + } + + e.text("First tour incoming.") + if _, err := callMCPTool("kandev", "show_walkthrough_kandev", wtArgs("First", + wtStep("First step", "reemit.txt", "REEMIT_FIRST step one.", 1, 0), + wtStep("First step 2", "reemit.txt", "REEMIT_FIRST step two.", 2, 0), + )); err != nil { + e.text(fmt.Sprintf("show_walkthrough failed: %s", err)) + return + } + e.text("reemit-first-done") + + fixedDelay(200) + + if _, err := callMCPTool("kandev", "show_walkthrough_kandev", wtArgs("Second", + wtStep("Second step", "reemit.txt", "REEMIT_SECOND step one.", 1, 0), + wtStep("Second step 2", "reemit.txt", "REEMIT_SECOND step two.", 2, 0), + wtStep("Second step 3", "reemit.txt", "REEMIT_SECOND step three.", 1, 0), + )); err != nil { + e.text(fmt.Sprintf("show_walkthrough re-emit failed: %s", err)) + return + } + e.text("reemit-second-done") +} + +// walkthroughDemoArgs builds a 5-step tour spanning three changed files plus +// one clean committed file (exercising both the diff-anchored card and the +// full-file / editor-mode floating window). +func walkthroughDemoArgs() map[string]interface{} { + return map[string]interface{}{ + wtKeyTitle: "Tour of the change", + wtKeySteps: []map[string]interface{}{ + wtStep("Entry point", "walkthrough_a.txt", + "Execution starts here. **Step 1** explains the entry point in file A.", 1, 0), + wtStep("The change in A", "walkthrough_a.txt", + "Step 2: this is WALKTHROUGH_CHANGE_A the tour narrates.", 2, 3), + wtStep("File B", "walkthrough_b.txt", + "Step 3: WALKTHROUGH_CHANGE_B lives in file B.", 2, 0), + wtStep("File C", "walkthrough_c.txt", + "Step 4: WALKTHROUGH_CHANGE_C lives in file C.", 2, 0), + wtStep("Unchanged file", "walkthrough_base.txt", + "Step 5: WALKTHROUGH_UNCHANGED — this base file did not change; shown from its current state.", 1, 0), + }, + } +} + +// writeWalkthroughFile writes content and commits it, then (when changed is +// non-empty) leaves an uncommitted modification so the file appears in review. +func writeWalkthroughFile(e *emitter, runGitCmd func(args ...string) error, prefix, path, base, changed string) bool { + if err := os.WriteFile(path, []byte(base), 0o644); err != nil { + e.text(prefix + ": write base failed: " + err.Error()) + return false + } + if err := runGitCmd("add", path); err != nil { + e.text(prefix + ": git add failed: " + err.Error()) + return false + } + if err := runGitCmd("commit", "-m", "add "+path); err != nil { + e.text(prefix + ": git commit failed: " + err.Error()) + return false + } + if changed == "" { + return true + } + if err := os.WriteFile(path, []byte(changed), 0o644); err != nil { + e.text(prefix + ": write change failed: " + err.Error()) + return false + } + return true +} + +func setupWalkthroughFiles(e *emitter, prefix string) bool { + wd, err := os.Getwd() + if err != nil { + e.text(prefix + ": getwd failed: " + err.Error()) + return false + } + runGitCmd := makeGitRunner(wd) + + fixtures := []string{"walkthrough_a.txt", "walkthrough_b.txt", "walkthrough_c.txt", "walkthrough_base.txt"} + _ = runGitCmd(append([]string{"rm", "--force"}, fixtures...)...) + _ = runGitCmd("commit", "-m", "cleanup walkthrough fixtures") + + // Three changed files (diff-anchored steps) + one clean committed file (editor-mode step). + ok := writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_a.txt", + "line 1: ENTRY\nline 2: BASE\nline 3: BASE\n", + "line 1: ENTRY\nline 2: WALKTHROUGH_CHANGE_A\nline 3: WALKTHROUGH_CHANGE_A\n") + ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_b.txt", + "line 1: B\nline 2: BASE\n", "line 1: B\nline 2: WALKTHROUGH_CHANGE_B\n") + ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_c.txt", + "line 1: C\nline 2: BASE\n", "line 1: C\nline 2: WALKTHROUGH_CHANGE_C\n") + ok = ok && writeWalkthroughFile(e, runGitCmd, prefix, "walkthrough_base.txt", + "line 1: WALKTHROUGH_UNCHANGED\nline 2: supporting context\n", "") + return ok +} + +func emitWalkthroughTour(e *emitter, doneText string) { + fixedDelay(100) + e.text("Let me walk you through the change.") + toolID := nextToolID() + toolName := "show_walkthrough_kandev" + args := walkthroughDemoArgs() + e.startTool(toolID, toolName, acp.ToolKindOther, args) + result, err := callMCPTool("kandev", toolName, args) + if err != nil { + e.completeTool(toolID, map[string]any{"error": "MCP error: " + err.Error()}) + e.text(fmt.Sprintf("show_walkthrough failed: %s", err)) + return + } + e.completeTool(toolID, map[string]any{"result": result}) + fixedDelay(50) + e.text(doneText) +} + +// scenarioWalkthroughSetup creates changed files without emitting a walkthrough. +// It lets E2E tests click the actual Changes-panel Walkthrough request button. +func scenarioWalkthroughSetup(e *emitter) { + fixedDelay(50) + if !setupWalkthroughFiles(e, "walkthrough-setup") { + return + } + e.text("walkthrough-setup complete: changes ready") +} + +// scenarioWalkthroughRequested is the mock-agent response to the prompt generated +// by the Changes-panel Walkthrough button. +func scenarioWalkthroughRequested(e *emitter) { + emitWalkthroughTour(e, "walkthrough-request complete: 5-step tour emitted") +} + +// scenarioWalkthroughBasic creates changed files, then emits a 5-step code +// walkthrough over them via the show_walkthrough_kandev MCP tool. +func scenarioWalkthroughBasic(e *emitter) { + fixedDelay(50) + if !setupWalkthroughFiles(e, "walkthrough-basic") { + return + } + emitWalkthroughTour(e, "walkthrough-basic complete: 5-step tour emitted") +} + // scenarioSymlinkFileSetup creates a file and a symlink to it, commits both, // then modifies the target file leaving an uncommitted diff. func scenarioSymlinkFileSetup(e *emitter) { diff --git a/apps/backend/config/prompts/changes-walkthrough.md b/apps/backend/config/prompts/changes-walkthrough.md new file mode 100644 index 0000000000..cba51bb9e9 --- /dev/null +++ b/apps/backend/config/prompts/changes-walkthrough.md @@ -0,0 +1,12 @@ +Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`. + +Walkthrough requirements: +- Use only files listed below or files you verify exist in this task's local worktree/review diff. +- For PR-only files, do not assume the PR head is checked out locally; anchor to the review diff when available, and avoid editor-only/current-worktree claims. +- Anchor steps to changed lines or changed line ranges whenever possible. +- Use `line_end` whenever a logical explanation spans multiple lines; prefer one range step over adjacent single-line steps. +- Keep each step concise and direct. Do not include a `Justification:` preamble. +- If a good local/review anchor is unavailable, omit that step instead of referencing a remote-only path. + +Available changed files: +{{changed_files}} diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 9459a49568..e02ce337c0 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1105,9 +1105,10 @@ func registerMCPAndDebugRoutes( planService *taskservice.PlanService, handoffSvc *taskservice.HandoffService, ) { + walkthroughService := taskservice.NewWalkthroughService(p.taskRepo, p.eventBus, p.log) mcpHandlers := mcphandlers.NewHandlers( p.taskSvc, wfCtrl, - clarificationStore, clarificationCanceller, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, p.orchestratorSvc, p.orchestratorSvc.GetMessageQueue(), p.log, + clarificationStore, clarificationCanceller, p.msgCreator, p.taskRepo, p.taskRepo, p.eventBus, planService, walkthroughService, p.orchestratorSvc, p.orchestratorSvc.GetMessageQueue(), p.log, ) // Wire config-mode dependencies for agent-native configuration mcpHandlers.SetConfigDeps(p.services.Workflow, p.agentSettingsController, p.mcpConfigSvc) diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index a396c15806..5e7b3db62b 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -69,6 +69,13 @@ const ( TaskPlanReverted = "task_plan.reverted" ) +// Event types for task walkthroughs (agent-authored guided code tours) +const ( + TaskWalkthroughCreated = "task_walkthrough.created" + TaskWalkthroughUpdated = "task_walkthrough.updated" + TaskWalkthroughDeleted = "task_walkthrough.deleted" +) + // Event types for session turns const ( TurnStarted = "turn.started" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 9d975b3179..ee6b2ee4b3 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -46,6 +46,9 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.TaskPlanDeleted, ws.ActionTaskPlanDeleted) b.subscribe(eventBus, events.TaskPlanRevisionCreated, ws.ActionTaskPlanRevisionCreated) b.subscribe(eventBus, events.TaskPlanReverted, ws.ActionTaskPlanReverted) + b.subscribe(eventBus, events.TaskWalkthroughCreated, ws.ActionTaskWalkthroughCreated) + b.subscribe(eventBus, events.TaskWalkthroughUpdated, ws.ActionTaskWalkthroughUpdated) + b.subscribe(eventBus, events.TaskWalkthroughDeleted, ws.ActionTaskWalkthroughDeleted) b.subscribe(eventBus, events.RepositoryCreated, ws.ActionRepositoryCreated) b.subscribe(eventBus, events.RepositoryUpdated, ws.ActionRepositoryUpdated) b.subscribe(eventBus, events.RepositoryDeleted, ws.ActionRepositoryDeleted) diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index fb0d368288..462e532399 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 51 + const wantSubscriptions = 54 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/integration/mcp_integration_test.go b/apps/backend/internal/integration/mcp_integration_test.go index 7a48d988e6..a1073cb5fc 100644 --- a/apps/backend/internal/integration/mcp_integration_test.go +++ b/apps/backend/internal/integration/mcp_integration_test.go @@ -20,7 +20,7 @@ func setupMCPTestServer(t *testing.T) (*TestServer, string, string, string, stri ts := NewTestServer(t) // Register MCP handlers on the gateway dispatcher - mcpH := mcphandlers.NewHandlers(ts.TaskSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ts.Logger) + mcpH := mcphandlers.NewHandlers(ts.TaskSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ts.Logger) mcpH.RegisterHandlers(ts.Gateway.Dispatcher) client := NewWSClient(t, ts.Server.URL) diff --git a/apps/backend/internal/mcp/handlers/clarification_pause_test.go b/apps/backend/internal/mcp/handlers/clarification_pause_test.go index 749fffd66d..eeaa2e3be9 100644 --- a/apps/backend/internal/mcp/handlers/clarification_pause_test.go +++ b/apps/backend/internal/mcp/handlers/clarification_pause_test.go @@ -103,7 +103,7 @@ func TestHandleAskUserQuestion_NoAnswerPausesSession(t *testing.T) { } }) pauser := &recordingClarificationInputPauser{} - h := NewHandlers(svc, nil, store, nil, nil, repo, repo, nil, nil, nil, nil, testLogger(t)) + h := NewHandlers(svc, nil, store, nil, nil, repo, repo, nil, nil, nil, nil, nil, testLogger(t)) h.SetClarificationInputPauser(pauser) payload := map[string]interface{}{ diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index bfa0cc422d..eb0db4025d 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -110,20 +110,21 @@ type PromptReferenceResolver interface { // Handlers provides MCP WebSocket handlers. type Handlers struct { - taskSvc *service.Service - workflowCtrl *workflowctrl.Controller - clarificationSvc ClarificationService - sessionCanceller SessionCanceller - inputPauser ClarificationInputPauser - messageCreator MessageCreator - sessionRepo SessionRepository - taskRepo TaskRepository - eventBus EventBus - planService *service.PlanService - sessionLauncher SessionLauncher - messageQueue MessageQueuer - promptResolver PromptReferenceResolver - logger *logger.Logger + taskSvc *service.Service + workflowCtrl *workflowctrl.Controller + clarificationSvc ClarificationService + sessionCanceller SessionCanceller + inputPauser ClarificationInputPauser + messageCreator MessageCreator + sessionRepo SessionRepository + taskRepo TaskRepository + eventBus EventBus + planService *service.PlanService + walkthroughService *service.WalkthroughService + sessionLauncher SessionLauncher + messageQueue MessageQueuer + promptResolver PromptReferenceResolver + logger *logger.Logger // Config-mode dependencies (optional, set via SetConfigDeps) workflowSvc *workflowsvc.Service @@ -151,23 +152,25 @@ func NewHandlers( taskRepo TaskRepository, eventBus EventBus, planService *service.PlanService, + walkthroughService *service.WalkthroughService, sessionLauncher SessionLauncher, messageQueue MessageQueuer, log *logger.Logger, ) *Handlers { return &Handlers{ - taskSvc: taskSvc, - workflowCtrl: workflowCtrl, - clarificationSvc: clarificationSvc, - sessionCanceller: sessionCanceller, - messageCreator: messageCreator, - sessionRepo: sessionRepo, - taskRepo: taskRepo, - eventBus: eventBus, - planService: planService, - sessionLauncher: sessionLauncher, - messageQueue: messageQueue, - logger: log.WithFields(zap.String("component", "mcp-handlers")), + taskSvc: taskSvc, + workflowCtrl: workflowCtrl, + clarificationSvc: clarificationSvc, + sessionCanceller: sessionCanceller, + messageCreator: messageCreator, + sessionRepo: sessionRepo, + taskRepo: taskRepo, + eventBus: eventBus, + planService: planService, + walkthroughService: walkthroughService, + sessionLauncher: sessionLauncher, + messageQueue: messageQueue, + logger: log.WithFields(zap.String("component", "mcp-handlers")), } } @@ -213,8 +216,16 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan) d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) d.RegisterFunc(ws.ActionMCPDeleteTaskPlan, h.handleDeleteTaskPlan) + d.RegisterFunc(ws.ActionMCPShowWalkthrough, h.handleShowWalkthrough) + d.RegisterFunc(ws.ActionMCPGetWalkthrough, h.handleGetWalkthrough) + d.RegisterFunc(ws.ActionMCPDeleteWalkthrough, h.handleDeleteWalkthrough) + // Plain (non-MCP) action so the web UI can backfill the current walkthrough + // on mount — live task.walkthrough.created events can fire before the page's + // WS subscription is established. Reuses the same read handler. + d.RegisterFunc(ws.ActionTaskWalkthroughGet, h.handleGetWalkthrough) + d.RegisterFunc(ws.ActionTaskWalkthroughDelete, h.handleDeleteWalkthrough) d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout) - count := 17 + count := 23 // Config-mode handlers (registered when config deps are set) if h.workflowSvc != nil { @@ -2718,6 +2729,88 @@ func (h *Handlers) handleDeleteTaskPlan(ctx context.Context, msg *ws.Message) (* return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) } +// handleShowWalkthrough creates or replaces a task's agent-authored code walkthrough. +func (h *Handlers) handleShowWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Title string `json:"title"` + Steps []models.WalkthroughStep `json:"steps"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if len(req.Steps) == 0 { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "at least one step is required", nil) + } + + wt, err := h.walkthroughService.ShowWalkthrough(ctx, service.ShowWalkthroughRequest{ + TaskID: req.TaskID, + Title: req.Title, + Steps: req.Steps, + }) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + if errors.Is(err, service.ErrInvalidWalkthrough) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to save walkthrough: "+err.Error(), nil) + } + + return ws.NewResponse(msg.ID, msg.Action, wt) +} + +// parseTaskIDPayload unmarshals a `{task_id}` payload, returning a ready error +// response (non-nil) when the payload is malformed. +func parseTaskIDPayload(msg *ws.Message) (string, *ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + m, e := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + return "", m, e + } + return req.TaskID, nil, nil +} + +// handleGetWalkthrough retrieves a task's walkthrough. +func (h *Handlers) handleGetWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + taskID, errMsg, errErr := parseTaskIDPayload(msg) + if errMsg != nil || errErr != nil { + return errMsg, errErr + } + wt, err := h.walkthroughService.GetWalkthrough(ctx, taskID) + if err != nil { + if errors.Is(err, service.ErrTaskIDRequired) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to get walkthrough", nil) + } + if wt == nil { + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{}) + } + return ws.NewResponse(msg.ID, msg.Action, wt) +} + +// handleDeleteWalkthrough deletes a task's walkthrough. +func (h *Handlers) handleDeleteWalkthrough(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + taskID, errMsg, errErr := parseTaskIDPayload(msg) + if errMsg != nil || errErr != nil { + return errMsg, errErr + } + switch err := h.walkthroughService.DeleteWalkthrough(ctx, taskID); { + case err == nil: + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"success": true}) + case errors.Is(err, service.ErrTaskIDRequired): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil) + case errors.Is(err, service.ErrTaskWalkthroughNotFound): + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Task walkthrough not found", nil) + default: + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to delete walkthrough: "+err.Error(), nil) + } +} + // handleClarificationTimeout is called by agentctl when the agent's MCP client // disconnects while waiting for a clarification response. It cancels the pending // clarification so the user's eventual answer goes through the event fallback path diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 27dbbda639..41291e18fe 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -2067,7 +2067,7 @@ func TestHandleClarificationTimeout_DetachesMessages(t *testing.T) { // Drain the in-memory store so the handler must fall back to DB-driven cleanup store.CancelSession(sess.ID) - h := NewHandlers(svc, nil, store, canceller, nil, repo, repo, eventBus, nil, nil, nil, testLogger(t)) + h := NewHandlers(svc, nil, store, canceller, nil, repo, repo, eventBus, nil, nil, nil, nil, testLogger(t)) msg := makeWSMessage(t, ws.ActionMCPClarificationTimeout, map[string]interface{}{"session_id": sess.ID}) resp, err := h.handleClarificationTimeout(ctx, msg) require.NoError(t, err) @@ -2110,7 +2110,7 @@ func TestHandleAskUserQuestion_Dedup_CreatesOnePendingBundle(t *testing.T) { store := clarification.NewStore(time.Minute) log := testLogger(t) - h := NewHandlers(svc, nil, store, nil, nil, repo, repo, nil, nil, nil, nil, log) + h := NewHandlers(svc, nil, store, nil, nil, repo, repo, nil, nil, nil, nil, nil, log) payload := map[string]interface{}{ "session_id": sess.ID, diff --git a/apps/backend/internal/mcp/server/handlers.go b/apps/backend/internal/mcp/server/handlers.go index 2246391c74..a98667114f 100644 --- a/apps/backend/internal/mcp/server/handlers.go +++ b/apps/backend/internal/mcp/server/handlers.go @@ -728,3 +728,63 @@ func (s *Server) deleteTaskPlanHandler() server.ToolHandlerFunc { return mcp.NewToolResultText("Plan deleted successfully."), nil } } + +func (s *Server) showWalkthroughHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := s.resolveTaskID(req) + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + args := req.GetArguments() + stepsRaw, ok := args["steps"] + if !ok { + return mcp.NewToolResultError("steps is required (array of {file, line, text} objects)"), nil + } + + payload := map[string]interface{}{ + "task_id": taskID, + "title": req.GetString("title", "Walkthrough"), + "steps": stepsRaw, + } + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPShowWalkthrough, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(fmt.Sprintf("Walkthrough saved:\n%s", string(data))), nil + } +} + +func (s *Server) getWalkthroughHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := s.resolveTaskID(req) + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + payload := map[string]string{"task_id": taskID} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPGetWalkthrough, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if len(result) == 0 { + return mcp.NewToolResultText("No walkthrough exists for this task yet."), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + +func (s *Server) deleteWalkthroughHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID, err := s.resolveTaskID(req) + if err != nil { + return mcp.NewToolResultError("task_id is required"), nil + } + payload := map[string]string{"task_id": taskID} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPDeleteWalkthrough, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultText("Walkthrough deleted successfully."), nil + } +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 006ead1b75..a3c5f8b4d1 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -369,6 +369,8 @@ func (s *Server) registerTools() { } s.registerPlanTools() count += 4 + s.registerWalkthroughTools() + count += 3 s.registerRelatedTasksTool() count++ // Task-mode only: requires a live session to attach the new @@ -866,6 +868,80 @@ func (s *Server) registerPlanTools() { ) } +// registerWalkthroughTools registers the agent-authored code-walkthrough tools. +// show_walkthrough is the JetBrains-style "walk a person through the code" tool: +// the agent supplies ordered, file+line-anchored steps that the user cycles +// through as popovers over the review diff with Previous/Next. +func (s *Server) registerWalkthroughTools() { + s.mcpServer.AddTool( + mcp.NewTool("show_walkthrough_kandev", + mcp.WithDescription( + "Show and store a guided code walkthrough for this task. Accepts an ordered list of "+ + "steps; each step anchors a short markdown explanation to a specific file line or "+ + "line range, and renders as a popover over the review diff/editor. The user cycles "+ + "through steps with Previous and Next. The walkthrough is saved to the task and "+ + "replaces any prior one. Only reference files that exist in the task's local worktree "+ + "or current review diff; for PR-only files, do not assume the PR head is checked out "+ + "locally. Use line_end when a logical explanation spans multiple lines. "+ + "Use this after producing a change to narrate the diff (what each hunk does and why), "+ + "or to explain how a part of the codebase works. Order steps to follow the reader's "+ + "natural path through the code (entry point first, then the call chain). Keep text "+ + "concise and do not add a 'Justification:' preamble."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to attach the walkthrough to")), + mcp.WithString("title", mcp.Description("Optional title for the walkthrough (default: 'Walkthrough')")), + mcp.WithArray("steps", mcp.Required(), + mcp.Description("Ordered list of walkthrough steps, each anchored to a file line or range."), + mcp.Items(buildWalkthroughStepSchemaItem()), + ), + ), + s.wrapHandler("show_walkthrough_kandev", s.showWalkthroughHandler()), + ) + s.mcpServer.AddTool( + mcp.NewTool("get_walkthrough_kandev", + mcp.WithDescription("Get the current code walkthrough for a task, including any steps."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to get the walkthrough for")), + ), + s.wrapHandler("get_walkthrough_kandev", s.getWalkthroughHandler()), + ) + s.mcpServer.AddTool( + mcp.NewTool("delete_walkthrough_kandev", + mcp.WithDescription("Delete the code walkthrough for a task."), + mcp.WithString("task_id", mcp.Required(), mcp.Description("The task ID to delete the walkthrough for")), + ), + s.wrapHandler("delete_walkthrough_kandev", s.deleteWalkthroughHandler()), + ) +} + +// buildWalkthroughStepSchemaItem describes one step object in the +// show_walkthrough_kandev tool schema. +func buildWalkthroughStepSchemaItem() map[string]any { + const typeKey = "type" + str := func(desc string) map[string]any { + return map[string]any{typeKey: "string", descriptionArg: desc} + } + num := func(desc string) map[string]any { + return map[string]any{typeKey: "integer", descriptionArg: desc} + } + return map[string]any{ + typeKey: "object", + "properties": map[string]any{ + titleArg: str("Optional short heading for this step."), + "repo": str("Optional repository name; disambiguates in multi-repo reviews."), + "file": str( + "Path to a file present in the task worktree or current review diff, relative to the repo root.", + ), + "line": num("1-based start line to anchor the popover to."), + "line_end": num( + "Optional 1-based end line. Use this for multi-line ranges instead of adjacent single-line steps.", + ), + "text": str( + "Concise markdown explanation shown in the step popover. Do not start with 'Justification:'.", + ), + }, + "required": []string{"file", "line", "text"}, + } +} + // buildQuestionSchemaItem describes the shape of a single question object in // the ask_user_question_kandev tool schema. Hoisted out of registerInteractionTools // to keep the registration body short and to deduplicate the JSON-schema diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index 807a35f22b..083377be06 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -212,10 +212,11 @@ func TestServerModeTask_ToolCount(t *testing.T) { tools := getRegisteredToolNames(s) // 13 kanban (incl. delete + archive task) + 1 add_branch_to_task + // 1 update_repository_base_branch + 1 step_complete (ADR 0015) + - // 1 interaction + 4 plan + 1 related-tasks = 22. Task-document tools - // (list/get/write) are office-only. + // 1 interaction + 4 plan + 3 walkthrough + 1 related-tasks = 25. + // Task-document tools (list/get/write) are office-only. assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode") - assert.Equal(t, 22, len(tools)) + assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode") + assert.Equal(t, 25, len(tools)) } func TestServerModeConfig_ToolCount(t *testing.T) { diff --git a/apps/backend/internal/prompts/store/sqlite.go b/apps/backend/internal/prompts/store/sqlite.go index 53feb49eec..6c2d8078fb 100644 --- a/apps/backend/internal/prompts/store/sqlite.go +++ b/apps/backend/internal/prompts/store/sqlite.go @@ -176,7 +176,7 @@ func (r *sqliteRepository) seedBuiltinPrompts() error { _, err := r.db.Exec(r.db.Rebind(` INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?) - ON CONFLICT(id) DO NOTHING + ON CONFLICT DO NOTHING `), prompt.ID, prompt.Name, prompt.Content, prompt.CreatedAt, prompt.UpdatedAt) if err != nil { return fmt.Errorf("failed to upsert built-in prompt %s: %w", prompt.ID, err) @@ -193,5 +193,6 @@ func (r *sqliteRepository) getBuiltinPrompts() []*models.Prompt { {ID: "builtin-open-pr", Name: "open-pr", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("open-pr")}, {ID: "builtin-merge-base", Name: "merge-base", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("merge-base")}, {ID: "builtin-ci-auto-fix", Name: "ci-auto-fix", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("ci-auto-fix")}, + {ID: "builtin-changes-walkthrough", Name: "changes-walkthrough", Builtin: true, CreatedAt: now, UpdatedAt: now, Content: promptcfg.Get("changes-walkthrough")}, } } diff --git a/apps/backend/internal/prompts/store/sqlite_test.go b/apps/backend/internal/prompts/store/sqlite_test.go index 3c27b45a8a..89e54275ec 100644 --- a/apps/backend/internal/prompts/store/sqlite_test.go +++ b/apps/backend/internal/prompts/store/sqlite_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/db" @@ -107,8 +108,8 @@ func TestSQLiteRepository_CRUD(t *testing.T) { t.Fatalf("expected custom prompt to be deleted, but it still exists") } } - if builtinCount != 4 { - t.Fatalf("expected 4 built-in prompts, got %d", builtinCount) + if builtinCount != 5 { + t.Fatalf("expected 5 built-in prompts, got %d", builtinCount) } } @@ -123,9 +124,10 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) { t.Fatalf("list prompts: %v", err) } - // Should include the CI auto-fix built-in prompt. + // Should include the CI auto-fix and changes walkthrough built-in prompts. builtinCount := 0 var ciAutoFixContent string + var changesWalkthroughContent string for _, p := range list { if p.Builtin { builtinCount++ @@ -133,10 +135,13 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) { if p.ID == "builtin-ci-auto-fix" && p.Name == "ci-auto-fix" && p.Content != "" { ciAutoFixContent = p.Content } + if p.ID == "builtin-changes-walkthrough" && p.Name == "changes-walkthrough" && p.Content != "" { + changesWalkthroughContent = p.Content + } } - if builtinCount != 4 { - t.Fatalf("expected 4 built-in prompts, got %d", builtinCount) + if builtinCount != 5 { + t.Fatalf("expected 5 built-in prompts, got %d", builtinCount) } if ciAutoFixContent == "" { t.Fatalf("expected ci-auto-fix built-in prompt") @@ -154,4 +159,62 @@ func TestSQLiteRepository_BuiltinPrompts(t *testing.T) { t.Fatalf("expected ci-auto-fix prompt to contain %q, got:\n%s", want, ciAutoFixContent) } } + if changesWalkthroughContent == "" { + t.Fatalf("expected changes-walkthrough built-in prompt") + } + for _, want := range []string{ + "show_walkthrough_kandev", + "{{changed_files}}", + "Use `line_end`", + "do not assume the PR head is checked out locally", + } { + if !strings.Contains(changesWalkthroughContent, want) { + t.Fatalf("expected changes-walkthrough prompt to contain %q, got:\n%s", want, changesWalkthroughContent) + } + } +} + +func TestSQLiteRepository_BuiltinSeedIgnoresUserPromptNameConflict(t *testing.T) { + tmpDir := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) + if err != nil { + t.Fatalf("failed to open sqlite db: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + defer func() { + if err := sqlxDB.Close(); err != nil { + t.Errorf("failed to close sqlite db: %v", err) + } + }() + if _, err := sqlxDB.Exec(` + CREATE TABLE custom_prompts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + content TEXT NOT NULL, + builtin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + `); err != nil { + t.Fatalf("create schema: %v", err) + } + now := time.Now().UTC() + if _, err := sqlxDB.Exec( + `INSERT INTO custom_prompts (id, name, content, builtin, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)`, + "user-walkthrough", "changes-walkthrough", "user-owned prompt", now, now, + ); err != nil { + t.Fatalf("seed user prompt: %v", err) + } + + repo, err := newSQLiteRepositoryWithDB(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("repo init should ignore built-in name conflicts: %v", err) + } + got, err := repo.GetPromptByName(context.Background(), "changes-walkthrough") + if err != nil { + t.Fatalf("get existing prompt: %v", err) + } + if got.ID != "user-walkthrough" || got.Builtin { + t.Fatalf("expected user prompt to remain canonical, got %+v", got) + } } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index f038db3e89..253d02ae96 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -18,6 +18,9 @@ var ErrExecutorRunningNotFound = errors.New("executor running not found") // ErrTaskSessionNotFound is returned when no task session record exists. var ErrTaskSessionNotFound = errors.New("task session not found") +// ErrTaskWalkthroughNotFound is returned when no walkthrough record exists. +var ErrTaskWalkthroughNotFound = errors.New("task walkthrough not found") + // ErrExecutorNotFound is returned by the executor repository when no // executor row exists for the given ID. Callers should use errors.Is to // distinguish "row doesn't exist" (404 semantically) from transport-level @@ -1152,6 +1155,32 @@ type TaskPlanRevision struct { UpdatedAt time.Time `json:"updated_at"` // bumps on coalesce merge } +// TaskWalkthrough is an agent-authored guided code tour attached to a task. +// It is the "what & where" of a review narration: an ordered list of Steps, +// each anchored to a concrete repo/file/line, rendered as popovers over the +// review diff. Mirrors the TaskPlan artifact pattern (one per task, agent-authored). +type TaskWalkthrough struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Title string `json:"title"` + Steps []WalkthroughStep `json:"steps"` + CreatedBy string `json:"created_by"` // always "agent" + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// WalkthroughStep is a single anchored stop in a TaskWalkthrough. Text is +// markdown shown in the popover; File/Line locate the anchor inside the diff +// (Repo disambiguates in multi-repo reviews, LineEnd optionally spans a range). +type WalkthroughStep struct { + Title string `json:"title,omitempty"` + Repo string `json:"repo,omitempty"` + File string `json:"file"` + Line int `json:"line"` + LineEnd int `json:"line_end,omitempty"` + Text string `json:"text"` +} + // TaskDocument represents a named document (plan, spec, notes, etc.) associated with a task. // The key uniquely identifies the document within a task (e.g., "plan", "spec", "notes"). type TaskDocument struct { diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 18014bd524..16665d8b69 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -17,6 +17,7 @@ func (r *Repository) initSchema() error { steps := []func() error{ r.initCoreSchema, r.initPlansSchema, + r.initWalkthroughsSchema, r.initDocumentsSchema, r.initSessionSchema, r.initGitSchema, @@ -362,6 +363,26 @@ func (r *Repository) initPlansSchema() error { return r.backfillInitialPlanRevisions() } +// initWalkthroughsSchema creates the task_walkthroughs table. Steps are stored +// as a JSON array in a single column (read/written whole — there is no +// per-step query path), keeping the artifact one row per task like task_plans. +func (r *Repository) initWalkthroughsSchema() error { + _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS task_walkthroughs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT 'Walkthrough', + steps TEXT NOT NULL DEFAULT '[]', + created_by TEXT NOT NULL DEFAULT 'agent', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_task_walkthroughs_task_id ON task_walkthroughs(task_id); + `) + return err +} + // backfillInitialPlanRevisions ensures every existing task_plans row has at least // one corresponding revision. Runs once at startup and is idempotent. func (r *Repository) backfillInitialPlanRevisions() error { diff --git a/apps/backend/internal/task/repository/sqlite/walkthrough.go b/apps/backend/internal/task/repository/sqlite/walkthrough.go new file mode 100644 index 0000000000..e5b670a8c0 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/walkthrough.go @@ -0,0 +1,120 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/kandev/kandev/internal/task/models" +) + +// walkthroughAuthorAgent matches the task_walkthroughs.created_by column +// DEFAULT — walkthroughs are always agent-authored. +const walkthroughAuthorAgent = "agent" + +// CreateTaskWalkthrough upserts a walkthrough for a task (one row per task, +// keyed by task_id). Steps are serialized to a JSON column. +func (r *Repository) CreateTaskWalkthrough(ctx context.Context, wt *models.TaskWalkthrough) error { + if wt.ID == "" { + wt.ID = uuid.New().String() + } + now := time.Now().UTC() + if wt.CreatedAt.IsZero() { + wt.CreatedAt = now + } + wt.UpdatedAt = now + if wt.Title == "" { + wt.Title = "Walkthrough" + } + if wt.CreatedBy == "" { + wt.CreatedBy = walkthroughAuthorAgent + } + + stepsJSON, err := marshalSteps(wt.Steps) + if err != nil { + return err + } + + err = r.db.QueryRowContext(ctx, r.db.Rebind(` + INSERT INTO task_walkthroughs (id, task_id, title, steps, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + title = excluded.title, + steps = excluded.steps, + created_by = excluded.created_by, + updated_at = excluded.updated_at + RETURNING id, created_at, updated_at + `), wt.ID, wt.TaskID, wt.Title, stepsJSON, wt.CreatedBy, wt.CreatedAt, wt.UpdatedAt).Scan( + &wt.ID, + &wt.CreatedAt, + &wt.UpdatedAt, + ) + if err != nil { + return fmt.Errorf("failed to upsert task walkthrough: %w", err) + } + return nil +} + +// GetTaskWalkthrough retrieves a walkthrough by task ID. Returns nil, nil when +// none exists. +func (r *Repository) GetTaskWalkthrough(ctx context.Context, taskID string) (*models.TaskWalkthrough, error) { + wt := &models.TaskWalkthrough{} + var stepsJSON string + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, task_id, title, steps, created_by, created_at, updated_at + FROM task_walkthroughs WHERE task_id = ? + `), taskID).Scan(&wt.ID, &wt.TaskID, &wt.Title, &stepsJSON, &wt.CreatedBy, &wt.CreatedAt, &wt.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get task walkthrough: %w", err) + } + steps, err := unmarshalSteps(stepsJSON) + if err != nil { + return nil, err + } + wt.Steps = steps + return wt, nil +} + +// DeleteTaskWalkthrough deletes a walkthrough by task ID. +func (r *Repository) DeleteTaskWalkthrough(ctx context.Context, taskID string) error { + result, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_walkthroughs WHERE task_id = ?`), taskID) + if err != nil { + return fmt.Errorf("failed to delete task walkthrough: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("%w for task: %s", models.ErrTaskWalkthroughNotFound, taskID) + } + return nil +} + +// marshalSteps serializes steps to a JSON array, never returning the "null" +// literal for an empty/nil slice (the column DEFAULT is '[]'). +func marshalSteps(steps []models.WalkthroughStep) (string, error) { + if len(steps) == 0 { + return "[]", nil + } + b, err := json.Marshal(steps) + if err != nil { + return "", fmt.Errorf("failed to marshal walkthrough steps: %w", err) + } + return string(b), nil +} + +func unmarshalSteps(raw string) ([]models.WalkthroughStep, error) { + if raw == "" { + return nil, nil + } + var steps []models.WalkthroughStep + if err := json.Unmarshal([]byte(raw), &steps); err != nil { + return nil, fmt.Errorf("failed to unmarshal walkthrough steps: %w", err) + } + return steps, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/walkthrough_test.go b/apps/backend/internal/task/repository/sqlite/walkthrough_test.go new file mode 100644 index 0000000000..8f4b0bc69c --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/walkthrough_test.go @@ -0,0 +1,136 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func seedWalkthroughTask(t *testing.T, ctx context.Context, repo *Repository, taskID string) { + t.Helper() + const wsID = "ws-wt" + const wfID = "wf-wt" + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: wsID, Name: "WT WS"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: wfID, WorkspaceID: wsID, Name: "WF"}) + if err := repo.CreateTask(ctx, &models.Task{ + ID: taskID, + WorkspaceID: wsID, + WorkflowID: wfID, + Title: "t", + State: "BACKLOG", + Priority: "medium", + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } +} + +func TestWalkthroughRepo_CreateGet(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedWalkthroughTask(t, ctx, repo, "task-1") + + wt := &models.TaskWalkthrough{ + TaskID: "task-1", + Title: "Tour", + Steps: []models.WalkthroughStep{ + {Title: "Start", Repo: "main", File: "a.go", Line: 3, Text: "first"}, + {File: "b.go", Line: 9, LineEnd: 12, Text: "second"}, + }, + } + if err := repo.CreateTaskWalkthrough(ctx, wt); err != nil { + t.Fatalf("CreateTaskWalkthrough: %v", err) + } + if wt.ID == "" || wt.CreatedAt.IsZero() { + t.Fatalf("expected id+timestamps populated: %+v", wt) + } + + got, err := repo.GetTaskWalkthrough(ctx, "task-1") + if err != nil { + t.Fatalf("GetTaskWalkthrough: %v", err) + } + if got == nil || got.Title != "Tour" || len(got.Steps) != 2 { + t.Fatalf("unexpected walkthrough: %+v", got) + } + if got.Steps[1].File != "b.go" || got.Steps[1].LineEnd != 12 { + t.Fatalf("steps round-trip mismatch: %+v", got.Steps) + } +} + +func TestWalkthroughRepo_Upsert(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedWalkthroughTask(t, ctx, repo, "task-1") + + first := &models.TaskWalkthrough{TaskID: "task-1", Steps: []models.WalkthroughStep{{File: "a.go", Line: 1, Text: "x"}}} + if err := repo.CreateTaskWalkthrough(ctx, first); err != nil { + t.Fatalf("create: %v", err) + } + second := &models.TaskWalkthrough{ID: first.ID, TaskID: "task-1", Steps: []models.WalkthroughStep{{File: "b.go", Line: 2, Text: "y"}, {File: "c.go", Line: 3, Text: "z"}}} + if err := repo.CreateTaskWalkthrough(ctx, second); err != nil { + t.Fatalf("upsert: %v", err) + } + if second.ID != first.ID || !second.CreatedAt.Equal(first.CreatedAt) { + t.Fatalf("expected upsert to reuse persisted id+created_at: first=%+v second=%+v", first, second) + } + + got, _ := repo.GetTaskWalkthrough(ctx, "task-1") + if got == nil || len(got.Steps) != 2 || got.Steps[0].File != "b.go" { + t.Fatalf("upsert did not replace steps: %+v", got) + } +} + +func TestWalkthroughRepo_GetMissing(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedWalkthroughTask(t, ctx, repo, "task-1") + + got, err := repo.GetTaskWalkthrough(ctx, "task-1") + if err != nil { + t.Fatalf("GetTaskWalkthrough: %v", err) + } + if got != nil { + t.Fatalf("expected nil, got %+v", got) + } +} + +func TestWalkthroughRepo_Delete(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedWalkthroughTask(t, ctx, repo, "task-1") + + if err := repo.CreateTaskWalkthrough(ctx, &models.TaskWalkthrough{TaskID: "task-1", Steps: []models.WalkthroughStep{{File: "a.go", Line: 1, Text: "x"}}}); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.DeleteTaskWalkthrough(ctx, "task-1"); err != nil { + t.Fatalf("delete: %v", err) + } + got, _ := repo.GetTaskWalkthrough(ctx, "task-1") + if got != nil { + t.Fatalf("expected nil after delete, got %+v", got) + } + if err := repo.DeleteTaskWalkthrough(ctx, "task-1"); !errors.Is(err, models.ErrTaskWalkthroughNotFound) { + t.Fatalf("expected ErrTaskWalkthroughNotFound deleting missing walkthrough, got %v", err) + } +} + +func TestWalkthroughRepo_CascadeOnTaskDelete(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedWalkthroughTask(t, ctx, repo, "task-1") + + if err := repo.CreateTaskWalkthrough(ctx, &models.TaskWalkthrough{TaskID: "task-1", Steps: []models.WalkthroughStep{{File: "a.go", Line: 1, Text: "x"}}}); err != nil { + t.Fatalf("create: %v", err) + } + if err := repo.DeleteTask(ctx, "task-1"); err != nil { + t.Fatalf("DeleteTask: %v", err) + } + got, err := repo.GetTaskWalkthrough(ctx, "task-1") + if err != nil { + t.Fatalf("GetTaskWalkthrough after task delete: %v", err) + } + if got != nil { + t.Fatalf("expected walkthrough cascade-deleted, got %+v", got) + } +} diff --git a/apps/backend/internal/task/service/walkthrough_service.go b/apps/backend/internal/task/service/walkthrough_service.go new file mode 100644 index 0000000000..24a739366c --- /dev/null +++ b/apps/backend/internal/task/service/walkthrough_service.go @@ -0,0 +1,198 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +// ErrTaskWalkthroughNotFound is returned when no walkthrough exists for a task. +var ErrTaskWalkthroughNotFound = models.ErrTaskWalkthroughNotFound + +// ErrInvalidWalkthrough is returned when an agent submits a malformed walkthrough. +var ErrInvalidWalkthrough = errors.New("invalid walkthrough") + +// Event-payload keys, hoisted to constants to satisfy goconst. +const ( + wtFieldTaskID = "task_id" + wtFieldTitle = "title" + wtFieldCreatedAt = "created_at" + wtFieldUpdatedAt = "updated_at" +) + +// walkthroughRepo is the minimal repository surface WalkthroughService needs. +// The SQLite repository satisfies it; declared locally so the service does not +// depend on the full aggregate repository interface. +type walkthroughRepo interface { + CreateTaskWalkthrough(ctx context.Context, wt *models.TaskWalkthrough) error + GetTaskWalkthrough(ctx context.Context, taskID string) (*models.TaskWalkthrough, error) + DeleteTaskWalkthrough(ctx context.Context, taskID string) error +} + +// WalkthroughService provides agent-authored code-walkthrough business logic. +// Walkthroughs mirror task plans: one per task, agent-authored, persisted, and +// broadcast to the web UI via the event bus. +type WalkthroughService struct { + repo walkthroughRepo + eventBus bus.EventBus + logger *logger.Logger +} + +// NewWalkthroughService creates a new walkthrough service. +func NewWalkthroughService(repo walkthroughRepo, eventBus bus.EventBus, log *logger.Logger) *WalkthroughService { + return &WalkthroughService{ + repo: repo, + eventBus: eventBus, + logger: log.WithFields(zap.String("component", "walkthrough-service")), + } +} + +// ShowWalkthroughRequest contains parameters for creating/replacing a walkthrough. +type ShowWalkthroughRequest struct { + TaskID string + Title string + Steps []models.WalkthroughStep +} + +// ShowWalkthrough upserts a task's walkthrough (replacing any existing one) and +// publishes the matching created/updated event. +func (s *WalkthroughService) ShowWalkthrough(ctx context.Context, req ShowWalkthroughRequest) (*models.TaskWalkthrough, error) { + if req.TaskID == "" { + return nil, ErrTaskIDRequired + } + title, steps, err := normalizeWalkthroughRequest(req) + if err != nil { + return nil, err + } + + existing, err := s.repo.GetTaskWalkthrough(ctx, req.TaskID) + if err != nil { + s.logger.Error("get existing walkthrough", zap.String("task_id", req.TaskID), zap.Error(err)) + return nil, err + } + created := existing == nil + + wt := &models.TaskWalkthrough{ + TaskID: req.TaskID, + Title: title, + Steps: steps, + CreatedBy: createdByAgent, + } + if existing != nil { + wt.ID = existing.ID + wt.CreatedAt = existing.CreatedAt + } + + if err := s.repo.CreateTaskWalkthrough(ctx, wt); err != nil { + s.logger.Error("upsert walkthrough", zap.String("task_id", req.TaskID), zap.Error(err)) + return nil, err + } + + if existing == nil && !wt.CreatedAt.Equal(wt.UpdatedAt) { + created = false + } + eventType := events.TaskWalkthroughUpdated + if created { + eventType = events.TaskWalkthroughCreated + } + s.publishEvent(ctx, eventType, wt) + return wt, nil +} + +func normalizeWalkthroughRequest(req ShowWalkthroughRequest) (string, []models.WalkthroughStep, error) { + if len(req.Steps) == 0 { + return "", nil, invalidWalkthroughError("at least one step is required") + } + + title := strings.TrimSpace(req.Title) + if title == "" { + title = "Walkthrough" + } + + steps := make([]models.WalkthroughStep, 0, len(req.Steps)) + for i, raw := range req.Steps { + step := models.WalkthroughStep{ + Title: strings.TrimSpace(raw.Title), + Repo: strings.TrimSpace(raw.Repo), + File: strings.TrimSpace(raw.File), + Line: raw.Line, + LineEnd: raw.LineEnd, + Text: strings.TrimSpace(raw.Text), + } + if step.File == "" { + return "", nil, invalidWalkthroughError("step %d file is required", i+1) + } + if step.Text == "" { + return "", nil, invalidWalkthroughError("step %d text is required", i+1) + } + if step.Line <= 0 { + return "", nil, invalidWalkthroughError("step %d line must be positive", i+1) + } + if step.LineEnd != 0 && step.LineEnd < step.Line { + return "", nil, invalidWalkthroughError("step %d line_end must be greater than or equal to line", i+1) + } + steps = append(steps, step) + } + + return title, steps, nil +} + +func invalidWalkthroughError(format string, args ...interface{}) error { + return fmt.Errorf("%w: %s", ErrInvalidWalkthrough, fmt.Sprintf(format, args...)) +} + +// GetWalkthrough returns a task's walkthrough, or nil, nil when none exists. +func (s *WalkthroughService) GetWalkthrough(ctx context.Context, taskID string) (*models.TaskWalkthrough, error) { + if taskID == "" { + return nil, ErrTaskIDRequired + } + return s.repo.GetTaskWalkthrough(ctx, taskID) +} + +// DeleteWalkthrough removes a task's walkthrough and publishes the deleted event. +func (s *WalkthroughService) DeleteWalkthrough(ctx context.Context, taskID string) error { + if taskID == "" { + return ErrTaskIDRequired + } + existing, err := s.repo.GetTaskWalkthrough(ctx, taskID) + if err != nil { + return err + } + if existing == nil { + return ErrTaskWalkthroughNotFound + } + if err := s.repo.DeleteTaskWalkthrough(ctx, taskID); err != nil { + if errors.Is(err, ErrTaskWalkthroughNotFound) { + return ErrTaskWalkthroughNotFound + } + return err + } + s.publishEvent(ctx, events.TaskWalkthroughDeleted, existing) + return nil +} + +func (s *WalkthroughService) publishEvent(ctx context.Context, eventType string, wt *models.TaskWalkthrough) { + if s.eventBus == nil { + return + } + payload := map[string]interface{}{ + "id": wt.ID, + wtFieldTaskID: wt.TaskID, + wtFieldTitle: wt.Title, + "steps": wt.Steps, + "created_by": wt.CreatedBy, + wtFieldCreatedAt: wt.CreatedAt, + wtFieldUpdatedAt: wt.UpdatedAt, + } + if err := s.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "walkthrough-service", payload)); err != nil { + s.logger.Error("publish walkthrough event", zap.String("event_type", eventType), zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/walkthrough_service_test.go b/apps/backend/internal/task/service/walkthrough_service_test.go new file mode 100644 index 0000000000..6cacbc274f --- /dev/null +++ b/apps/backend/internal/task/service/walkthrough_service_test.go @@ -0,0 +1,264 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +func createTestWalkthroughService(t *testing.T) (*WalkthroughService, *MockEventBus, *sqliterepo.Repository) { + t.Helper() + _, eventBus, repo := createTestService(t) + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewWalkthroughService(repo, eventBus, log) + return svc, eventBus, repo +} + +func sampleSteps() []models.WalkthroughStep { + return []models.WalkthroughStep{ + {Title: "Entry", File: "main.go", Line: 10, Text: "Program starts here"}, + {File: "server.go", Line: 42, LineEnd: 50, Text: "Routes registered"}, + } +} + +func TestWalkthroughService_ShowWalkthrough_Create(t *testing.T) { + svc, eventBus, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + wt, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{ + TaskID: "task-1", + Title: "Tour", + Steps: sampleSteps(), + }) + if err != nil { + t.Fatalf("ShowWalkthrough failed: %v", err) + } + if wt.TaskID != "task-1" || wt.Title != "Tour" { + t.Fatalf("unexpected walkthrough: %+v", wt) + } + if len(wt.Steps) != 2 || wt.Steps[0].File != "main.go" { + t.Fatalf("steps not persisted: %+v", wt.Steps) + } + if wt.CreatedBy != "agent" { + t.Fatalf("expected agent author, got %q", wt.CreatedBy) + } + + findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskWalkthroughCreated) +} + +func TestWalkthroughService_ShowWalkthrough_Replace(t *testing.T) { + svc, eventBus, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + first, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{TaskID: "task-1", Steps: sampleSteps()}) + if err != nil { + t.Fatalf("first ShowWalkthrough failed: %v", err) + } + eventBus.ClearEvents() + + second, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{ + TaskID: "task-1", + Steps: []models.WalkthroughStep{{File: "only.go", Line: 1, Text: "single"}}, + }) + if err != nil { + t.Fatalf("second ShowWalkthrough failed: %v", err) + } + if second.ID != first.ID { + t.Fatalf("replace should keep same id: %s vs %s", first.ID, second.ID) + } + if len(second.Steps) != 1 || second.Steps[0].File != "only.go" { + t.Fatalf("steps not replaced: %+v", second.Steps) + } + + got, err := svc.GetWalkthrough(ctx, "task-1") + if err != nil || got == nil || len(got.Steps) != 1 { + t.Fatalf("GetWalkthrough after replace = %+v, err %v", got, err) + } + findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskWalkthroughUpdated) +} + +func TestWalkthroughService_ShowWalkthrough_ConcurrentInsertPublishesUpdated(t *testing.T) { + eventBus := NewMockEventBus() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewWalkthroughService(&concurrentCreateWalkthroughRepo{}, eventBus, log) + + wt, err := svc.ShowWalkthrough(context.Background(), ShowWalkthroughRequest{ + TaskID: "task-1", + Steps: sampleSteps(), + }) + if err != nil { + t.Fatalf("ShowWalkthrough failed: %v", err) + } + if !wt.CreatedAt.Before(wt.UpdatedAt) { + t.Fatalf("expected repository to return an updated existing row, got created_at=%v updated_at=%v", wt.CreatedAt, wt.UpdatedAt) + } + findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskWalkthroughUpdated) +} + +func TestWalkthroughService_ShowWalkthrough_Validation(t *testing.T) { + svc, _, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + if _, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{Steps: sampleSteps()}); err == nil { + t.Fatal("expected error for missing task_id") + } + if _, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{TaskID: "task-1"}); err == nil { + t.Fatal("expected error for empty steps") + } else if !errors.Is(err, ErrInvalidWalkthrough) { + t.Fatalf("expected ErrInvalidWalkthrough for empty steps, got %v", err) + } + + tests := []struct { + name string + steps []models.WalkthroughStep + }{ + { + name: "empty file", + steps: []models.WalkthroughStep{{File: " ", Line: 1, Text: "x"}}, + }, + { + name: "empty text", + steps: []models.WalkthroughStep{{File: "a.go", Line: 1, Text: " "}}, + }, + { + name: "non-positive line", + steps: []models.WalkthroughStep{{File: "a.go", Line: 0, Text: "x"}}, + }, + { + name: "line_end before line", + steps: []models.WalkthroughStep{{File: "a.go", Line: 5, LineEnd: 4, Text: "x"}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{TaskID: "task-1", Steps: tt.steps}); err == nil { + t.Fatalf("expected validation error for %s", tt.name) + } else if !errors.Is(err, ErrInvalidWalkthrough) { + t.Fatalf("expected ErrInvalidWalkthrough for %s, got %v", tt.name, err) + } + }) + } +} + +func TestWalkthroughService_ShowWalkthrough_TrimsInput(t *testing.T) { + svc, _, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + wt, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{ + TaskID: "task-1", + Title: " Tour ", + Steps: []models.WalkthroughStep{{ + Title: " Intro ", + Repo: " repo-a ", + File: " main.go ", + Line: 7, + Text: " explanation ", + }}, + }) + if err != nil { + t.Fatalf("ShowWalkthrough failed: %v", err) + } + if wt.Title != "Tour" { + t.Fatalf("expected trimmed title, got %q", wt.Title) + } + step := wt.Steps[0] + if step.Title != "Intro" || step.Repo != "repo-a" || step.File != "main.go" || step.Text != "explanation" { + t.Fatalf("expected trimmed step fields, got %+v", step) + } +} + +func TestWalkthroughService_GetMissing(t *testing.T) { + svc, _, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + got, err := svc.GetWalkthrough(ctx, "task-1") + if err != nil { + t.Fatalf("GetWalkthrough failed: %v", err) + } + if got != nil { + t.Fatalf("expected nil for missing walkthrough, got %+v", got) + } +} + +func TestWalkthroughService_Delete(t *testing.T) { + svc, eventBus, repo := createTestWalkthroughService(t) + ctx := context.Background() + seedTask(t, ctx, repo, "task-1") + + if _, err := svc.ShowWalkthrough(ctx, ShowWalkthroughRequest{TaskID: "task-1", Steps: sampleSteps()}); err != nil { + t.Fatalf("seed walkthrough failed: %v", err) + } + eventBus.ClearEvents() + + if err := svc.DeleteWalkthrough(ctx, "task-1"); err != nil { + t.Fatalf("DeleteWalkthrough failed: %v", err) + } + got, err := svc.GetWalkthrough(ctx, "task-1") + if err != nil { + t.Fatalf("GetWalkthrough after delete failed: %v", err) + } + if got != nil { + t.Fatalf("expected nil after delete, got %+v", got) + } + findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskWalkthroughDeleted) + + if err := svc.DeleteWalkthrough(ctx, "task-1"); err != ErrTaskWalkthroughNotFound { + t.Fatalf("expected ErrTaskWalkthroughNotFound, got %v", err) + } +} + +func TestWalkthroughService_Delete_ConcurrentDeleteReturnsNotFound(t *testing.T) { + eventBus := NewMockEventBus() + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) + svc := NewWalkthroughService(&concurrentDeleteWalkthroughRepo{}, eventBus, log) + + if err := svc.DeleteWalkthrough(context.Background(), "task-1"); err != ErrTaskWalkthroughNotFound { + t.Fatalf("expected ErrTaskWalkthroughNotFound, got %v", err) + } + if got := len(eventBus.GetPublishedEvents()); got != 0 { + t.Fatalf("expected no delete event after concurrent delete, got %d", got) + } +} + +type concurrentCreateWalkthroughRepo struct{} + +func (r *concurrentCreateWalkthroughRepo) GetTaskWalkthrough(ctx context.Context, taskID string) (*models.TaskWalkthrough, error) { + return nil, nil +} + +func (r *concurrentCreateWalkthroughRepo) CreateTaskWalkthrough(ctx context.Context, wt *models.TaskWalkthrough) error { + now := time.Now().UTC() + wt.ID = "existing-walkthrough" + wt.CreatedAt = now.Add(-time.Minute) + wt.UpdatedAt = now + return nil +} + +func (r *concurrentCreateWalkthroughRepo) DeleteTaskWalkthrough(ctx context.Context, taskID string) error { + return nil +} + +type concurrentDeleteWalkthroughRepo struct{} + +func (r *concurrentDeleteWalkthroughRepo) GetTaskWalkthrough(ctx context.Context, taskID string) (*models.TaskWalkthrough, error) { + return &models.TaskWalkthrough{ID: "gone", TaskID: taskID, Steps: sampleSteps()}, nil +} + +func (r *concurrentDeleteWalkthroughRepo) CreateTaskWalkthrough(ctx context.Context, wt *models.TaskWalkthrough) error { + return nil +} + +func (r *concurrentDeleteWalkthroughRepo) DeleteTaskWalkthrough(ctx context.Context, taskID string) error { + return ErrTaskWalkthroughNotFound +} diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 53134d7e93..5bfcf4154a 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -165,6 +165,11 @@ const ( ActionTaskPlanDeleted = "task.plan.deleted" ActionTaskPlanRevisionCreated = "task.plan.revision.created" ActionTaskPlanReverted = "task.plan.reverted" + ActionTaskWalkthroughGet = "task.walkthrough.get" + ActionTaskWalkthroughDelete = "task.walkthrough.delete" + ActionTaskWalkthroughCreated = "task.walkthrough.created" + ActionTaskWalkthroughUpdated = "task.walkthrough.updated" + ActionTaskWalkthroughDeleted = "task.walkthrough.deleted" ActionAgentUpdated = "agent.updated" ActionAgentAvailableUpdated = "agent.available.updated" ActionAgentInstallStarted = "agent.install.started" @@ -338,6 +343,9 @@ const ( ActionMCPGetTaskPlan = "mcp.get_task_plan" ActionMCPUpdateTaskPlan = "mcp.update_task_plan" ActionMCPDeleteTaskPlan = "mcp.delete_task_plan" + ActionMCPShowWalkthrough = "mcp.show_walkthrough" + ActionMCPGetWalkthrough = "mcp.get_walkthrough" + ActionMCPDeleteWalkthrough = "mcp.delete_walkthrough" ActionMCPClarificationTimeout = "mcp.clarification_timeout" // Office task handoffs (cross-task context). diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 87cbab06e0..77d60ad12b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1378,6 +1378,22 @@ background-color: color-mix(in oklab, var(--primary) 15%, transparent) !important; } +/* Monaco Editor - Walkthrough active range */ +.monaco-walkthrough-line { + background-color: color-mix(in oklab, var(--primary) 11%, transparent) !important; +} +.monaco-walkthrough-line-number { + background-color: color-mix(in oklab, var(--primary) 15%, transparent) !important; + color: var(--foreground) !important; +} +.monaco-walkthrough-range, +.monaco-walkthrough-range-start { + background-color: color-mix(in oklab, var(--primary) 10%, transparent) !important; +} +.monaco-walkthrough-range-start { + border-left: 3px solid color-mix(in oklab, var(--primary) 72%, transparent) !important; +} + /* Monaco Editor - Peek view / references widget overrides */ .monaco-editor .peekview-widget .head { box-sizing: border-box; diff --git a/apps/web/components/diff/diff-viewer.tsx b/apps/web/components/diff/diff-viewer.tsx index 0415616f41..49917b350d 100644 --- a/apps/web/components/diff/diff-viewer.tsx +++ b/apps/web/components/diff/diff-viewer.tsx @@ -123,6 +123,32 @@ function NoDiffPlaceholder({ className }: { className?: string }) { ); } +function useScrollWalkthroughRangeIntoView( + wrapperRef: React.RefObject, + selectedLines: ReturnType["walkthroughSelectedLines"], + filePath: string, +) { + useEffect(() => { + if (!selectedLines) return; + let innerFrame: number | null = null; + const frame = requestAnimationFrame(() => { + innerFrame = requestAnimationFrame(() => { + const container = wrapperRef.current?.querySelector("diffs-container"); + const shadow = container?.shadowRoot; + const selected = shadow?.querySelector( + '[data-selected-line="first"], [data-selected-line="single"], [data-selected-line]', + ); + const fallback = shadow?.querySelector(`[data-line="${selectedLines.start}"]`); + (selected ?? fallback)?.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + }); + return () => { + cancelAnimationFrame(frame); + if (innerFrame !== null) cancelAnimationFrame(innerFrame); + }; + }, [wrapperRef, selectedLines, filePath]); +} + type WiringArgs = { data: FileDiffData; state: ReturnType; @@ -239,6 +265,7 @@ export const DiffViewer = memo(function DiffViewer({ baseRef, repo, }); + useScrollWalkthroughRangeIntoView(wrapperRef, state.walkthroughSelectedLines, data.filePath); const { options, renderHeaderMetadata, renderHoverUtility, renderAnnotation } = useDiffViewerWiring({ @@ -260,14 +287,20 @@ export const DiffViewer = memo(function DiffViewer({ repo, }); - const controlledSelection = state.showCommentForm ? state.selectedLines : null; + const controlledSelection = state.showCommentForm + ? state.selectedLines + : state.walkthroughSelectedLines; if (!state.fileDiffMetadata) { return ; } return ( -
+
): ReactNode => { const { type, comment, isEditing, changeBlockId } = annotation.metadata; + if (type === "walkthrough-step") { + return ; + } + if (type === "hunk-actions" && changeBlockId) { return ( { expect(out.result[0].side).toBe("additions"); }); }); + +describe("buildWalkthroughSelectedLines", () => { + it("returns the active walkthrough line range for a matching file", () => { + expect( + buildWalkthroughSelectedLines( + { path: "apps/web/main.ts", repository_name: "frontend" }, + { file: "main.ts", repo: "frontend", line: 10, line_end: 12, text: "explain" }, + ), + ).toEqual({ side: "additions", start: 10, end: 12 }); + }); + + it("returns null for mismatched repos", () => { + expect( + buildWalkthroughSelectedLines( + { path: "apps/web/main.ts", repository_name: "backend" }, + { file: "main.ts", repo: "frontend", line: 10, text: "explain" }, + ), + ).toBeNull(); + }); + + it("defaults line_end to line when the walkthrough step has no line_end", () => { + expect( + buildWalkthroughSelectedLines( + { path: "apps/web/main.ts", repository_name: "frontend" }, + { file: "main.ts", repo: "frontend", line: 15, text: "see this" }, + ), + ).toEqual({ side: "additions", start: 15, end: 15 }); + }); +}); diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index d6781780a1..50c32b6b76 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -13,6 +13,12 @@ import { useDiffMetadata } from "./use-diff-metadata"; import { useExpandableDiff } from "./use-expandable-diff"; import type { RevertBlockInfo } from "./diff-viewer"; import type { AnnotationMetadata } from "./use-diff-annotation-renderer"; +import { useOptionalAppStore } from "@/components/state-provider"; +import { + walkthroughStepMatchesFile, + type WalkthroughTargetFile, +} from "@/lib/diff/walkthrough-match"; +import type { WalkthroughStep } from "@/lib/types/http"; type BuildAnnotationsOpts = { comments: DiffComment[]; @@ -149,6 +155,57 @@ type UseDiffViewerStateOpts = { repo?: string; }; +/** + * Returns a `walkthrough-step` annotation anchored to the active walkthrough + * step's line when that step targets this file, else null. Additive and gated: + * with no active walkthrough it returns null and the diff is unaffected. + */ +export function buildWalkthroughSelectedLines( + file: WalkthroughTargetFile, + step: WalkthroughStep | null | undefined, +): SelectedLineRange | null { + if (!step || !walkthroughStepMatchesFile(file, step)) return null; + return { + side: "additions", + start: step.line, + end: step.line_end ?? step.line, + }; +} + +function useWalkthroughSelection( + filePath: string, + repo: string | undefined, +): { + annotation: DiffLineAnnotation | null; + selectedLines: SelectedLineRange | null; +} { + const activeTaskId = useOptionalAppStore((s) => s.tasks.activeTaskId, null); + const walkthrough = useOptionalAppStore( + (s) => (activeTaskId ? s.walkthroughs.byTaskId[activeTaskId] : null), + null, + ); + const activeStep = useOptionalAppStore( + (s) => (activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0), + 0, + ); + return useMemo(() => { + const step = walkthrough?.steps[activeStep]; + const selectedLines = buildWalkthroughSelectedLines( + { path: filePath, repository_name: repo }, + step, + ); + if (!selectedLines) return { annotation: null, selectedLines: null }; + return { + annotation: { + side: "additions" as AnnotationSide, + lineNumber: Math.max(selectedLines.start, selectedLines.end), + metadata: { type: "walkthrough-step" as const }, + }, + selectedLines, + }; + }, [walkthrough, activeStep, filePath, repo]); +} + function useDiffViewerAnnotations({ comments, editingCommentId, @@ -156,9 +213,13 @@ function useDiffViewerAnnotations({ selectedLines, enableAcceptReject, fileDiffMetadata, + filePath, + repo, changeLineMapRef, revertInfoRef, }: BuildAnnotationsOpts & { + filePath: string; + repo?: string; changeLineMapRef: RefObject>; revertInfoRef: RefObject>; }) { @@ -182,12 +243,18 @@ function useDiffViewerAnnotations({ ], ); + const walkthrough = useWalkthroughSelection(filePath, repo); + const withWalkthrough = useMemo( + () => (walkthrough.annotation ? [...annotations, walkthrough.annotation] : annotations), + [annotations, walkthrough.annotation], + ); + useEffect(() => { changeLineMapRef.current = lineMap; revertInfoRef.current = revertMap; }, [lineMap, revertMap, changeLineMapRef, revertInfoRef]); - return annotations; + return { annotations: withWalkthrough, walkthroughSelectedLines: walkthrough.selectedLines }; } type CommentHandlerOpts = { @@ -372,13 +439,15 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { const changeLineMapRef = useRef>(new Map()); const hideTimeoutRef = useRef | null>(null); - const annotations = useDiffViewerAnnotations({ + const { annotations, walkthroughSelectedLines } = useDiffViewerAnnotations({ comments, editingCommentId, showCommentForm, selectedLines, enableAcceptReject, fileDiffMetadata: expansion.metadata, + filePath: data.filePath, + repo, changeLineMapRef, revertInfoRef, }); @@ -405,6 +474,7 @@ export function useDiffViewerState(opts: UseDiffViewerStateOpts) { comments, fileDiffMetadata: expansion.metadata, annotations, + walkthroughSelectedLines, selectedLines, showCommentForm, setShowCommentForm, diff --git a/apps/web/components/diff/walkthrough-floating-window.tsx b/apps/web/components/diff/walkthrough-floating-window.tsx new file mode 100644 index 0000000000..c1bf2c3524 --- /dev/null +++ b/apps/web/components/diff/walkthrough-floating-window.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + type PointerEvent as ReactPointerEvent, + type RefObject, +} from "react"; +import { useAppStore } from "@/components/state-provider"; +import { useFileEditors } from "@/hooks/use-file-editors"; +import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal"; +import { + clearWalkthroughEditorAnchor, + computeWalkthroughConnectorPath, + isWalkthroughAnchorTargetVisible, + useWalkthroughEditorAnchor, + type WalkthroughViewportRect, +} from "@/lib/walkthrough-editor-anchor"; +import { WalkthroughStepInner, useHasActiveWalkthroughStep } from "./walkthrough-step-card"; +import { cn } from "@kandev/ui/lib/utils"; + +const DESKTOP_QUERY = "(min-width: 640px)"; +const CARD_WIDTH = 400; +const VIEWPORT_MARGIN = 16; + +function viewportRectFromDomRect(rect: DOMRect): WalkthroughViewportRect { + return { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + height: rect.height, + }; +} + +function clampPosition(pos: { x: number; y: number }, width: number, height: number) { + return { + x: Math.min(window.innerWidth - width - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, pos.x)), + y: Math.min(window.innerHeight - height - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, pos.y)), + }; +} + +function defaultDesktopPosition() { + return { + x: Math.max(VIEWPORT_MARGIN, window.innerWidth - CARD_WIDTH - 24), + y: 80, + }; +} + +function useIsDesktop() { + const [isDesktop, setIsDesktop] = useState(false); + useEffect(() => { + const media = window.matchMedia(DESKTOP_QUERY); + const update = () => setIsDesktop(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + return isDesktop; +} + +function useDraggableWindow(cardRef: RefObject, isDesktop: boolean) { + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); + useEffect(() => { + if (!isDesktop) return; + setPosition((current) => current ?? defaultDesktopPosition()); + }, [isDesktop]); + + const onPointerDown = (event: ReactPointerEvent) => { + if (!isDesktop) return; + const target = event.target as HTMLElement; + if (!target.closest("[data-walkthrough-drag-handle]")) return; + if (target.closest("button,input,textarea,a")) return; + const card = cardRef.current; + if (!card) return; + event.preventDefault(); + card.setPointerCapture(event.pointerId); + const rect = card.getBoundingClientRect(); + const offset = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + const onMove = (moveEvent: PointerEvent) => { + setPosition( + clampPosition( + { x: moveEvent.clientX - offset.x, y: moveEvent.clientY - offset.y }, + rect.width, + rect.height, + ), + ); + }; + const onUp = () => { + card.releasePointerCapture(event.pointerId); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }; + + return { position, onPointerDown }; +} + +function useCardRect( + cardRef: RefObject, + position: { x: number; y: number } | null, + anchorKey: string | undefined, +) { + const [rect, setRect] = useState(null); + useEffect(() => { + const card = cardRef.current; + if (!card) return; + const update = () => setRect(viewportRectFromDomRect(card.getBoundingClientRect())); + update(); + const observer = new ResizeObserver(update); + observer.observe(card); + window.addEventListener("resize", update); + return () => { + observer.disconnect(); + window.removeEventListener("resize", update); + }; + }, [cardRef, position, anchorKey]); + return rect; +} + +function useVisibleWalkthroughAnchor() { + const anchor = useWalkthroughEditorAnchor(); + useEffect(() => { + if (!anchor?.container) return; + const check = () => { + if (!isWalkthroughAnchorTargetVisible(anchor.container ?? null, anchor.rect)) { + clearWalkthroughEditorAnchor(anchor.key); + } + }; + check(); + const interval = window.setInterval(check, 150); + window.addEventListener("resize", check); + window.addEventListener("scroll", check, true); + return () => { + window.clearInterval(interval); + window.removeEventListener("resize", check); + window.removeEventListener("scroll", check, true); + }; + }, [anchor]); + return anchor; +} + +function WalkthroughConnector({ cardRect }: { cardRect: WalkthroughViewportRect | null }) { + const anchor = useVisibleWalkthroughAnchor(); + if (!anchor || !cardRect) return null; + if (anchor.container && !isWalkthroughAnchorTargetVisible(anchor.container, anchor.rect)) { + return null; + } + const path = computeWalkthroughConnectorPath(cardRect, anchor.rect); + if (!path) return null; + return ( + + ); +} + +/** + * The primary walkthrough surface: a floating card that, for each step, opens the + * step's file in its *current state* and reveals/centers the anchored line in the + * editor. Works uniformly for changed and unchanged files (onboarding / whole-repo + * tours), so it does not depend on the file being part of a diff. + */ +export function WalkthroughFloatingWindow({ + onClose, + onSelectFile, +}: { + onClose: () => void; + onSelectFile?: OpenFileFn; +}) { + const hasStep = useHasActiveWalkthroughStep(); + const { openFile: defaultOpenFile } = useFileEditors(); + const openFile = onSelectFile ?? defaultOpenFile; + const cardRef = useRef(null); + const isDesktop = useIsDesktop(); + const { position, onPointerDown } = useDraggableWindow(cardRef, isDesktop); + const anchor = useWalkthroughEditorAnchor(); + const cardRect = useCardRect(cardRef, position, anchor?.key); + // Select primitives (not a fresh object) so the selector stays referentially + // stable — returning a new object here would loop the store subscription. + const stepFile = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return null; + const wt = s.walkthroughs.byTaskId[taskId]; + const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; + return wt?.steps[idx]?.file ?? null; + }); + const stepLine = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return 0; + const wt = s.walkthroughs.byTaskId[taskId]; + const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; + return wt?.steps[idx]?.line ?? 0; + }); + const stepRepo = useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return undefined; + const wt = s.walkthroughs.byTaskId[taskId]; + const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; + return wt?.steps[idx]?.repo; + }); + + // Open the step's file (current state) and reveal its line whenever the step changes. + useEffect(() => { + if (stepFile) revealFileAtLine(openFile, stepFile, stepLine, stepRepo); + }, [stepFile, stepLine, stepRepo, openFile]); + + if (!hasStep) return null; + + return ( + <> + {isDesktop ? : null} +
+ +
+ + ); +} diff --git a/apps/web/components/diff/walkthrough-step-card.test.ts b/apps/web/components/diff/walkthrough-step-card.test.ts new file mode 100644 index 0000000000..26f90c7386 --- /dev/null +++ b/apps/web/components/diff/walkthrough-step-card.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { WALKTHROUGH_STEP_BODY_CLASS } from "./walkthrough-step-card"; + +describe("walkthrough step card", () => { + it("keeps walkthrough description text left-aligned", () => { + expect(WALKTHROUGH_STEP_BODY_CLASS).toContain("text-left"); + expect(WALKTHROUGH_STEP_BODY_CLASS).toContain("[text-align:left]"); + expect(WALKTHROUGH_STEP_BODY_CLASS).toContain("[&_p]:text-left"); + expect(WALKTHROUGH_STEP_BODY_CLASS).toContain("[&_li]:text-left"); + }); +}); diff --git a/apps/web/components/diff/walkthrough-step-card.tsx b/apps/web/components/diff/walkthrough-step-card.tsx new file mode 100644 index 0000000000..080e296ae1 --- /dev/null +++ b/apps/web/components/diff/walkthrough-step-card.tsx @@ -0,0 +1,327 @@ +"use client"; + +import { useEffect, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { IconX, IconFileCode } from "@tabler/icons-react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { useAppStore } from "@/components/state-provider"; +import { useCommentsStore } from "@/lib/state/slices/comments"; +import { useFileEditors } from "@/hooks/use-file-editors"; +import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; +import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal"; +import { generateUUID } from "@/lib/utils"; +import { useToast } from "@/components/toast-provider"; +import { + markdownComponents, + normalizeMarkdown, + remarkPlugins, +} from "@/components/shared/markdown-components"; +import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http"; +import type { WalkthroughComment } from "@/lib/state/slices/comments"; +import { CommentForm } from "./comment-form"; + +export const WALKTHROUGH_STEP_BODY_CLASS = + "prose prose-sm dark:prose-invert max-w-none text-left text-sm leading-6 [overflow-wrap:anywhere] [text-align:left] [&_li]:text-left [&_p]:my-2 [&_p]:text-left"; + +function StepBody({ step, onOpenFile }: { step: WalkthroughStep; onOpenFile: () => void }) { + const lineLabel = step.line_end ? `${step.line}–${step.line_end}` : `${step.line}`; + const fileName = step.file.split("/").pop() || step.file; + return ( +
+ {step.title ? ( +

+ {step.title} +

+ ) : null} + +
+ + {normalizeMarkdown(step.text)} + +
+
+ ); +} + +function StepHeader({ + activeStep, + stepCount, + lineLabel, + onClose, +}: { + activeStep: number; + stepCount: number; + lineLabel: string; + onClose: () => void; +}) { + return ( +
+ + Walkthrough + + + Step {activeStep + 1} / {stepCount} · {lineLabel} + + +
+ ); +} + +function StepNavigation({ + activeStep, + stepCount, + onPrevious, + onNext, +}: { + activeStep: number; + stepCount: number; + onPrevious: () => void; + onNext: () => void; +}) { + return ( +
+ + +
+ ); +} + +/** True when the active task has a walkthrough with a valid active step. */ +export function useHasActiveWalkthroughStep(): boolean { + return useAppStore((s) => { + const taskId = s.tasks.activeTaskId; + if (!taskId) return false; + const wt = s.walkthroughs.byTaskId[taskId]; + const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; + return !!wt?.steps[idx]; + }); +} + +function useWalkthroughStepFeedback(params: { + activeStep: number; + activeTaskId: string | null; + sessionId: string | null | undefined; + step: WalkthroughStep | undefined; + stepCount: number; + walkthrough: TaskWalkthrough | null | undefined; +}) { + const { activeStep, activeTaskId, sessionId, step, stepCount, walkthrough } = params; + const addComment = useCommentsStore((s) => s.addComment); + const { runComment } = useRunComment({ sessionId: sessionId ?? null, taskId: activeTaskId }); + const { toast } = useToast(); + const buildComment = (text: string): WalkthroughComment | null => { + if (!sessionId || !activeTaskId || !walkthrough || !step) return null; + return buildWalkthroughComment({ + sessionId, + taskId: activeTaskId, + walkthrough, + step, + activeStep, + stepCount, + text, + }); + }; + const showMissingSessionError = () => { + toast({ title: "No active session for walkthrough note", variant: "error" }); + }; + const addWalkthroughFeedback = (text: string) => { + const comment = buildComment(text); + if (!comment) { + showMissingSessionError(); + return; + } + addComment(comment); + toast({ title: "Walkthrough note added", variant: "success" }); + }; + const runWalkthroughFeedback = (text: string) => { + const comment = buildComment(text); + if (!comment) { + showMissingSessionError(); + return; + } + addComment(comment); + void runComment(comment) + .then(({ queued }) => { + toast({ + title: queued ? "Walkthrough note queued" : "Walkthrough note sent", + variant: "success", + }); + }) + .catch(() => { + toast({ title: "Failed to send walkthrough note", variant: "error" }); + }); + }; + return { addWalkthroughFeedback, runWalkthroughFeedback }; +} + +/** + * The shared inner card body (header, markdown, Prev/Next, ask box) used by both + * the inline diff-anchored card and the editor-mode floating window. Reads all + * state from the store; renders nothing when there is no active step. The note + * box mirrors other agent feedback surfaces: "Add" creates a pending + * walkthrough context item, and "Run" sends the same context to the agent. + */ +export function WalkthroughStepInner({ + onClose, + onSelectFile, +}: { + onClose: () => void; + onSelectFile?: OpenFileFn; +}) { + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const sessionId = useAppStore((s) => s.tasks.activeSessionId); + const walkthrough = useAppStore((s) => + activeTaskId ? s.walkthroughs.byTaskId[activeTaskId] : null, + ); + const activeStep = useAppStore((s) => + activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, + ); + const setActiveStep = useAppStore((s) => s.setWalkthroughActiveStep); + const markSeen = useAppStore((s) => s.markWalkthroughSeen); + const { openFile: defaultOpenFile } = useFileEditors(); + const openFile = onSelectFile ?? defaultOpenFile; + const stepCount = walkthrough?.steps.length ?? 0; + const step = walkthrough?.steps[activeStep]; + const { addWalkthroughFeedback, runWalkthroughFeedback } = useWalkthroughStepFeedback({ + activeStep, + activeTaskId, + sessionId, + step, + stepCount, + walkthrough, + }); + useEffect(() => { + if (activeTaskId && walkthrough) markSeen(activeTaskId); + }, [activeTaskId, walkthrough, markSeen]); + if (!activeTaskId || !walkthrough) return null; + if (!step) return null; + const lineLabel = step.line_end ? `Lines ${step.line}–${step.line_end}` : `Line ${step.line}`; + + return ( +
+ + revealFileAtLine(openFile, step.file, step.line, step.repo)} + /> + setActiveStep(activeTaskId, activeStep - 1)} + onNext={() => setActiveStep(activeTaskId, activeStep + 1)} + /> +
+ {}} + autoFocus={false} + /> +
+
+ ); +} + +function buildWalkthroughComment(params: { + sessionId: string; + taskId: string; + walkthrough: TaskWalkthrough; + step: WalkthroughStep; + activeStep: number; + stepCount: number; + text: string; +}): WalkthroughComment { + const { sessionId, taskId, walkthrough, step, activeStep, stepCount, text } = params; + const startLine = step.line; + const endLine = step.line_end ?? step.line; + return { + id: generateUUID(), + source: "walkthrough", + sessionId, + taskId, + walkthroughId: walkthrough.id, + walkthroughTitle: walkthrough.title, + stepIndex: activeStep, + stepCount, + repo: step.repo, + filePath: step.file, + startLine: Math.min(startLine, endLine), + endLine: Math.max(startLine, endLine), + stepText: step.text, + text, + createdAt: new Date().toISOString(), + status: "pending", + }; +} + +/** + * Inline walkthrough popover rendered as a diff annotation, anchored directly + * beneath the step's line. A caret + left accent visually bind it to the line + * above. + */ +export function WalkthroughStepCard() { + const hasStep = useHasActiveWalkthroughStep(); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const activeStep = useAppStore((s) => + activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, + ); + const updatedAt = useAppStore((s) => + activeTaskId ? s.walkthroughs.byTaskId[activeTaskId]?.updated_at : undefined, + ); + const [dismissedKey, setDismissedKey] = useState(null); + const stepKey = activeTaskId ? `${activeTaskId}:${updatedAt ?? ""}:${activeStep}` : ""; + if (!hasStep || dismissedKey === stepKey) return null; + return ( +
+
+ setDismissedKey(stepKey)} /> +
+ ); +} diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 88d7388da4..80520c878d 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -1,7 +1,8 @@ "use client"; -import { useRef } from "react"; +import { useCallback, useRef, useState } from "react"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import type { EditorView } from "@codemirror/view"; import { Button } from "@kandev/ui/button"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { @@ -22,6 +23,7 @@ import { EditorCommentPopover } from "@/components/task/editor-comment-popover"; import { CommentViewPopover } from "@/components/task/comment-view-popover"; import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { useCodeMirrorEditorState } from "./use-codemirror-editor-state"; +import { useCodeMirrorWalkthroughRange } from "./use-codemirror-walkthrough-range"; const SAVE_SHORTCUT = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; @@ -36,6 +38,7 @@ type FileEditorContentProps = { isSaving: boolean; sessionId?: string; worktreePath?: string; + repo?: string; enableComments?: boolean; onToggleMarkdownPreview?: () => void; onChange: (newContent: string) => void; @@ -312,6 +315,7 @@ export function CodeMirrorCodeEditor({ isSaving, sessionId, worktreePath, + repo, enableComments = false, onToggleMarkdownPreview, onChange, @@ -320,7 +324,9 @@ export function CodeMirrorCodeEditor({ onDelete, }: FileEditorContentProps) { const wrapperRef = useRef(null); + const editorAreaRef = useRef(null); const editorRef = useRef(null); + const [editorView, setEditorView] = useState(null); const state = useCodeMirrorEditorState({ path, content, @@ -334,6 +340,15 @@ export function CodeMirrorCodeEditor({ wrapperRef, editorRef, }); + const walkthroughRange = useCodeMirrorWalkthroughRange({ + view: editorView, + editorAreaRef, + path, + repo, + }); + const handleCreateEditor = useCallback((view: EditorView) => { + setEditorView(view); + }, []); return (
@@ -354,7 +369,7 @@ export function CodeMirrorCodeEditor({ onDelete={onDelete} onToggleMarkdownPreview={onToggleMarkdownPreview} /> -
+
+ {walkthroughRange ? ( +
diff --git a/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts b/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts new file mode 100644 index 0000000000..2b045f6d61 --- /dev/null +++ b/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts @@ -0,0 +1,152 @@ +import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { EditorView } from "@codemirror/view"; +import { useAppStore } from "@/components/state-provider"; +import { + clearWalkthroughEditorAnchor, + isWalkthroughAnchorTargetVisible, + setWalkthroughEditorAnchor, + type WalkthroughViewportRect, +} from "@/lib/walkthrough-editor-anchor"; +import { + getWalkthroughEditorRange, + type WalkthroughEditorRange, + type WalkthroughRangeBox, +} from "@/lib/walkthrough-editor-range"; +import { useIsWalkthroughOpenForTask } from "@/lib/walkthrough-open-state"; + +type UseCodeMirrorWalkthroughRangeOpts = { + view: EditorView | null; + editorAreaRef: RefObject; + path: string; + repo?: string; +}; + +function rectFromViewport(left: number, top: number, width: number, height: number) { + return { left, top, width, height, right: left + width, bottom: top + height }; +} + +function clampDocLine(view: EditorView, line: number): number { + return Math.min(Math.max(line, 1), view.state.doc.lines); +} + +function linePosition(view: EditorView, line: number) { + return view.state.doc.line(clampDocLine(view, line)); +} + +function measureRangeBox( + view: EditorView, + area: HTMLElement, + range: WalkthroughEditorRange, +): { box: WalkthroughRangeBox; viewportRect: WalkthroughViewportRect } | null { + const startLine = linePosition(view, range.startLine); + const endLine = linePosition(view, range.endLine); + const start = view.coordsAtPos(startLine.from); + const end = view.coordsAtPos(endLine.to) ?? start; + if (!start || !end) return null; + + const areaRect = area.getBoundingClientRect(); + const scrollerRect = view.scrollDOM.getBoundingClientRect(); + const left = Math.max(38, scrollerRect.left - areaRect.left + 36); + const top = start.top - areaRect.top; + const height = Math.max(18, end.bottom - start.top); + const width = Math.max(120, areaRect.right - (areaRect.left + left) - 12); + + return { + box: { ...range, top, left, width, height }, + viewportRect: rectFromViewport(areaRect.left + left, start.top, width, height), + }; +} + +function useActiveWalkthroughStep() { + const taskId = useAppStore((s) => s.tasks.activeTaskId); + const isOpen = useIsWalkthroughOpenForTask(taskId); + const stepIndex = useAppStore((s) => + taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, + ); + const step = useAppStore((s) => + taskId ? (s.walkthroughs.byTaskId[taskId]?.steps[stepIndex] ?? null) : null, + ); + return { taskId, stepIndex, step: isOpen ? step : null }; +} + +export function useCodeMirrorWalkthroughRange({ + view, + editorAreaRef, + path, + repo, +}: UseCodeMirrorWalkthroughRangeOpts): WalkthroughRangeBox | null { + const [box, setBox] = useState(null); + const { taskId, stepIndex, step } = useActiveWalkthroughStep(); + const range = useMemo( + () => getWalkthroughEditorRange({ path, repository_name: repo }, step), + [path, repo, step], + ); + const anchorKey = taskId ? `${taskId}:${stepIndex}:${repo ?? ""}:${path}:cm` : ""; + const didRevealRef = useRef(""); + + useEffect(() => { + const area = editorAreaRef.current; + if (!view || !area || !range || !taskId || !step) { + didRevealRef.current = ""; + setBox(null); + if (anchorKey) clearWalkthroughEditorAnchor(anchorKey); + return; + } + + const revealKey = `${anchorKey}:${range.startLine}-${range.endLine}`; + if (didRevealRef.current !== revealKey) { + didRevealRef.current = revealKey; + const startLine = linePosition(view, range.startLine); + view.dispatch({ + effects: EditorView.scrollIntoView(startLine.from, { y: "center" }), + }); + } + + let frame: number | null = null; + const update = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + const measured = measureRangeBox(view, area, range); + if (!measured) { + setBox(null); + clearWalkthroughEditorAnchor(anchorKey); + return; + } + if (!isWalkthroughAnchorTargetVisible(view.dom, measured.viewportRect)) { + setBox(null); + clearWalkthroughEditorAnchor(anchorKey); + return; + } + setBox(measured.box); + setWalkthroughEditorAnchor({ + key: anchorKey, + taskId, + stepIndex, + file: step.file, + repo: step.repo, + line: range.startLine, + lineEnd: range.endLine, + rect: measured.viewportRect, + container: view.dom, + }); + }); + }; + + update(); + const resizeObserver = new ResizeObserver(update); + resizeObserver.observe(area); + const visibilityInterval = window.setInterval(update, 150); + view.scrollDOM.addEventListener("scroll", update); + window.addEventListener("resize", update); + return () => { + if (frame !== null) cancelAnimationFrame(frame); + resizeObserver.disconnect(); + window.clearInterval(visibilityInterval); + view.scrollDOM.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + clearWalkthroughEditorAnchor(anchorKey); + }; + }, [anchorKey, editorAreaRef, path, range, step, stepIndex, taskId, view]); + + return box; +} diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx index 89c86c09f5..544fd6bdd2 100644 --- a/apps/web/components/editors/monaco/monaco-code-editor.tsx +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -13,6 +13,7 @@ import { useEditorViewZoneComments } from "@/hooks/use-editor-view-zone-comments import { MonacoEditorToolbar } from "./monaco-editor-toolbar"; import { useMonacoEditorComments } from "./use-monaco-editor-state"; import { useMonacoEditorLsp, useMonacoDiffDecorations } from "./use-monaco-editor-lsp"; +import { useMonacoWalkthroughRange } from "./use-monaco-walkthrough-range"; import { initMonacoThemes } from "./monaco-init"; initMonacoThemes(); @@ -27,6 +28,7 @@ type MonacoCodeEditorProps = { isSaving: boolean; sessionId?: string; worktreePath?: string; + repo?: string; enableComments?: boolean; onToggleMarkdownPreview?: () => void; onChange: (newContent: string) => void; @@ -116,6 +118,7 @@ function useMonacoCodeEditorSetup(props: MonacoCodeEditorProps) { vcsDiff, sessionId, worktreePath, + repo, enableComments = false, onChange, onSave, @@ -125,6 +128,7 @@ function useMonacoCodeEditorSetup(props: MonacoCodeEditorProps) { const language = getMonacoLanguage(path); const state = useMonacoEditorComments({ path, + repo, enableComments, sessionId, wrapperRef, @@ -175,6 +179,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { isSaving, sessionId, worktreePath, + repo, enableComments = false, onToggleMarkdownPreview, onSave, @@ -183,6 +188,13 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { } = props; const { resolvedTheme } = useTheme(); const { wrapperRef, language, state, lsp, diffStats, options } = useMonacoCodeEditorSetup(props); + const editorAreaRef = useRef(null); + const walkthroughRange = useMonacoWalkthroughRange({ + editor: state.editorInstance, + editorAreaRef, + path, + repo, + }); return (
@@ -209,7 +221,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { onDelete={onDelete} onToggleMarkdownPreview={onToggleMarkdownPreview} /> -
+
} /> + {walkthroughRange ? ( + + ); +} + +type DiscardWalkthroughDialogProps = { + discarding: boolean; + onConfirm: (event: MouseEvent) => void; + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +function DiscardWalkthroughDialog({ + discarding, + onConfirm, + onOpenChange, + open, +}: DiscardWalkthroughDialogProps) { + return ( + + + + Discard walkthrough? + + This removes the saved walkthrough from this task. The agent can create a new one later. + + + + + Cancel + + + Discard walkthrough + + + + + ); +} + +function useWalkthroughBackfill(params: { + connectionStatus: string; + setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; + taskId: string | null; + walkthrough: TaskWalkthrough | null | undefined; +}) { + const { connectionStatus, setWalkthrough, taskId, walkthrough } = params; + const fetchedRef = useRef>(new Set()); + const inFlightRef = useRef>(new Set()); + useEffect(() => { + if ( + !taskId || + walkthrough || + connectionStatus !== "connected" || + fetchedRef.current.has(taskId) || + inFlightRef.current.has(taskId) + ) { + return; + } + let cancelled = false; + inFlightRef.current.add(taskId); + getTaskWalkthrough(taskId) + .then((wt) => { + if (cancelled) return; + if (wt) { + fetchedRef.current.add(taskId); + setWalkthrough(taskId, wt); + } + }) + .catch(() => {}) + .finally(() => { + inFlightRef.current.delete(taskId); + }); + return () => { + cancelled = true; + }; + }, [connectionStatus, setWalkthrough, taskId, walkthrough]); +} + +/** + * Task-level launcher for an agent-authored walkthrough. It (1) backfills the + * walkthrough into the store on mount — a live `task.walkthrough.created` event + * can fire before the page's WS subscription exists — and (2) toggles the + * floating step card, which opens each step's file (current state) and reveals + * the anchored line. Works for changed and unchanged files alike (no review + * surface required). + */ +export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) { + const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null)); + const connectionStatus = useAppStore((s) => s.connection.status); + const activeStep = useAppStore((s) => + taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, + ); + const lastSeenUpdatedAt = useAppStore((s) => + taskId ? s.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] : undefined, + ); + const setWalkthrough = useAppStore((s) => s.setWalkthrough); + const [open, setOpen] = useState(false); + const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); + const [discarding, setDiscarding] = useState(false); + const { toast } = useToast(); + + useWalkthroughBackfill({ connectionStatus, setWalkthrough, taskId, walkthrough }); + + useEffect(() => { + if (!taskId || !open) return; + setOpenWalkthroughTaskId(taskId); + return () => clearOpenWalkthroughTaskId(taskId); + }, [open, taskId]); + + if (!taskId || !walkthrough) return null; + const hasUnseen = walkthrough.updated_at !== lastSeenUpdatedAt; + + // Refresh to the latest persisted walkthrough when opening the card — covers + // the case where the agent re-emitted a walkthrough and the live WS update + // was missed (e.g. the tab was idle), so it shows without a page reload. + const openTour = () => { + setOpenWalkthroughTaskId(taskId); + getTaskWalkthrough(taskId) + .then((wt) => { + if (wt) setWalkthrough(taskId, wt); + }) + .catch(() => {}); + setOpen(true); + }; + const closeTour = () => { + clearOpenWalkthroughTaskId(taskId); + setOpen(false); + }; + const confirmDiscard = async (event: MouseEvent) => { + event.preventDefault(); + if (!taskId || discarding) return; + setDiscarding(true); + try { + await deleteTaskWalkthrough(taskId); + clearOpenWalkthroughTaskId(taskId); + setOpen(false); + setWalkthrough(taskId, null); + setConfirmDiscardOpen(false); + toast({ title: "Walkthrough discarded", variant: "success" }); + } catch (error) { + console.error("Failed to discard walkthrough:", error); + toast({ title: "Failed to discard walkthrough", variant: "error" }); + } finally { + setDiscarding(false); + } + }; + + return ( + <> + {open ? : null} + setConfirmDiscardOpen(true)} + onToggle={() => (open ? closeTour() : openTour())} + stepCount={walkthrough.steps.length} + /> + + + ); +} diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx index 8ad9a1efa4..47088eb4ac 100644 --- a/apps/web/components/state-provider.tsx +++ b/apps/web/components/state-provider.tsx @@ -1,6 +1,14 @@ "use client"; -import { createContext, useContext, useEffect, useLayoutEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useLayoutEffect, + useState, + useSyncExternalStore, +} from "react"; import type { StoreApi } from "zustand"; import { useStore } from "zustand"; import { isDebug, registerSessionTaskResolver } from "@/lib/debug/log"; @@ -118,6 +126,22 @@ export function useAppStore(selector: (state: AppState) => T) { return useStore(store, selector); } +export function useOptionalAppStore(selector: (state: AppState) => T, fallback: T) { + const store = useContext(StoreContext); + const subscribe = useCallback( + (listener: () => void) => { + if (!store) return () => {}; + return store.subscribe(() => listener()); + }, + [store], + ); + const getSnapshot = useCallback( + () => (store ? selector(store.getState()) : fallback), + [fallback, selector, store], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + export function useAppStoreApi() { const store = useContext(StoreContext); if (!store) { diff --git a/apps/web/components/task/changes-panel-data.tsx b/apps/web/components/task/changes-panel-data.tsx index 6d9e08b39b..f294eb05a4 100644 --- a/apps/web/components/task/changes-panel-data.tsx +++ b/apps/web/components/task/changes-panel-data.tsx @@ -27,6 +27,8 @@ import { buildRepoNameById, } from "./changes-panel-helpers"; import type { OpenDiffOptions } from "./changes-diff-target"; +import type { WalkthroughPromptFile } from "@/lib/walkthrough-request"; +import { buildWalkthroughPromptFiles } from "@/lib/walkthrough-prompt-files"; function useChangesPanelStoreData() { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); @@ -242,8 +244,19 @@ export function useChangesPanelData() { () => buildPrByRepoMap(taskPRsForMap, repoNameById, pendingByRepo), [taskPRsForMap, repoNameById, pendingByRepo], ); + const walkthroughPromptFiles = useMemo( + () => + buildWalkthroughPromptFiles({ + unstagedFiles, + stagedFiles, + committedFiles: git.statusLoaded ? git.cumulativeDiff?.files : null, + prFiles: prData.prFiles, + }), + [git.cumulativeDiff?.files, git.statusLoaded, prData.prFiles, stagedFiles, unstagedFiles], + ); return { activeTaskId, + activeSessionId, git, baseBranchDisplay, baseBranchByRepo, @@ -259,6 +272,7 @@ export function useChangesPanelData() { repoDisplayName, prByRepo, existingPrUrl, + walkthroughPromptFiles, ...prData, }; } diff --git a/apps/web/components/task/changes-panel-header.tsx b/apps/web/components/task/changes-panel-header.tsx index 350071a604..b4e626ddba 100644 --- a/apps/web/components/task/changes-panel-header.tsx +++ b/apps/web/components/task/changes-panel-header.tsx @@ -11,12 +11,14 @@ import { IconArrowRight, IconLoader2, IconEdit, + IconRoute, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, @@ -420,10 +422,14 @@ function ChangesPanelHeaderLeft({ showDiffReview, onOpenDiffAll, onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, }: { showDiffReview: boolean; onOpenDiffAll?: () => void; onOpenReview?: () => void; + onRequestWalkthrough?: () => void; + requestWalkthroughDisabled?: boolean; }) { if (!showDiffReview) return null; return ( @@ -446,10 +452,49 @@ function ChangesPanelHeaderLeft({ Review + {onRequestWalkthrough ? ( + + ) : null} ); } +function ChangesPanelWalkthroughButton({ + onRequestWalkthrough, + requestWalkthroughDisabled, +}: { + onRequestWalkthrough: () => void; + requestWalkthroughDisabled?: boolean; +}) { + const tooltip = requestWalkthroughDisabled + ? "Loading changed files..." + : "Walk me through these changes"; + return ( + + + + + + + {tooltip} + + ); +} + export function ChangesPanelHeader({ hasChanges, hasCommits, @@ -462,6 +507,8 @@ export function ChangesPanelHeader({ loadingOperation, onOpenDiffAll, onOpenReview, + onRequestWalkthrough, + requestWalkthroughDisabled, repoNames, perRepoStatus, onRepoPull, @@ -484,6 +531,8 @@ export function ChangesPanelHeader({ loadingOperation: string | null; onOpenDiffAll?: () => void; onOpenReview?: () => void; + onRequestWalkthrough?: () => void; + requestWalkthroughDisabled?: boolean; /** Always non-empty (single-repo includes the empty-name entry). */ repoNames: string[]; perRepoStatus: PerRepoStatus[]; @@ -511,6 +560,8 @@ export function ChangesPanelHeader({ showDiffReview={showDiffReview} onOpenDiffAll={onOpenDiffAll} onOpenReview={onOpenReview} + onRequestWalkthrough={onRequestWalkthrough} + requestWalkthroughDisabled={requestWalkthroughDisabled} /> } right={ diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 99177a30e7..2bee1c999d 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -8,6 +8,7 @@ import { filterUnpushedCommits, mergeCommits } from "./changes-panel-helpers"; import { useChangesPanelData, buildChangesPanelBodyProps } from "./changes-panel-data"; import { ChangesPanelBody } from "./changes-panel-body"; import type { OpenDiffOptions } from "./changes-diff-target"; +import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough"; export { filterUnpushedCommits, mergeCommits }; @@ -22,6 +23,11 @@ type ChangesPanelProps = { const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { const isArchived = useIsTaskArchived(); const data = useChangesPanelData(); + const requestWalkthrough = useRequestChangesWalkthrough({ + taskId: data.activeTaskId, + sessionId: data.activeSessionId, + files: data.walkthroughPromptFiles, + }); if (isArchived) return ; return ( @@ -37,6 +43,8 @@ const ChangesPanel = memo(function ChangesPanel(props: ChangesPanelProps) { loadingOperation={data.git.loadingOperation} onOpenDiffAll={props.onOpenDiffAll} onOpenReview={props.onOpenReview} + onRequestWalkthrough={requestWalkthrough} + requestWalkthroughDisabled={data.walkthroughPromptFiles.length === 0} repoNames={data.git.repoNames} perRepoStatus={data.git.perRepoStatus} onRepoPull={data.repoCallbacks.onRepoPull} diff --git a/apps/web/components/task/changes-top-bar.tsx b/apps/web/components/task/changes-top-bar.tsx index c1723bd5ab..bb10d3fda3 100644 --- a/apps/web/components/task/changes-top-bar.tsx +++ b/apps/web/components/task/changes-top-bar.tsx @@ -7,6 +7,7 @@ import { IconLayoutRows, IconMessageForward, IconArrowsMaximize, + IconRoute, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; @@ -31,6 +32,8 @@ export type ChangesTopBarProps = { handleToggleSplitView: (v: boolean) => void; handleToggleAutoMark: (v: boolean) => void; handleFixComments: () => void; + handleRequestWalkthrough?: () => void; + requestWalkthroughDisabled?: boolean; }; function ChangesTopBarLeft({ @@ -81,6 +84,40 @@ function ChangesTopBarLeft({ ); } +function ReviewWalkthroughRequestButton({ + handleRequestWalkthrough, + requestWalkthroughDisabled, +}: Pick) { + if (!handleRequestWalkthrough) return null; + const tooltip = requestWalkthroughDisabled + ? "Loading changed files..." + : "Walk me through these changes"; + return ( + + + + + + + {tooltip} + + ); +} + function ChangesTopBarRight({ splitView, wordWrap, @@ -88,6 +125,8 @@ function ChangesTopBarRight({ setWordWrap, handleToggleSplitView, handleFixComments, + handleRequestWalkthrough, + requestWalkthroughDisabled, }: Pick< ChangesTopBarProps, | "splitView" @@ -96,9 +135,15 @@ function ChangesTopBarRight({ | "setWordWrap" | "handleToggleSplitView" | "handleFixComments" + | "handleRequestWalkthrough" + | "requestWalkthroughDisabled" >) { return ( <> + +
+ ))} +
+ ); + + return ( + + ); +}); diff --git a/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx b/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx index 46f0dd5d17..07098cbf0f 100644 --- a/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx +++ b/apps/web/components/task/chat/messages/kandev-tool-message.test.tsx @@ -51,6 +51,9 @@ describe("hasKandevRenderer", () => { expect(hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__list_tasks_kandev" }))).toBe( true, ); + expect( + hasKandevRenderer(kandevToolCall({ toolName: "mcp__kandev__show_walkthrough_kandev" })), + ).toBe(true); }); it("does not match unrelated tools", () => { @@ -69,6 +72,7 @@ describe("hasKandevRenderer", () => { // constants to satisfy the "no duplicate string" lint rule. const COMPLETED = "COMPLETED"; const FIX_LOGIN_BUG = "Fix login bug"; +const CHANGE_TOUR = "Change tour"; // The expandable row only renders its body when expanded. For tests that // need to inspect the body, set status to "running" which auto-expands the @@ -274,6 +278,76 @@ describe("KandevToolMessage document & question renderers", () => { }); }); +describe("KandevToolMessage walkthrough renderer", () => { + const step = { + title: "Review handler", + file: "apps/backend/internal/mcp/handlers.go", + line: 42, + line_end: 45, + text: "This handler persists the walkthrough.", + }; + + it("renders show_walkthrough with a walkthrough icon and title", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Walkthrough: Change tour"); + expect(html).toContain("tabler-icon-route"); + expect(html).toContain("1 step"); + expect(html).toContain("apps/backend/internal/mcp/handlers.go:42-45"); + expect(html).toContain("Review handler"); + }); + + it("renders show_walkthrough from an MCP text result when args are absent", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("Walkthrough: Change tour"); + expect(html).toContain("1 step"); + expect(html).toContain("Review handler"); + }); + + it("includes repo anchors in walkthrough step locations", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("backend:apps/backend/internal/mcp/handlers.go:42-45"); + }); +}); + // Unset status is treated as pending by parsePermission. function pendingPermissionMessage(toolCallId: string): Message { return { diff --git a/apps/web/components/task/chat/messages/kandev/registry.ts b/apps/web/components/task/chat/messages/kandev/registry.ts index 43939d7004..9f7f253c2a 100644 --- a/apps/web/components/task/chat/messages/kandev/registry.ts +++ b/apps/web/components/task/chat/messages/kandev/registry.ts @@ -28,6 +28,7 @@ import { WriteTaskDocumentRenderer, } from "./document-renderers"; import { AskUserQuestionRenderer } from "./ask-user-question-renderer"; +import { ShowWalkthroughRenderer } from "./walkthrough-renderer"; import type { KandevRenderer } from "./types"; export const KANDEV_RENDERERS: Record = { @@ -54,6 +55,7 @@ export const KANDEV_RENDERERS: Record = { get_task_conversation: GetTaskConversationRenderer, ask_user_question: AskUserQuestionRenderer, + show_walkthrough: ShowWalkthroughRenderer, }; export function getKandevRenderer(stem: string | null): KandevRenderer | null { diff --git a/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx b/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx new file mode 100644 index 0000000000..adfa068b78 --- /dev/null +++ b/apps/web/components/task/chat/messages/kandev/walkthrough-renderer.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { IconRoute } from "@tabler/icons-react"; +import { + IdChip, + KandevBody, + KandevRow, + KeyValueRow, + ListItemRow, + SummaryDot, + pluralCount, +} from "./shared"; +import { pickArray, pickNumber, pickString } from "./parse"; +import type { KandevRenderer } from "./types"; + +const DEFAULT_TITLE = "Walkthrough"; +const RESULT_JSON_PREFIX = "Walkthrough saved:"; + +type WalkthroughStepLike = Record; + +function parseResultText(text: string | undefined): Record | null { + if (!text) return null; + const trimmed = text.trim(); + const jsonStart = trimmed.startsWith(RESULT_JSON_PREFIX) + ? trimmed.slice(RESULT_JSON_PREFIX.length).trim() + : trimmed; + if (!jsonStart.startsWith("{")) return null; + try { + const parsed = JSON.parse(jsonStart); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function resultPayload(result: unknown): unknown { + return ( + parseResultText(typeof result === "string" ? result : undefined) ?? + parseResultText(pickString(result, "result")) ?? + parseResultText(pickString(result, "text")) ?? + result + ); +} + +function titleFrom(args: unknown, result: unknown): string { + const payload = resultPayload(result); + return pickString(payload, "title") ?? pickString(args, "title") ?? DEFAULT_TITLE; +} + +function stepsFrom(args: unknown, result: unknown): WalkthroughStepLike[] { + const payload = resultPayload(result); + return ( + pickArray(payload, "steps") ?? + pickArray(args, "steps") ?? + [] + ); +} + +function stepLocation(step: WalkthroughStepLike): string | null { + const file = pickString(step, "file"); + const line = pickNumber(step, "line"); + if (!file || !line) return null; + const repo = pickString(step, "repo"); + const lineEnd = pickNumber(step, "line_end"); + const lineLabel = lineEnd && lineEnd !== line ? `${line}-${lineEnd}` : `${line}`; + return `${repo ? `${repo}:` : ""}${file}:${lineLabel}`; +} + +function stepPreview(step: WalkthroughStepLike): string | null { + const title = pickString(step, "title"); + if (title) return title; + const text = pickString(step, "text")?.trim(); + if (!text) return null; + return text.length > 90 ? `${text.slice(0, 90).trim()}...` : text; +} + +export const ShowWalkthroughRenderer: KandevRenderer = ({ args, result, status }) => { + const taskId = pickString(args, "task_id"); + const title = titleFrom(args, result); + const steps = stepsFrom(args, result); + return ( + + {taskId && ( + <> + + + + )} + {pluralCount(steps.length, "step")} + + } + status={status} + hasExpandableContent={steps.length > 0} + > + + {title} +
+ {steps.map((step, index) => { + const location = stepLocation(step); + const preview = stepPreview(step); + return ( + +
+ Step {index + 1} + {location && ( + + {location} + + )} + {preview && {preview}} +
+
+ ); + })} +
+
+
+ ); +}; 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 8fb1dec735..91cca123ca 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -13,17 +13,27 @@ import { useProcessedMessages } from "@/hooks/use-processed-messages"; import { useSessionModel } from "@/hooks/domains/session/use-session-model"; import { useQueue } from "@/hooks/domains/session/use-queue"; import { useContextFilesStore, type ContextFile } from "@/lib/state/context-files-store"; -import { useCommentsStore, isPlanComment, isPRFeedbackComment } from "@/lib/state/slices/comments"; +import { + useCommentsStore, + isPlanComment, + isPRFeedbackComment, + isWalkthroughComment, +} from "@/lib/state/slices/comments"; import { usePendingDiffCommentsByFile } from "@/hooks/domains/comments/use-diff-comments"; import { usePendingPlanComments, usePendingPRFeedback, + usePendingWalkthroughComments, } from "@/hooks/domains/comments/use-pending-comments"; import { buildContextItems } from "../chat-context-items"; import { useAutoDisablePlanMode, usePlanLayoutHandlers } from "./use-plan-mode-helpers"; import type { ContextItem } from "@/lib/types/context"; import type { DiffComment } from "@/lib/diff/types"; -import type { PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments"; +import type { + PlanComment, + PRFeedbackComment, + WalkthroughComment, +} from "@/lib/state/slices/comments"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { BuiltInPreset } from "@/lib/state/layout-manager/presets"; import { readLastAgentError } from "@/lib/session-last-agent-error"; @@ -42,11 +52,14 @@ export type CommentsState = { planComments: PlanComment[]; pendingCommentsByFile: Record; pendingPRFeedback: PRFeedbackComment[]; + walkthroughComments: WalkthroughComment[]; markCommentsSent: (ids: string[]) => void; handleRemoveCommentFile: (filePath: string) => void; handleRemoveComment: (commentId: string) => void; handleRemovePRFeedback: (commentId: string) => void; handleClearPRFeedback: () => void; + handleRemoveWalkthroughComment: (commentId: string) => void; + handleClearWalkthroughComments: () => void; clearSessionPlanComments: () => void; }; @@ -248,6 +261,7 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat const planComments = usePendingPlanComments(resolvedSessionId); const pendingCommentsByFile = usePendingDiffCommentsByFile(resolvedSessionId); const pendingPRFeedback = usePendingPRFeedback(resolvedSessionId); + const walkthroughComments = usePendingWalkthroughComments(resolvedSessionId); const markCommentsSent = useCommentsStore((state) => state.markCommentsSent); const removeComment = useCommentsStore((state) => state.removeComment); const clearSessionPlanComments = useCallback(() => { @@ -286,15 +300,33 @@ export function useCommentsState(resolvedSessionId: string | null): CommentsStat } } }, [resolvedSessionId]); + const handleRemoveWalkthroughComment = useCallback( + (commentId: string) => removeComment(commentId), + [removeComment], + ); + const handleClearWalkthroughComments = useCallback(() => { + if (!resolvedSessionId) return; + const state = useCommentsStore.getState(); + const allPending = [...state.pendingForChat]; + for (const id of allPending) { + const c = state.byId[id]; + if (c && isWalkthroughComment(c) && c.sessionId === resolvedSessionId) { + state.removeComment(id); + } + } + }, [resolvedSessionId]); return { planComments, pendingCommentsByFile, pendingPRFeedback, + walkthroughComments, markCommentsSent, handleRemoveCommentFile, handleRemoveComment, handleRemovePRFeedback, handleClearPRFeedback, + handleRemoveWalkthroughComment, + handleClearWalkthroughComments, clearSessionPlanComments, }; } @@ -352,6 +384,9 @@ function useChatContextItems(opts: ChatContextItemsOptions) { pendingPRFeedback: comments.pendingPRFeedback, handleRemovePRFeedback: comments.handleRemovePRFeedback, handleClearPRFeedback: comments.handleClearPRFeedback, + walkthroughComments: comments.walkthroughComments, + handleRemoveWalkthroughComment: comments.handleRemoveWalkthroughComment, + handleClearWalkthroughComments: comments.handleClearWalkthroughComments, taskId, }), [ @@ -372,6 +407,9 @@ function useChatContextItems(opts: ChatContextItemsOptions) { comments.pendingPRFeedback, comments.handleRemovePRFeedback, comments.handleClearPRFeedback, + comments.walkthroughComments, + comments.handleRemoveWalkthroughComment, + comments.handleClearWalkthroughComments, taskId, ], ); diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index d3d97caeaf..78f5908af2 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -46,6 +46,7 @@ import { } from "./dockview-desktop-layout-hooks"; import { renderPanel } from "./dockview-panel-content"; import { PreviewController } from "./preview-controller"; +import { WalkthroughOverlay } from "@/components/review/walkthrough-overlay"; import { BottomTerminalPanel } from "./bottom-terminal-panel"; import { DockviewReviewDialog } from "./dockview-review-dialog"; import { useReviewDialog } from "./use-review-dialog"; @@ -409,6 +410,11 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ +
); }); diff --git a/apps/web/components/task/file-browser-actions.test.ts b/apps/web/components/task/file-browser-actions.test.ts new file mode 100644 index 0000000000..a5c1a2be9b --- /dev/null +++ b/apps/web/components/task/file-browser-actions.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { FileContentResponse, OpenFileTab } from "@/lib/types/backend"; +import type { fetchAndOpenFile as FetchAndOpenFile } from "./file-browser-actions"; + +const getWebSocketClientMock = vi.fn(() => ({})); +const requestFileContentMock = vi.fn(); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => getWebSocketClientMock(), +})); + +vi.mock("@/lib/ws/workspace-files", () => ({ + requestFileTree: vi.fn(), + requestFileContent: (...args: unknown[]) => requestFileContentMock(...args), +})); + +let fetchAndOpenFile: typeof FetchAndOpenFile; +const SESSION_ID = "session-1"; +const FILE_PATH = "src/example.ts"; +const REPO = "frontend"; + +beforeEach(async () => { + vi.resetModules(); + getWebSocketClientMock.mockReset().mockReturnValue({}); + requestFileContentMock.mockReset(); + ({ fetchAndOpenFile } = await import("./file-browser-actions")); +}); + +function defer() { + let resolve: (value: T) => void = () => {}; + let reject: (reason?: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function response(content = "export const value = 1;"): FileContentResponse { + return { + path: FILE_PATH, + content, + is_binary: false, + } as FileContentResponse; +} + +describe("fetchAndOpenFile", () => { + it("opens the requested repo-scoped file when the request is current", async () => { + requestFileContentMock.mockResolvedValueOnce(response()); + const onOpenFile = vi.fn<[OpenFileTab], void>(); + const toast = vi.fn(); + + await fetchAndOpenFile(SESSION_ID, FILE_PATH, onOpenFile, toast, { repo: REPO }); + + expect(requestFileContentMock).toHaveBeenCalledWith( + expect.anything(), + SESSION_ID, + FILE_PATH, + REPO, + ); + expect(onOpenFile).toHaveBeenCalledWith( + expect.objectContaining({ + path: FILE_PATH, + name: "example.ts", + repo: REPO, + content: "export const value = 1;", + isDirty: false, + }), + ); + expect(toast).not.toHaveBeenCalled(); + }); + + it("does not open a stale file after the request is aborted", async () => { + const pending = defer(); + requestFileContentMock.mockReturnValueOnce(pending.promise); + const onOpenFile = vi.fn(); + const toast = vi.fn(); + const controller = new AbortController(); + + const opening = fetchAndOpenFile(SESSION_ID, FILE_PATH, onOpenFile, toast, { + repo: REPO, + signal: controller.signal, + }); + expect(requestFileContentMock).toHaveBeenCalledTimes(1); + + controller.abort(); + pending.resolve(response("stale")); + await opening; + + expect(onOpenFile).not.toHaveBeenCalled(); + expect(toast).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/file-browser-actions.ts b/apps/web/components/task/file-browser-actions.ts index 9bd8f79137..39787b0e18 100644 --- a/apps/web/components/task/file-browser-actions.ts +++ b/apps/web/components/task/file-browser-actions.ts @@ -8,6 +8,11 @@ import { getFilesPanelScrollPosition, setFilesPanelScrollPosition } from "@/lib/ import type { useToast } from "@/components/toast-provider"; import type { useFileBrowserTree } from "./file-browser-hooks"; +export type FetchAndOpenFileOptions = { + repo?: string; + signal?: AbortSignal; +}; + /** Hook for scroll position persistence in the file browser. */ export function useScrollPersistence( sessionId: string, @@ -116,17 +121,23 @@ export async function fetchAndOpenFile( path: string, onOpenFile: (file: OpenFileTab) => void, toast: ReturnType["toast"], + options: FetchAndOpenFileOptions = {}, ) { + const { repo, signal } = options; try { + if (signal?.aborted) return; const client = getWebSocketClient(); if (!client) return; - const response: FileContentResponse = await requestFileContent(client, sessionId, path); + const response: FileContentResponse = await requestFileContent(client, sessionId, path, repo); + if (signal?.aborted) return; const { calculateHash } = await import("@/lib/utils/file-diff"); const hash = await calculateHash(response.content); + if (signal?.aborted) return; const name = path.split("/").pop() || path; onOpenFile({ path, name, + repo, content: response.content, originalContent: response.content, originalHash: hash, @@ -134,6 +145,7 @@ export async function fetchAndOpenFile( isBinary: response.is_binary, }); } catch (error) { + if (signal?.aborted) return; const reason = error instanceof Error ? error.message : "Unknown error"; toast({ title: "Failed to open file", description: reason, variant: "error" }); } diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index 3eca58a553..d5d700ff94 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useMemo, useCallback, useRef, useState } from "react"; +import React, { useEffect, useMemo, useCallback, useLayoutEffect, useRef, useState } from "react"; import { ScrollArea } from "@kandev/ui/scroll-area"; import type { FileTreeNode, OpenFileTab } from "@/lib/types/backend"; import { useSession } from "@/hooks/domains/session/use-session"; @@ -103,6 +103,15 @@ function useFileBrowserHandlers( const { toast } = useToast(); const [creatingInPath, setCreatingInPath] = useState(null); const [activeFolderPath, setActiveFolderPath] = useState(""); + const openFileAbortRef = useRef(null); + + useLayoutEffect( + () => () => { + openFileAbortRef.current?.abort(); + openFileAbortRef.current = null; + }, + [sessionId], + ); const handleStartCreate = useCallback(() => { if (activeFolderPath && !treeState.expandedPaths.has(activeFolderPath)) { @@ -134,7 +143,18 @@ function useFileBrowserHandlers( ); const openFileByPath = useCallback( - (path: string) => fetchAndOpenFile(sessionId, path, onOpenFile, toast), + (path: string) => { + openFileAbortRef.current?.abort(); + const controller = new AbortController(); + openFileAbortRef.current = controller; + return fetchAndOpenFile(sessionId, path, onOpenFile, toast, { + signal: controller.signal, + }).finally(() => { + if (openFileAbortRef.current === controller) { + openFileAbortRef.current = null; + } + }); + }, [sessionId, onOpenFile, toast], ); const handleCancelCreate = useCallback(() => setCreatingInPath(null), []); diff --git a/apps/web/components/task/file-editor-content.tsx b/apps/web/components/task/file-editor-content.tsx index 93e83d53cb..e1f079b349 100644 --- a/apps/web/components/task/file-editor-content.tsx +++ b/apps/web/components/task/file-editor-content.tsx @@ -16,6 +16,7 @@ export type FileEditorContentProps = { isSaving: boolean; sessionId?: string; worktreePath?: string; + repo?: string; enableComments?: boolean; markdownPreview?: boolean; onToggleMarkdownPreview?: () => void; diff --git a/apps/web/components/task/file-editor-panel.tsx b/apps/web/components/task/file-editor-panel.tsx index c62bd6c09f..bc439ccbdf 100644 --- a/apps/web/components/task/file-editor-panel.tsx +++ b/apps/web/components/task/file-editor-panel.tsx @@ -305,6 +305,7 @@ export const FileEditorPanel = memo(function FileEditorPanel({ isSaving={savingFiles.has(fileKey)} sessionId={activeSessionId || undefined} worktreePath={worktreePath} + repo={repo} enableComments={!!activeSessionId} markdownPreview={isMarkdown ? markdownPreview : false} onToggleMarkdownPreview={isMarkdown ? onToggleMarkdownPreview : undefined} diff --git a/apps/web/components/task/file-viewer-content.tsx b/apps/web/components/task/file-viewer-content.tsx index 01a98d77e2..ececbd15a3 100644 --- a/apps/web/components/task/file-viewer-content.tsx +++ b/apps/web/components/task/file-viewer-content.tsx @@ -1,21 +1,23 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import CodeMirror from "@uiw/react-codemirror"; import { vscodeDark } from "@uiw/codemirror-theme-vscode"; import { EditorView } from "@codemirror/view"; import type { Extension } from "@codemirror/state"; import { getCodeMirrorExtensionFromPath } from "@/lib/languages"; import { consumePendingCursorPosition } from "@/hooks/use-file-editors"; +import { useCodeMirrorWalkthroughRange } from "@/components/editors/codemirror/use-codemirror-walkthrough-range"; import { cn } from "@/lib/utils"; type FileViewerContentProps = { path: string; content: string; + repo?: string; className?: string; }; -export function FileViewerContent({ path, content, className }: FileViewerContentProps) { +export function FileViewerContent({ path, content, repo, className }: FileViewerContentProps) { const langExt = getCodeMirrorExtensionFromPath(path); const extensions: Extension[] = [EditorView.lineWrapping, EditorView.editable.of(false)]; if (langExt) { @@ -23,27 +25,39 @@ export function FileViewerContent({ path, content, className }: FileViewerConten } const viewRef = useRef(null); + const [editorView, setEditorView] = useState(null); + const areaRef = useRef(null); + const walkthroughRange = useCodeMirrorWalkthroughRange({ + view: editorView, + editorAreaRef: areaRef, + path, + repo, + }); // Consume a pending cursor position (set by chat read/edit file links before // they open the file) and scroll CodeMirror so the target 1-based line is // centered. No pending entry → no scroll, so normal file browsing stays at // the top. Desktop's Monaco consumes the same entry; on mobile there is no // Monaco, so this CodeMirror viewer must consume it instead. - const scrollToPendingLine = useCallback((view: EditorView, filePath: string) => { - const pending = consumePendingCursorPosition(filePath); - if (!pending) return; - const totalLines = view.state.doc.lines; - const clampedLine = Math.min(Math.max(pending.line, 1), totalLines); - const pos = view.state.doc.line(clampedLine).from; - view.dispatch({ - selection: { anchor: pos }, - effects: EditorView.scrollIntoView(pos, { y: "center" }), - }); - }, []); + const scrollToPendingLine = useCallback( + (view: EditorView, filePath: string) => { + const pending = consumePendingCursorPosition(filePath, repo); + if (!pending) return; + const totalLines = view.state.doc.lines; + const clampedLine = Math.min(Math.max(pending.line, 1), totalLines); + const pos = view.state.doc.line(clampedLine).from; + view.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { y: "center" }), + }); + }, + [repo], + ); const handleCreateEditor = useCallback( (view: EditorView) => { viewRef.current = view; + setEditorView(view); scrollToPendingLine(view, path); }, [path, scrollToPendingLine], @@ -60,28 +74,43 @@ export function FileViewerContent({ path, content, className }: FileViewerConten }, [path, scrollToPendingLine]); return ( - +
+ + {walkthroughRange ? ( + ); } diff --git a/apps/web/components/task/mobile/mobile-changes-panel.tsx b/apps/web/components/task/mobile/mobile-changes-panel.tsx index 31eda1b675..955a79e1ac 100644 --- a/apps/web/components/task/mobile/mobile-changes-panel.tsx +++ b/apps/web/components/task/mobile/mobile-changes-panel.tsx @@ -11,6 +11,7 @@ import { ChangesPanelHeader } from "../changes-panel-header"; import { MobileDiffSheet } from "./mobile-diff-sheet"; import { useReviewSources } from "@/hooks/domains/session/use-review-sources"; import { useAppStore } from "@/components/state-provider"; +import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough"; import type { SelectedDiff } from "../task-layout"; import type { OpenDiffOptions, DiffSheetMode } from "../changes-diff-target"; @@ -34,6 +35,11 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({ const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const { sourceCounts } = useReviewSources(activeSessionId); const [diffSheet, setDiffSheet] = useState(null); + const requestWalkthrough = useRequestChangesWalkthrough({ + taskId: data.activeTaskId, + sessionId: data.activeSessionId, + files: data.walkthroughPromptFiles, + }); // Track the previous selectedDiff to detect changes const prevSelectedDiffRef = useRef(null); @@ -100,6 +106,8 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({ loadingOperation={data.git.loadingOperation} onOpenDiffAll={handleOpenDiffAll} onOpenReview={handleOpenReview} + onRequestWalkthrough={requestWalkthrough} + requestWalkthroughDisabled={data.walkthroughPromptFiles.length === 0} repoNames={data.git.repoNames} perRepoStatus={data.git.perRepoStatus} onRepoPull={data.repoCallbacks.onRepoPull} diff --git a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx index 57d3abec83..652152d3a7 100644 --- a/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx +++ b/apps/web/components/task/mobile/mobile-file-viewer-panel.tsx @@ -89,7 +89,7 @@ export function MobileFileViewerPanel({ file, sessionId, onClose }: MobileFileVi onTogglePreview={() => setMarkdownPreview((current) => !current)} /> ) : ( - + ))}
diff --git a/apps/web/components/task/mobile/session-mobile-layout.test.tsx b/apps/web/components/task/mobile/session-mobile-layout.test.tsx index 0ef5191552..2e141ac6af 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.test.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.test.tsx @@ -29,6 +29,7 @@ const OTHER_FILE: OpenFileTab = { }; const CHAT_LINK_PATH = "src/chat-link.ts"; +const REPO = "frontend"; function renderHandlers(initialSid: string | null = "s1") { const handlePanelChange = vi.fn(); @@ -63,6 +64,7 @@ describe("useMobilePanelHandlers", () => { CHAT_LINK_PATH, expect.any(Function), expect.any(Function), + { repo: undefined, signal: expect.objectContaining({ aborted: false }) }, ); const openFile = fetchAndOpenFileMock.mock.calls[0]?.[2] as (file: OpenFileTab) => void; @@ -72,6 +74,19 @@ describe("useMobilePanelHandlers", () => { expect(handlePanelChange).toHaveBeenCalledWith("files"); }); + it("passes repo through when opening a walkthrough file from mobile", () => { + const { result } = renderHandlers(); + act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH, REPO)); + + expect(fetchAndOpenFileMock).toHaveBeenCalledWith( + "s1", + CHAT_LINK_PATH, + expect.any(Function), + expect.any(Function), + { repo: REPO, signal: expect.objectContaining({ aborted: false }) }, + ); + }); + it("handleOpenFileFromChat no-ops when no active session", () => { const { result } = renderHandlers(null); act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH)); @@ -116,6 +131,7 @@ describe("useMobilePanelHandlers", () => { CHAT_LINK_PATH, expect.any(Function), expect.any(Function), + { repo: undefined, signal: expect.objectContaining({ aborted: false }) }, ); // Simulate session switch before the async callback fires @@ -139,3 +155,31 @@ describe("useMobilePanelHandlers", () => { expect(result.current.selectedFile).toEqual(OTHER_FILE); }); }); + +describe("useMobilePanelHandlers request cancellation", () => { + beforeEach(() => { + fetchAndOpenFileMock.mockReset(); + }); + + it("aborts stale chat file requests when a newer one starts", () => { + const { result } = renderHandlers(); + act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH)); + const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal }; + + act(() => result.current.handleOpenFileFromChat("src/newer.ts")); + const secondOptions = fetchAndOpenFileMock.mock.calls[1]?.[4] as { signal: AbortSignal }; + + expect(firstOptions.signal.aborted).toBe(true); + expect(secondOptions.signal.aborted).toBe(false); + }); + + it("aborts stale chat file requests when the session changes", () => { + const { result, rerender } = renderHandlers(); + act(() => result.current.handleOpenFileFromChat(CHAT_LINK_PATH)); + const firstOptions = fetchAndOpenFileMock.mock.calls[0]?.[4] as { signal: AbortSignal }; + + rerender({ sid: "s2" }); + + expect(firstOptions.signal.aborted).toBe(true); + }); +}); diff --git a/apps/web/components/task/mobile/session-mobile-layout.tsx b/apps/web/components/task/mobile/session-mobile-layout.tsx index a0218c4531..665121b0d2 100644 --- a/apps/web/components/task/mobile/session-mobile-layout.tsx +++ b/apps/web/components/task/mobile/session-mobile-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { SessionMobileTopBar } from "./session-mobile-top-bar"; import { SessionMobileBottomNav } from "./session-mobile-bottom-nav"; import { SessionTaskSwitcherSheet } from "./session-task-switcher-sheet"; @@ -9,6 +9,7 @@ import { TaskChatPanel } from "../task-chat-panel"; import { TaskPlanPanel } from "../task-plan-panel"; import { MobileChangesPanel } from "./mobile-changes-panel"; import { ReviewDialog } from "@/components/review/review-dialog"; +import { WalkthroughOverlay } from "@/components/review/walkthrough-overlay"; import { useReviewDialog } from "../use-review-dialog"; import { TaskFilesPanel } from "../task-files-panel"; import { PassthroughToolbar } from "../passthrough-toolbar"; @@ -51,7 +52,7 @@ function MobileChatPanelContent({ activeTaskId: string | null; isPassthroughMode: boolean; effectiveSessionId: string | null; - onOpenFile: (path: string) => void; + onOpenFile: (path: string, repo?: string) => void; }) { if (!activeTaskId) { return ( @@ -233,23 +234,34 @@ function MobileTopBarSticky(props: MobileTopBarStickyProps) { function MobileReviewDialogMount({ sessionId, review, + activeTaskId, + onSelectWalkthroughFile, }: { sessionId: string | null; review: ReturnType; + activeTaskId: string | null; + onSelectWalkthroughFile: (path: string, repo?: string) => void; }) { if (!sessionId) return null; return ( - + <> + + + ); } @@ -264,6 +276,7 @@ export function useMobilePanelHandlers({ const [selectedFile, setSelectedFile] = useState(null); const [trackedSessionId, setTrackedSessionId] = useState(effectiveSessionId); const latestRequestIdRef = useRef(0); + const openFileAbortRef = useRef(null); // Reset viewer when switching sessions — adjust state during render per // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes @@ -272,24 +285,44 @@ export function useMobilePanelHandlers({ setSelectedFile(null); } - useEffect(() => { + useLayoutEffect(() => { latestRequestIdRef.current += 1; + openFileAbortRef.current?.abort(); + openFileAbortRef.current = null; }, [effectiveSessionId]); + useEffect( + () => () => { + openFileAbortRef.current?.abort(); + openFileAbortRef.current = null; + }, + [], + ); + const handleOpenFileFromChat = useCallback( - (path: string) => { + (path: string, repo?: string) => { if (!effectiveSessionId) return; const requestId = (latestRequestIdRef.current += 1); - void fetchAndOpenFile( - effectiveSessionId, - path, - (file) => { - if (requestId !== latestRequestIdRef.current) return; - setSelectedFile(file); - handlePanelChange("files"); - }, - toast, - ); + openFileAbortRef.current?.abort(); + const controller = new AbortController(); + openFileAbortRef.current = controller; + void Promise.resolve( + fetchAndOpenFile( + effectiveSessionId, + path, + (file) => { + if (requestId !== latestRequestIdRef.current || controller.signal.aborted) return; + setSelectedFile(file); + handlePanelChange("files"); + }, + toast, + { repo, signal: controller.signal }, + ), + ).finally(() => { + if (openFileAbortRef.current === controller) { + openFileAbortRef.current = null; + } + }); }, [effectiveSessionId, handlePanelChange, toast], ); @@ -297,6 +330,8 @@ export function useMobilePanelHandlers({ const handleOpenFile = useCallback( (file: OpenFileTab) => { latestRequestIdRef.current += 1; + openFileAbortRef.current?.abort(); + openFileAbortRef.current = null; setSelectedFile(file); handlePanelChange("files"); }, @@ -306,6 +341,8 @@ export function useMobilePanelHandlers({ const handlePanelChangeAndClearSheet = useCallback( (panel: MobileSessionPanel) => { latestRequestIdRef.current += 1; + openFileAbortRef.current?.abort(); + openFileAbortRef.current = null; setSelectedFile(null); handlePanelChange(panel); }, @@ -415,7 +452,12 @@ export const SessionMobileLayout = memo(function SessionMobileLayout({ workflowId={workflowId} /> - +
); }); diff --git a/apps/web/components/task/passthrough-chat-composer.test.ts b/apps/web/components/task/passthrough-chat-composer.test.ts index e6ce47d45f..45b4f2e213 100644 --- a/apps/web/components/task/passthrough-chat-composer.test.ts +++ b/apps/web/components/task/passthrough-chat-composer.test.ts @@ -48,9 +48,11 @@ function panelState(overrides: Record = {}) { prompts: [], pendingPRFeedback: [], planComments: [], + walkthroughComments: [], planModeEnabled: false, handleClearPRFeedback: vi.fn(), clearSessionPlanComments: vi.fn(), + handleClearWalkthroughComments: vi.fn(), clearEphemeral: vi.fn(), addContextFile: vi.fn(), ...overrides, @@ -208,9 +210,11 @@ describe("passthrough chat composer cleanup", () => { planModeEnabled: true, pendingPRFeedback: [{ id: "feedback-1" }], planComments: [{ id: "plan-comment-1" }], + walkthroughComments: [{ id: "walkthrough-comment-1" }], }) as unknown as { handleClearPRFeedback: ReturnType; clearSessionPlanComments: ReturnType; + handleClearWalkthroughComments: ReturnType; clearEphemeral: ReturnType; addContextFile: ReturnType; }; @@ -219,6 +223,7 @@ describe("passthrough chat composer cleanup", () => { expect(state.handleClearPRFeedback).toHaveBeenCalledTimes(1); expect(state.clearSessionPlanComments).toHaveBeenCalledTimes(1); + expect(state.handleClearWalkthroughComments).toHaveBeenCalledTimes(1); expect(state.clearEphemeral).toHaveBeenCalledWith(SESSION_ID); expect(state.addContextFile).toHaveBeenCalledWith(SESSION_ID, { path: PLAN_CONTEXT_PATH, diff --git a/apps/web/components/task/passthrough-chat-composer.tsx b/apps/web/components/task/passthrough-chat-composer.tsx index 8487c72a08..ced65403cb 100644 --- a/apps/web/components/task/passthrough-chat-composer.tsx +++ b/apps/web/components/task/passthrough-chat-composer.tsx @@ -48,7 +48,9 @@ export function PassthroughComposerPanel({ isSending: boolean; }) { const hasContextComments = - panelState.planComments.length > 0 || panelState.pendingPRFeedback.length > 0; + panelState.planComments.length > 0 || + panelState.pendingPRFeedback.length > 0 || + panelState.walkthroughComments.length > 0; return (
0 || - panelState.planComments.length > 0; + panelState.planComments.length > 0 || + panelState.walkthroughComments.length > 0; if (hasStructuredComments) { return { formatted: buildSubmitMessage( @@ -114,6 +117,7 @@ export function formatPassthroughBaseMessage( commentsToSend.length > 0 ? commentsToSend : undefined, panelState.pendingPRFeedback, panelState.planComments, + panelState.walkthroughComments, ), commentsToSend, }; @@ -248,6 +252,9 @@ export function clearPassthroughComposerContext(panelState: ReturnType 0) { panelState.clearSessionPlanComments(); } + if (panelState.walkthroughComments.length > 0) { + panelState.handleClearWalkthroughComments(); + } if (!panelState.resolvedSessionId) return; panelState.clearEphemeral(panelState.resolvedSessionId); if (panelState.planModeEnabled) { diff --git a/apps/web/components/task/passthrough-toolbar.test.tsx b/apps/web/components/task/passthrough-toolbar.test.tsx index d9ae399cb4..f9d84b0f92 100644 --- a/apps/web/components/task/passthrough-toolbar.test.tsx +++ b/apps/web/components/task/passthrough-toolbar.test.tsx @@ -150,6 +150,7 @@ vi.mock("./chat/use-chat-panel-state", () => ({ prompts: [{ id: "prompt-1", name: "Prompt", content: "Prompt content" }], planComments: [], pendingPRFeedback: [], + walkthroughComments: [], pendingCommentsByFile: mockPendingByFile, }), })); diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index b92cd1c7c9..99d0eb3ea2 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -18,6 +18,7 @@ import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults"; import type { ReviewFile } from "@/components/review/types"; import { hashDiff, reviewFileKey, splitReviewFileKey } from "@/components/review/types"; import { usePanelActions } from "@/hooks/use-panel-actions"; +import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough"; import { ChangesTopBar } from "./changes-top-bar"; import type { SelectedDiff } from "./task-layout"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; @@ -168,6 +169,18 @@ function persistAutoMarkSetting(checked: boolean) { updateUserSettings(payload, { cache: "no-store" }).catch(() => {}); } +function useReviewWalkthroughRequest( + activeSessionId: string | null | undefined, + allFiles: ReviewFile[], +) { + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + return useRequestChangesWalkthrough({ + taskId: activeTaskId, + sessionId: activeSessionId, + files: allFiles, + }); +} + function useChangesActions( activeSessionId: string | null | undefined, allFiles: ReviewFile[], @@ -472,6 +485,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ handleToggleAutoMark, handleFixComments, } = useChangesActions(activeSessionId, allFiles, wordWrapProp); + const handleRequestWalkthrough = useReviewWalkthroughRequest(activeSessionId, allFiles); const { visibleFiles, visibleFileRefs, reviewedCount, totalCount, progressPercent } = useVisibleDiffState({ allFiles, @@ -510,6 +524,8 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ handleToggleSplitView={handleToggleSplitView} handleToggleAutoMark={handleToggleAutoMark} handleFixComments={handleFixComments} + handleRequestWalkthrough={handleRequestWalkthrough} + requestWalkthroughDisabled={allFiles.length === 0} /> diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index dcc457a745..e122f78fe5 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -95,7 +95,12 @@ export const test = backendFixture.extend< fs.mkdirSync(repoDir, { recursive: true }); const gitEnv = makeGitEnv(backend.tmpDir); execSync("git init -b main", { cwd: repoDir, env: gitEnv }); - execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv }); + fs.writeFileSync( + path.join(repoDir, "walkthrough_base.txt"), + "line 1: WALKTHROUGH_UNCHANGED\nline 2: seeded on main\n", + ); + execSync("git add walkthrough_base.txt", { cwd: repoDir, env: gitEnv }); + execSync('git commit -m "init"', { cwd: repoDir, env: gitEnv }); const repo = await apiClient.createRepository(workspace.id, repoDir); // Agent registry seeding (runInitialAgentSetup → discovery) is diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 562a308ee0..96b999e88b 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -438,6 +438,13 @@ export class ApiClient { return this.request("POST", "/api/v1/prompts", { name, content }); } + async updatePrompt( + promptId: string, + patch: { name?: string; content?: string }, + ): Promise<{ id: string; name: string; content: string; builtin: boolean }> { + return this.request("PATCH", `/api/v1/prompts/${promptId}`, patch); + } + async deletePrompt(promptId: string): Promise { await this.request("DELETE", `/api/v1/prompts/${promptId}`); } diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 384517da56..b623d198df 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -830,6 +830,44 @@ export class SessionPage { await this.page.locator('[data-testid^="session-tab-"]').first().click(); } + /** Main Changes-panel button that asks the agent to create a walkthrough. */ + changesRequestWalkthroughButton(): Locator { + return this.changes.getByTestId("changes-request-walkthrough"); + } + + /** Compact request button in the expanded Review Changes toolbar. */ + reviewRequestWalkthroughButton(): Locator { + return this.page.getByTestId("review-request-walkthrough"); + } + + walkthroughLauncher(): Locator { + return this.page.getByTestId("walkthrough-launcher"); + } + + walkthroughDiscardButton(): Locator { + return this.page.getByTestId("walkthrough-discard"); + } + + walkthroughDiscardDialog(): Locator { + return this.page.getByRole("alertdialog", { name: "Discard walkthrough?" }); + } + + walkthroughFloating(): Locator { + return this.page.getByTestId("walkthrough-floating"); + } + + walkthroughStepHeader(): Locator { + return this.walkthroughFloating().getByTestId("walkthrough-step-header"); + } + + walkthroughStepBody(): Locator { + return this.walkthroughFloating().getByTestId("walkthrough-step-body"); + } + + walkthroughEditorRange(): Locator { + return this.page.getByTestId("walkthrough-editor-range"); + } + /** PR files section within the changes panel. */ prFilesSection(): Locator { return this.changes.getByTestId("pr-files-section"); diff --git a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts index 6cf372cf22..09850f4c6a 100644 --- a/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts +++ b/apps/web/e2e/tests/integrations/github-workspace-settings.spec.ts @@ -36,13 +36,13 @@ test.describe("GitHub workspace settings", () => { ]); await testPage.goto(`/settings/workspace/${seedData.workspaceId}/integrations/github`); - await expect(testPage.getByTestId("integration-workspace-switcher")).toHaveCount(0); + await expect(testPage.getByTestId("github-integration-heading")).toBeVisible(); await testPage.getByTestId("github-scope-mode").click(); await testPage.getByRole("option", { name: "Selected repositories" }).click(); await testPage.getByTestId("github-scope-repos-input").fill("kdlbs/kandev"); await testPage.getByTestId("github-scope-save").click(); - await expect(testPage.getByText("GitHub workspace settings saved")).toBeVisible({ + await expect(testPage.getByText("GitHub workspace settings saved").last()).toBeVisible({ timeout: 10_000, }); @@ -79,7 +79,7 @@ test.describe("GitHub workspace settings", () => { await apiClient.mockGitHubSetUser("test-user"); await testPage.goto("/settings/integrations/github"); - await expect(testPage.getByTestId("integration-workspace-switcher")).toBeVisible(); + await expect(testPage.getByTestId("github-integration-heading")).toBeVisible(); await testPage.getByTestId("github-scope-mode").click(); await testPage.getByRole("option", { name: "Selected repositories" }).click(); @@ -88,7 +88,7 @@ test.describe("GitHub workspace settings", () => { await testPage.getByRole("option", { name: "Organizations" }).click(); await testPage.getByTestId("github-scope-orgs-input").fill("kdlbs"); await testPage.getByTestId("github-scope-save").click(); - await expect(testPage.getByText("GitHub workspace settings saved")).toBeVisible({ + await expect(testPage.getByText("GitHub workspace settings saved").last()).toBeVisible({ timeout: 10_000, }); @@ -112,7 +112,7 @@ test.describe("GitHub workspace settings", () => { await testPage.getByTestId("github-scope-mode").click(); await testPage.getByRole("option", { name: "Organizations" }).click(); await testPage.getByTestId("github-scope-save").click(); - await expect(testPage.getByText("GitHub workspace settings saved")).toBeVisible({ + await expect(testPage.getByText("GitHub workspace settings saved").last()).toBeVisible({ timeout: 10_000, }); diff --git a/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts b/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts new file mode 100644 index 0000000000..5dd1b094b2 --- /dev/null +++ b/apps/web/e2e/tests/review/mobile-walkthrough.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import type { Page } from "@playwright/test"; + +async function seedWalkthroughTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + scenario = "walkthrough-basic", + doneText = "5-step tour", +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile Walkthrough E2E", + seedData.agentProfileId, + { + description: `/e2e:${scenario}`, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText(doneText, { exact: false })).toBeVisible({ + timeout: 45_000, + }); + await session.waitForChatIdle(); +} + +test.describe("Mobile code walkthrough", () => { + test.describe.configure({ retries: 2, timeout: 120_000 }); + + test("opens the walkthrough as a bottom-sheet panel", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData); + + await testPage.getByTestId("walkthrough-launcher").click(); + const card = testPage.getByTestId("walkthrough-floating"); + await expect(card).toBeVisible({ timeout: 30_000 }); + await expect(card).toHaveAttribute("data-mobile-variant", "bottom-sheet"); + + const box = await card.boundingBox(); + const viewport = testPage.viewportSize(); + if (!box || !viewport) throw new Error("walkthrough geometry unavailable"); + + expect(box.x).toBeLessThanOrEqual(12); + expect(box.width).toBeGreaterThanOrEqual(viewport.width - 24); + expect(box.y + box.height).toBeGreaterThanOrEqual(viewport.height - 16); + + await expect(testPage.getByTestId("walkthrough-editor-range")).toBeVisible({ + timeout: 15_000, + }); + }); + + test("requests a walkthrough from Changes and opens the generated bottom sheet", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-setup", "changes ready"); + + await testPage.getByRole("button", { name: "Changes" }).click(); + const changes = testPage.getByTestId("mobile-changes-panel"); + await expect(changes).toBeVisible({ timeout: 15_000 }); + const request = changes.getByTestId("changes-request-walkthrough"); + await expect(request).toBeEnabled({ timeout: 30_000 }); + await request.click(); + + await testPage.getByRole("button", { name: "Chat" }).click(); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.activeChat()).toContainText("Walkthrough: Tour of the change", { + timeout: 45_000, + }); + await expect(session.activeChat()).toContainText("walkthrough-request complete", { + timeout: 45_000, + }); + + await session.walkthroughLauncher().click(); + const card = session.walkthroughFloating(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await expect(card).toHaveAttribute("data-mobile-variant", "bottom-sheet"); + await expect(session.walkthroughEditorRange()).toBeVisible({ timeout: 15_000 }); + }); + + test("can discard a walkthrough from the touch launcher", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData); + const session = new SessionPage(testPage); + + await expect(session.walkthroughLauncher()).toBeVisible({ timeout: 30_000 }); + await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 }); + await session.walkthroughDiscardButton().click(); + await expect(session.walkthroughDiscardDialog()).toBeVisible(); + await session + .walkthroughDiscardDialog() + .getByRole("button", { name: "Discard walkthrough" }) + .click(); + + await expect(session.walkthroughLauncher()).toHaveCount(0); + await expect(session.walkthroughFloating()).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/review/walkthrough.spec.ts b/apps/web/e2e/tests/review/walkthrough.spec.ts new file mode 100644 index 0000000000..8268929fe3 --- /dev/null +++ b/apps/web/e2e/tests/review/walkthrough.spec.ts @@ -0,0 +1,346 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; +import type { ApiClient } from "../../helpers/api-client"; +import type { SeedData } from "../../fixtures/test-base"; +import type { Page, Locator } from "@playwright/test"; + +async function seedWalkthroughTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + scenario: string, + doneText: string, +): Promise { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Walkthrough E2E", + seedData.agentProfileId, + { + description: `/e2e:${scenario}`, + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.chat.getByText(doneText, { exact: false })).toBeVisible({ timeout: 45_000 }); + await session.waitForChatIdle(); + return session; +} + +async function seedWalkthroughChangesTask( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, +): Promise { + return seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-setup", "changes ready"); +} + +async function customizeChangesWalkthroughPrompt( + apiClient: ApiClient, +): Promise<() => Promise> { + const { prompts } = await apiClient.listPrompts(); + const prompt = prompts.find((p) => p.name === "changes-walkthrough"); + expect(prompt, "changes-walkthrough built-in prompt should be seeded").toBeTruthy(); + const originalContent = prompt!.content; + await apiClient.updatePrompt(prompt!.id, { + content: [ + "E2E_CUSTOM_CHANGES_WALKTHROUGH", + "Please create an agent-authored walkthrough of the current changes using `show_walkthrough_kandev`.", + "", + "Walkthrough requirements:", + "- Use `line_end` whenever a logical explanation spans multiple lines.", + "- For PR-only files, do not assume the PR head is checked out locally.", + "- Keep each step concise and direct. Do not include a `Justification:` preamble.", + "", + "Available changed files:", + "{{changed_files}}", + ].join("\n"), + }); + return () => apiClient.updatePrompt(prompt!.id, { content: originalContent }); +} + +/** Open the floating walkthrough card via the launcher pill. */ +async function openWalkthrough(testPage: Page): Promise { + const launcher = testPage.getByTestId("walkthrough-launcher"); + await expect(launcher).toBeVisible({ timeout: 30_000 }); + await launcher.click(); + const card = testPage.getByTestId("walkthrough-floating"); + await expect(card).toBeVisible({ timeout: 30_000 }); + return card; +} + +test.describe("Code walkthrough", () => { + test.describe.configure({ retries: 2, timeout: 120_000 }); + + test("Changes-panel request asks the agent to walk through current changes", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedWalkthroughChangesTask(testPage, apiClient, seedData); + const restorePrompt = await customizeChangesWalkthroughPrompt(apiClient); + try { + await expect(session.walkthroughLauncher()).toHaveCount(0); + await session.clickTab("Changes"); + const request = session.changesRequestWalkthroughButton(); + await expect(request).toBeVisible({ timeout: 15_000 }); + await expect(request).toContainText("Walkthrough"); + await expect(request).toBeEnabled({ timeout: 30_000 }); + + await request.click(); + await session.showSessionContext(); + + await expect(session.activeChat()).toContainText("@changes-walkthrough", { + timeout: 15_000, + }); + await expect(session.activeChat()).toContainText("Changed files:"); + await expect(session.activeChat()).toContainText("walkthrough_a.txt ["); + await expect(session.activeChat()).not.toContainText("E2E_CUSTOM_CHANGES_WALKTHROUGH"); + await expect(session.activeChat()).toContainText("Walkthrough: Tour of the change", { + timeout: 45_000, + }); + await expect(session.activeChat().locator(".tabler-icon-route")).toBeVisible(); + await expect(session.activeChat()).toContainText("walkthrough-request complete", { + timeout: 45_000, + }); + + const card = await openWalkthrough(testPage); + await expect(card.getByTestId("walkthrough-step-header")).toContainText("Step 1 / 5"); + await expect(card.getByTestId("walkthrough-step-body")).toContainText("Step 1"); + await expect(session.walkthroughEditorRange()).toBeVisible({ timeout: 15_000 }); + } finally { + await restorePrompt(); + } + }); + + test("expanded review toolbar sends the saved walkthrough prompt reference", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedWalkthroughChangesTask(testPage, apiClient, seedData); + const restorePrompt = await customizeChangesWalkthroughPrompt(apiClient); + try { + await session.clickTab("Changes"); + + await session.changes.getByRole("button", { name: "Review" }).click(); + const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + + const request = session.reviewRequestWalkthroughButton(); + await expect(request).toBeVisible(); + await expect(request).toBeEnabled({ timeout: 30_000 }); + await expect(request).toHaveAttribute("aria-label", "Walk me through these review changes"); + await request.click(); + + await testPage.keyboard.press("Escape"); + await expect(dialog).toBeHidden({ timeout: 15_000 }); + await session.showSessionContext(); + + await expect(session.activeChat()).toContainText("@changes-walkthrough", { + timeout: 15_000, + }); + await expect(session.activeChat()).toContainText("Changed files:"); + await expect(session.activeChat()).toContainText("walkthrough_a.txt ["); + await expect(session.activeChat()).not.toContainText("E2E_CUSTOM_CHANGES_WALKTHROUGH"); + await expect(session.activeChat()).toContainText("walkthrough-request complete", { + timeout: 45_000, + }); + } finally { + await restorePrompt(); + } + }); + + test("floating card walks all steps across changed and unchanged files", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-basic", "5-step tour"); + const card = await openWalkthrough(testPage); + const header = card.getByTestId("walkthrough-step-header"); + const body = card.getByTestId("walkthrough-step-body"); + + await expect(header).toContainText("Step 1 / 5"); + await expect(body).toContainText("Step 1"); + await expect(card.getByTestId("walkthrough-step-file")).toContainText("walkthrough_a.txt"); + await expect(card.getByTestId("walkthrough-prev")).toBeDisabled(); + + const expectStep = async (n: number, text: string) => { + await card.getByTestId("walkthrough-next").click(); + await expect(header).toContainText(`Step ${n} / 5`); + await expect(body).toContainText(text); + }; + await expectStep(2, "WALKTHROUGH_CHANGE_A"); + await expectStep(3, "WALKTHROUGH_CHANGE_B"); + await expectStep(4, "WALKTHROUGH_CHANGE_C"); + await expectStep(5, "WALKTHROUGH_UNCHANGED"); + await expect(card.getByTestId("walkthrough-next")).toBeDisabled(); + + // The unchanged/base file is opened in an editor tab, but it does not belong + // in the Review diff because it was not changed by this task. + await expect(testPage.locator('.dv-default-tab:has-text("walkthrough_base.txt")')).toBeVisible({ + timeout: 15_000, + }); + await testPage.evaluate(() => window.dispatchEvent(new CustomEvent("open-review-dialog"))); + const dialog = testPage.getByRole("dialog", { name: "Review Changes" }); + await expect(dialog).toBeVisible({ timeout: 15_000 }); + await expect( + dialog.getByTestId("review-dialog-sidebar").getByText("walkthrough_base.txt"), + ).toHaveCount(0); + await expect( + dialog.getByTestId("changes-repo-group").getByText("walkthrough_base.txt"), + ).toHaveCount(0); + }); + + test("editor walkthrough shows range marker, connector, and supports dragging", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-basic", "5-step tour"); + const card = await openWalkthrough(testPage); + + await expect(testPage.getByTestId("walkthrough-editor-range")).toBeVisible({ + timeout: 15_000, + }); + await expect(testPage.getByTestId("walkthrough-connector")).toBeVisible({ timeout: 15_000 }); + + await card.getByTestId("walkthrough-next").click(); + await expect(card.getByTestId("walkthrough-step-header")).toContainText("Step 2 / 5"); + await expect(testPage.getByTestId("walkthrough-editor-range")).toHaveAttribute( + "data-line-range", + "2-3", + { timeout: 15_000 }, + ); + + const before = await card.boundingBox(); + if (!before) throw new Error("walkthrough card missing before drag"); + const dragHandle = card.getByTestId("walkthrough-drag-handle"); + const handleBox = await dragHandle.boundingBox(); + if (!handleBox) throw new Error("walkthrough drag handle missing"); + await testPage.mouse.move( + handleBox.x + handleBox.width / 2, + handleBox.y + handleBox.height / 2, + ); + await testPage.mouse.down(); + await testPage.mouse.move(handleBox.x - 120, handleBox.y + 80, { steps: 6 }); + await testPage.mouse.up(); + + const after = await card.boundingBox(); + if (!after) throw new Error("walkthrough card missing after drag"); + expect(Math.abs(after.x - before.x)).toBeGreaterThan(40); + expect(Math.abs(after.y - before.y)).toBeGreaterThan(30); + }); + + test("step file label is shown and opens the file", async ({ testPage, apiClient, seedData }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-basic", "5-step tour"); + const card = await openWalkthrough(testPage); + + const fileLabel = card.getByTestId("walkthrough-step-file"); + await expect(fileLabel).toContainText("walkthrough_a.txt"); + await fileLabel.click(); + await expect(testPage.locator('.dv-default-tab:has-text("walkthrough_a.txt")')).toBeVisible({ + timeout: 15_000, + }); + }); + + test("ask box offers Add (queue) and Run (ask now)", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-basic", "5-step tour"); + const card = await openWalkthrough(testPage); + + await card.getByRole("textbox").fill("Why does this line exist?"); + await expect(card.getByRole("button", { name: "Add" })).toBeEnabled(); + await expect(card.getByRole("button", { name: "Run" })).toBeEnabled(); + await card.getByRole("button", { name: "Run" }).click(); + await expect(card.getByRole("textbox")).toHaveValue(""); + }); + + test("closing minimizes the card but keeps the launcher; reopen restores it", async ({ + testPage, + apiClient, + seedData, + }) => { + await seedWalkthroughTask(testPage, apiClient, seedData, "walkthrough-basic", "5-step tour"); + const card = await openWalkthrough(testPage); + const range = testPage.getByTestId("walkthrough-editor-range"); + await expect(range).toBeVisible({ timeout: 15_000 }); + + await card.getByTestId("walkthrough-close").click(); + await expect(card).toBeHidden({ timeout: 5_000 }); + await expect(range).toHaveCount(0); + // The launcher persists — the tour is not lost, just minimized. + const launcher = testPage.getByTestId("walkthrough-launcher"); + await expect(launcher).toBeVisible(); + await launcher.click(); + await expect(testPage.getByTestId("walkthrough-floating")).toBeVisible({ timeout: 10_000 }); + await expect(range).toBeVisible({ timeout: 15_000 }); + }); + + test("discard removes the persisted walkthrough after confirmation", async ({ + testPage, + apiClient, + seedData, + }) => { + const session = await seedWalkthroughTask( + testPage, + apiClient, + seedData, + "walkthrough-basic", + "5-step tour", + ); + const card = await openWalkthrough(testPage); + await expect(session.walkthroughEditorRange()).toBeVisible({ timeout: 15_000 }); + + await session.walkthroughLauncher().hover(); + await expect(session.walkthroughDiscardButton()).toBeVisible({ timeout: 5_000 }); + await session.walkthroughDiscardButton().click(); + await expect(session.walkthroughDiscardDialog()).toBeVisible(); + await session.walkthroughDiscardDialog().getByRole("button", { name: "Cancel" }).click(); + await expect(card).toBeVisible(); + await expect(session.walkthroughLauncher()).toHaveCount(1); + + await session.walkthroughLauncher().hover(); + await session.walkthroughDiscardButton().click(); + await expect(session.walkthroughDiscardDialog()).toBeVisible(); + await session + .walkthroughDiscardDialog() + .getByRole("button", { name: "Discard walkthrough" }) + .click(); + + await expect(session.walkthroughDiscardDialog()).toBeHidden({ timeout: 10_000 }); + await expect(session.walkthroughLauncher()).toHaveCount(0); + await expect(session.walkthroughFloating()).toHaveCount(0); + await expect(session.walkthroughEditorRange()).toHaveCount(0); + }); + + test("a re-emitted walkthrough replaces the previous one without a page reload", async ({ + testPage, + apiClient, + seedData, + }) => { + // The agent emits a 2-step tour, then a different 3-step tour. Opening the + // card refetches the latest, so the re-emit shows without reloading. + await seedWalkthroughTask( + testPage, + apiClient, + seedData, + "walkthrough-reemit", + "reemit-second-done", + ); + const card = await openWalkthrough(testPage); + + await expect(testPage.getByTestId("walkthrough-launcher")).toHaveCount(1); + await expect(card.getByTestId("walkthrough-step-header")).toContainText("Step 1 / 3"); + await expect(card.getByTestId("walkthrough-step-body")).toContainText("REEMIT_SECOND"); + await expect(card.getByTestId("walkthrough-step-body")).not.toContainText("REEMIT_FIRST"); + }); +}); diff --git a/apps/web/hooks/domains/comments/use-pending-comments.ts b/apps/web/hooks/domains/comments/use-pending-comments.ts index 8fe294903f..94b9c2009e 100644 --- a/apps/web/hooks/domains/comments/use-pending-comments.ts +++ b/apps/web/hooks/domains/comments/use-pending-comments.ts @@ -5,13 +5,20 @@ import type { DiffComment, PlanComment, PRFeedbackComment, + WalkthroughComment, +} from "@/lib/state/slices/comments"; +import { + isDiffComment, + isPlanComment, + isPRFeedbackComment, + isWalkthroughComment, } from "@/lib/state/slices/comments"; -import { isDiffComment, isPlanComment, isPRFeedbackComment } from "@/lib/state/slices/comments"; const EMPTY_COMMENTS: Comment[] = []; const EMPTY_DIFF_COMMENTS: DiffComment[] = []; const EMPTY_PLAN_COMMENTS: PlanComment[] = []; const EMPTY_PR_FEEDBACK_COMMENTS: PRFeedbackComment[] = []; +const EMPTY_WALKTHROUGH_COMMENTS: WalkthroughComment[] = []; /** * Get all pending comments (any source). @@ -94,3 +101,25 @@ export function usePendingPRFeedback(sessionId?: string | null): PRFeedbackComme return pending.length === 0 ? EMPTY_PR_FEEDBACK_COMMENTS : pending; }, [byId, pendingForChat, sessionId]); } + +/** + * Get all pending walkthrough comments. + * If sessionId is provided, only returns comments belonging to that session. + */ +export function usePendingWalkthroughComments(sessionId?: string | null): WalkthroughComment[] { + const byId = useCommentsStore((state) => state.byId); + const pendingForChat = useCommentsStore((state) => state.pendingForChat); + + return useMemo(() => { + if (pendingForChat.length === 0) return EMPTY_WALKTHROUGH_COMMENTS; + const pending: WalkthroughComment[] = []; + for (const id of pendingForChat) { + const comment = byId[id]; + if (comment && isWalkthroughComment(comment)) { + if (sessionId && comment.sessionId !== sessionId) continue; + pending.push(comment); + } + } + return pending.length === 0 ? EMPTY_WALKTHROUGH_COMMENTS : pending; + }, [byId, pendingForChat, sessionId]); +} diff --git a/apps/web/hooks/domains/comments/use-run-comment.test.ts b/apps/web/hooks/domains/comments/use-run-comment.test.ts index b868df8f09..f25dfa4703 100644 --- a/apps/web/hooks/domains/comments/use-run-comment.test.ts +++ b/apps/web/hooks/domains/comments/use-run-comment.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; -import type { DiffComment, PlanComment } from "@/lib/state/slices/comments"; +import type { DiffComment, PlanComment, WalkthroughComment } from "@/lib/state/slices/comments"; // --------------------------------------------------------------------------- // Mocks — declared before imports that use them @@ -34,6 +34,8 @@ vi.mock("@/lib/state/slices/comments/format", () => ({ formatReviewCommentsAsMarkdown: (comments: DiffComment[]) => `[diff] ${comments[0]?.text ?? ""}`, formatPlanCommentsAsMarkdown: (comments: PlanComment[]) => `[plan] ${comments[0]?.text ?? ""}`, formatPRFeedbackAsMarkdown: () => "[pr-feedback]", + formatWalkthroughCommentsAsMarkdown: (comments: WalkthroughComment[]) => + `[walkthrough] ${comments[0]?.text ?? ""}`, })); import { useRunComment } from "./use-run-comment"; @@ -82,6 +84,26 @@ function makePlanComment(text = "split step 2"): PlanComment { }; } +function makeWalkthroughComment(text = "explain this step"): WalkthroughComment { + return { + id: "c-3", + source: "walkthrough", + sessionId: "sess-1", + taskId: "task-1", + walkthroughId: "wt-1", + walkthroughTitle: "Tour", + stepIndex: 0, + stepCount: 2, + filePath: "src/app.ts", + startLine: 10, + endLine: 12, + stepText: "Agent explanation", + text, + createdAt: new Date().toISOString(), + status: "pending", + }; +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -223,6 +245,20 @@ describe("useRunComment — busy agent queues", () => { expect(mockAppendToQueue).toHaveBeenCalled(); expect(mockRequest).not.toHaveBeenCalled(); }); + + it("formats walkthrough comments when queuing", async () => { + mockStoreState = makeStoreState("RUNNING"); + const { result } = renderCommentHook(); + + await act(async () => { + await result.current.runComment(makeWalkthroughComment("please expand")); + }); + + expect(mockAppendToQueue).toHaveBeenCalledWith( + expect.objectContaining({ content: "[walkthrough] please expand" }), + ); + expect(mockMarkCommentsSent).toHaveBeenCalledWith(["c-3"]); + }); }); describe("useRunComment — edge cases", () => { diff --git a/apps/web/hooks/domains/comments/use-run-comment.ts b/apps/web/hooks/domains/comments/use-run-comment.ts index 4946983214..f07a4028ef 100644 --- a/apps/web/hooks/domains/comments/use-run-comment.ts +++ b/apps/web/hooks/domains/comments/use-run-comment.ts @@ -7,6 +7,7 @@ import { formatReviewCommentsAsMarkdown, formatPlanCommentsAsMarkdown, formatPRFeedbackAsMarkdown, + formatWalkthroughCommentsAsMarkdown, } from "@/lib/state/slices/comments/format"; import type { Comment, @@ -14,6 +15,7 @@ import type { PlanComment, FileEditorComment, PRFeedbackComment, + WalkthroughComment, } from "@/lib/state/slices/comments"; import type { Message } from "@/lib/types/http"; @@ -28,6 +30,8 @@ function formatSingleComment(comment: Comment): string { return formatPlanCommentsAsMarkdown([comment as PlanComment]); case "pr-feedback": return formatPRFeedbackAsMarkdown([comment as PRFeedbackComment]); + case "walkthrough": + return formatWalkthroughCommentsAsMarkdown([comment as WalkthroughComment]); case "file-editor": { const fc = comment as FileEditorComment; const lines: string[] = ["### File Comment", ""]; diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts new file mode 100644 index 0000000000..36f1ce3695 --- /dev/null +++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts @@ -0,0 +1,144 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockRequest = vi.fn(); +const mockQueueMessage = vi.fn(); +const mockAddMessage = vi.fn(); +const mockToast = vi.fn(); +const mockListPrompts = vi.fn(); +const mockGetWebSocketClient = vi.fn(() => ({ request: mockRequest })); +type MockStoreState = ReturnType; +let mockStoreState: MockStoreState; + +vi.mock("@/components/state-provider", () => ({ + useAppStoreApi: () => ({ getState: () => mockStoreState }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +vi.mock("@/lib/api/domains/queue-api", () => ({ + queueMessage: (...args: unknown[]) => mockQueueMessage(...args), +})); + +vi.mock("@/lib/api", () => ({ + listPrompts: (...args: unknown[]) => mockListPrompts(...args), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => mockGetWebSocketClient(), +})); + +import { useRequestChangesWalkthrough } from "./use-request-changes-walkthrough"; + +function storeState(sessionState: string, planMode = false) { + return { + taskSessions: { items: { "session-1": { state: sessionState } } }, + chatInput: { planModeBySessionId: { "session-1": planMode } }, + addMessage: mockAddMessage, + }; +} + +function renderRequestHook(files = [{ path: "src/app.ts", source: "uncommitted" }]) { + return renderHook(() => + useRequestChangesWalkthrough({ + taskId: "task-1", + sessionId: "session-1", + files, + }), + ); +} + +describe("useRequestChangesWalkthrough", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStoreState = storeState("WAITING_FOR_INPUT"); + mockListPrompts.mockResolvedValue({ + prompts: [ + { + id: "builtin-changes-walkthrough", + name: "changes-walkthrough", + content: "CUSTOM_WALKTHROUGH_PROMPT\n{{changed_files}}\nshow_walkthrough_kandev", + builtin: true, + }, + ], + }); + }); + + it("sends a walkthrough request directly when the agent is waiting", async () => { + mockRequest.mockResolvedValueOnce({ + id: "msg-1", + session_id: "session-1", + task_id: "task-1", + type: "message", + author_type: "user", + content: "prompt", + created_at: "2026-07-07T00:00:00Z", + }); + const { result } = renderRequestHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockRequest).toHaveBeenCalledWith( + "message.add", + expect.objectContaining({ + task_id: "task-1", + session_id: "session-1", + content: expect.stringMatching(/^@changes-walkthrough\n\nChanged files:/), + }), + 10000, + ); + const sentContent = mockRequest.mock.calls[0]?.[1]?.content as string; + expect(sentContent).toContain(""); + expect(sentContent).toContain("CUSTOM_WALKTHROUGH_PROMPT"); + expect(mockListPrompts).toHaveBeenCalledWith({ cache: "no-store" }); + expect(mockAddMessage).toHaveBeenCalledWith(expect.objectContaining({ id: "msg-1" })); + expect(mockQueueMessage).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Walkthrough request sent" }), + ); + }); + + it("queues a walkthrough request when the agent is running", async () => { + mockStoreState = storeState("RUNNING", true); + const { result } = renderRequestHook(); + + await act(async () => { + await result.current(); + }); + + expect(mockQueueMessage).toHaveBeenCalledWith( + expect.objectContaining({ + session_id: "session-1", + task_id: "task-1", + plan_mode: true, + content: expect.stringMatching(/^@changes-walkthrough\n\nChanged files:/), + }), + ); + const queuedContent = mockQueueMessage.mock.calls[0]?.[0]?.content as string; + expect(queuedContent).toContain(""); + expect(queuedContent).toContain("CUSTOM_WALKTHROUGH_PROMPT"); + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Walkthrough request queued" }), + ); + }); + + it("does not send a low-context prompt before changed files are loaded", async () => { + const { result } = renderRequestHook([]); + + await act(async () => { + await result.current(); + }); + + expect(mockRequest).not.toHaveBeenCalled(); + expect(mockQueueMessage).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Changes are still loading", variant: "error" }), + ); + expect(mockListPrompts).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts new file mode 100644 index 0000000000..ac0a003851 --- /dev/null +++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts @@ -0,0 +1,115 @@ +import { useCallback } from "react"; +import { useAppStoreApi } from "@/components/state-provider"; +import { useToast } from "@/components/toast-provider"; +import { listPrompts } from "@/lib/api"; +import { queueMessage } from "@/lib/api/domains/queue-api"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { + buildChangesWalkthroughPrompt, + CHANGES_WALKTHROUGH_PROMPT_NAME, + type WalkthroughPromptFile, +} from "@/lib/walkthrough-request"; +import type { Message } from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; + +type UseRequestChangesWalkthroughParams = { + taskId: string | null | undefined; + sessionId: string | null | undefined; + files: WalkthroughPromptFile[]; +}; + +function isAgentBusy(state: string | undefined): boolean { + return state === "STARTING" || state === "RUNNING"; +} + +function planModePayload(enabled: boolean): { plan_mode?: true } { + return enabled ? { plan_mode: true } : {}; +} + +async function loadChangesWalkthroughPromptTemplate(): Promise { + const { prompts } = await listPrompts({ cache: "no-store" }); + const prompt = prompts.find((p) => p.name === CHANGES_WALKTHROUGH_PROMPT_NAME); + const content = prompt?.content?.trim(); + if (!content) { + throw new Error(`${CHANGES_WALKTHROUGH_PROMPT_NAME} prompt is not available`); + } + return content; +} + +async function queueWalkthroughRequest(params: { + taskId: string; + sessionId: string; + content: string; + planModeEnabled: boolean; +}) { + await queueMessage({ + session_id: params.sessionId, + task_id: params.taskId, + content: params.content, + ...planModePayload(params.planModeEnabled), + }); +} + +async function sendWalkthroughRequest(params: { + taskId: string; + sessionId: string; + content: string; + planModeEnabled: boolean; + state: AppState; +}) { + const client = getWebSocketClient(); + if (!client) throw new Error("WebSocket client unavailable"); + const created = await client.request( + "message.add", + { + task_id: params.taskId, + session_id: params.sessionId, + content: params.content, + ...planModePayload(params.planModeEnabled), + }, + 10000, + ); + if (created?.id && created.session_id) params.state.addMessage(created); +} + +export function useRequestChangesWalkthrough({ + taskId, + sessionId, + files, +}: UseRequestChangesWalkthroughParams) { + const storeApi = useAppStoreApi(); + const { toast } = useToast(); + + return useCallback(async () => { + if (!taskId || !sessionId) return; + + const state = storeApi.getState(); + const activeSession = state.taskSessions.items[sessionId] ?? null; + const shouldQueue = isAgentBusy(activeSession?.state); + const planModeEnabled = state.chatInput.planModeBySessionId[sessionId] ?? false; + if (files.length === 0) { + toast({ title: "Changes are still loading", variant: "error" }); + return; + } + try { + const template = await loadChangesWalkthroughPromptTemplate(); + const content = buildChangesWalkthroughPrompt(template, files); + if (shouldQueue) { + await queueWalkthroughRequest({ + taskId, + sessionId, + content, + planModeEnabled, + }); + toast({ title: "Walkthrough request queued", variant: "success" }); + return; + } + + await sendWalkthroughRequest({ taskId, sessionId, content, planModeEnabled, state }); + toast({ title: "Walkthrough request sent", variant: "success" }); + } catch (error) { + console.error("Failed to request walkthrough:", error); + toast({ title: "Failed to request walkthrough", variant: "error" }); + } + }, [files, sessionId, storeApi, taskId, toast]); +} diff --git a/apps/web/hooks/domains/session/use-session-git.ts b/apps/web/hooks/domains/session/use-session-git.ts index 8cfd752ea9..b67c95eeec 100644 --- a/apps/web/hooks/domains/session/use-session-git.ts +++ b/apps/web/hooks/domains/session/use-session-git.ts @@ -62,6 +62,7 @@ export type SessionGit = { commitsLoading: boolean; // Derived state — single source of truth for all git-dependent UI + statusLoaded: boolean; hasUnstaged: boolean; hasStaged: boolean; hasCommits: boolean; @@ -660,6 +661,7 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit } = useFileDerivations(statusByRepo, gitStatus); usePerRepoPendingClear(statusByRepo, allFiles, setPendingStageFiles); const ahead = gitStatus?.ahead ?? 0; + const statusLoaded = Boolean(gitStatus || statusByRepo.length > 0); const hasUnstaged = unstagedFiles.length > 0; const hasStaged = stagedFiles.length > 0; const hasCommits = commits.length > 0; @@ -694,6 +696,7 @@ export function useSessionGit(sessionId: string | null | undefined): SessionGit cumulativeDiff, commitsLoading: commitsLoading ?? false, + statusLoaded, hasUnstaged, hasStaged, hasCommits, diff --git a/apps/web/hooks/file-editor-cursor.ts b/apps/web/hooks/file-editor-cursor.ts new file mode 100644 index 0000000000..2394204b19 --- /dev/null +++ b/apps/web/hooks/file-editor-cursor.ts @@ -0,0 +1,77 @@ +import { getMonacoInstance } from "@/components/editors/monaco/monaco-init"; +import { walkthroughFileMatches } from "@/lib/diff/walkthrough-match"; +import { buildRepoScopedItemId } from "@/lib/state/dockview-panel-actions"; + +const pendingCursorPositions = new Map(); + +function pendingCursorKey(path: string, repo?: string): string { + return buildRepoScopedItemId(path, repo); +} + +export function setPendingCursorPosition( + path: string, + line: number, + column: number, + repo?: string, +) { + pendingCursorPositions.set(pendingCursorKey(path, repo), { line, column }); +} + +export function consumePendingCursorPosition( + path: string, + repo?: string, +): { line: number; column: number } | undefined { + const key = pendingCursorKey(path, repo); + const pos = pendingCursorPositions.get(key); + if (pos) pendingCursorPositions.delete(key); + return pos; +} + +function pathSegments(path: string): string[] { + return path.trim().replaceAll("\\", "/").split("/").filter(Boolean); +} + +function repoScopedModelMatches(modelPath: string, repo: string | undefined, path: string) { + const repoSegments = pathSegments(repo ?? ""); + if (repoSegments.length === 0) return false; + const modelSegments = pathSegments(modelPath); + const targetSegments = [...repoSegments, ...pathSegments(path)]; + if (targetSegments.length > modelSegments.length) return false; + const offset = modelSegments.length - targetSegments.length; + return targetSegments.every((segment, index) => modelSegments[offset + index] === segment); +} + +function editorModelMatches(modelPath: string, monacoPath: string, path: string, repo?: string) { + const exactMatch = modelPath === `/${monacoPath}` || modelPath === monacoPath; + if (repo) return repoScopedModelMatches(modelPath, repo, path); + return exactMatch || walkthroughFileMatches(modelPath, path); +} + +export function scrollEditorIfMounted( + path: string, + worktreePath: string | null, + line: number, + column: number, + repo?: string, +): boolean { + const monaco = getMonacoInstance(); + if (!monaco) return false; + + const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; + for (const editor of monaco.editor.getEditors()) { + const model = editor.getModel(); + if (!model) continue; + const modelPath = model.uri.path; + const matches = worktreePath + ? modelPath === `/${monacoPath}` || modelPath === monacoPath + : editorModelMatches(modelPath, monacoPath, path, repo); + if (matches) { + consumePendingCursorPosition(path, repo); + editor.setPosition({ lineNumber: line, column }); + editor.revealLineInCenter(line); + editor.focus(); + return true; + } + } + return false; +} diff --git a/apps/web/hooks/use-file-editors.test.tsx b/apps/web/hooks/use-file-editors.test.tsx index 900cf0ed31..8d3eb2fd44 100644 --- a/apps/web/hooks/use-file-editors.test.tsx +++ b/apps/web/hooks/use-file-editors.test.tsx @@ -6,6 +6,7 @@ const APP_PATH = "src/app.ts"; const MISSING_PATH = "src/missing.ts"; const WORKTREE_PATH = "/worktree"; const WORKTREE_APP_PATH = "/worktree/src/app.ts"; +const REPO = "frontend"; vi.mock("@/components/editors/monaco/monaco-init", () => ({ getMonacoInstance, @@ -31,6 +32,7 @@ describe("scrollEditorIfMounted", () => { afterEach(() => { getMonacoInstance.mockReset(); consumePendingCursorPosition(APP_PATH); + consumePendingCursorPosition(APP_PATH, REPO); consumePendingCursorPosition(MISSING_PATH); }); @@ -47,6 +49,45 @@ describe("scrollEditorIfMounted", () => { expect(consumePendingCursorPosition(APP_PATH)).toBeUndefined(); }); + it("falls back to a path-segment suffix match when the worktree path is unknown", () => { + const editor = createEditor(WORKTREE_APP_PATH); + getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } }); + setPendingCursorPosition(APP_PATH, 12, 1); + + expect(scrollEditorIfMounted(APP_PATH, null, 42, 3)).toBe(true); + + expect(editor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 }); + expect(editor.revealLineInCenter).toHaveBeenCalledWith(42); + expect(editor.focus).toHaveBeenCalledTimes(1); + expect(consumePendingCursorPosition(APP_PATH)).toBeUndefined(); + }); + + it("requires the repo segment when repo-scoped fallback scrolling", () => { + const wrongRepoEditor = createEditor("/worktree/backend/src/app.ts"); + const repoEditor = createEditor("/worktree/frontend/src/app.ts"); + getMonacoInstance.mockReturnValue({ + editor: { getEditors: () => [wrongRepoEditor, repoEditor] }, + }); + setPendingCursorPosition(APP_PATH, 12, 1, REPO); + + expect(scrollEditorIfMounted(APP_PATH, null, 42, 3, REPO)).toBe(true); + + expect(wrongRepoEditor.setPosition).not.toHaveBeenCalled(); + expect(repoEditor.setPosition).toHaveBeenCalledWith({ lineNumber: 42, column: 3 }); + expect(consumePendingCursorPosition(APP_PATH, REPO)).toBeUndefined(); + }); + + it("does not suffix-scroll a repo-scoped request into an unscoped model path", () => { + const editor = createEditor(WORKTREE_APP_PATH); + getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } }); + setPendingCursorPosition(APP_PATH, 12, 1, REPO); + + expect(scrollEditorIfMounted(APP_PATH, null, 42, 3, REPO)).toBe(false); + + expect(editor.setPosition).not.toHaveBeenCalled(); + expect(consumePendingCursorPosition(APP_PATH, REPO)).toEqual({ line: 12, column: 1 }); + }); + it("returns false when no mounted Monaco editor matches the path", () => { const editor = createEditor("/worktree/src/other.ts"); getMonacoInstance.mockReturnValue({ editor: { getEditors: () => [editor] } }); diff --git a/apps/web/hooks/use-file-editors.ts b/apps/web/hooks/use-file-editors.ts index e6dfdc0dd8..d56a31117b 100644 --- a/apps/web/hooks/use-file-editors.ts +++ b/apps/web/hooks/use-file-editors.ts @@ -17,7 +17,6 @@ import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-sta import { useSaveDeleteActions } from "./use-file-save-delete"; import { buildRepoScopedItemId, PREVIEW_FILE_EDITOR_ID } from "@/lib/state/dockview-panel-actions"; import { useOpenFileWorkspaceSync } from "./file-editors-sync"; -import { getMonacoInstance } from "@/components/editors/monaco/monaco-init"; import { fetchFileEditorState, getPreviewItemIdToRemoveOnReplace, @@ -25,63 +24,17 @@ import { isRestoreWriteCurrent, type FileEditorRequestToken, } from "./file-editor-state"; +import { scrollEditorIfMounted, setPendingCursorPosition } from "./file-editor-cursor"; +export { + consumePendingCursorPosition, + scrollEditorIfMounted, + setPendingCursorPosition, +} from "./file-editor-cursor"; // Module-level guard: ensures restoration only runs once across all hook instances let _restoredSessionId: string | null = null; let _restorationInProgress = false; -// Pending cursor positions: set before opening a file, consumed by the editor on mount. -// Used by LSP Go-to-Definition to jump to the correct line/column. -const _pendingCursorPositions = new Map(); - -export function setPendingCursorPosition(path: string, line: number, column: number) { - _pendingCursorPositions.set(path, { line, column }); -} - -export function consumePendingCursorPosition( - path: string, -): { line: number; column: number } | undefined { - const pos = _pendingCursorPositions.get(path); - if (pos) _pendingCursorPositions.delete(path); - return pos; -} - -/** - * Scroll an already-mounted Monaco editor for `path` to the given position. - * Returns true if an editor was found and scrolled. The editor's model URI is - * built as `${worktreePath}/${path}` (see use-monaco-editor-lsp), so the same - * `path` used for setPendingCursorPosition resolves the right editor here. - * - * Used for the already-open case: when a file link is clicked but its tab is - * already open, no editor mounts, so handleEditorDidMount never consumes the - * pending position — this scrolls the live editor instead (and consumes the - * pending entry so a later remount doesn't double-apply it). - */ -export function scrollEditorIfMounted( - path: string, - worktreePath: string | null, - line: number, - column: number, -): boolean { - const monaco = getMonacoInstance(); - if (!monaco) return false; - - const monacoPath = worktreePath ? `${worktreePath}/${path}` : path; - for (const editor of monaco.editor.getEditors()) { - const model = editor.getModel(); - if (!model) continue; - const modelPath = model.uri.path; - if (modelPath === `/${monacoPath}` || modelPath === monacoPath) { - consumePendingCursorPosition(path); - editor.setPosition({ lineNumber: line, column }); - editor.revealLineInCenter(line); - editor.focus(); - return true; - } - } - return false; -} - export function useOpenFileAtLine( onOpenFile: ((path: string) => void) | undefined, startLine: number | undefined, diff --git a/apps/web/lib/api/domains/walkthrough-api.ts b/apps/web/lib/api/domains/walkthrough-api.ts new file mode 100644 index 0000000000..7a2c4132d8 --- /dev/null +++ b/apps/web/lib/api/domains/walkthrough-api.ts @@ -0,0 +1,32 @@ +import { getWebSocketClient } from "@/lib/ws/connection"; +import type { TaskWalkthrough } from "@/lib/types/http"; + +const WS_CLIENT_UNAVAILABLE = "WebSocket client not available"; + +/** + * Get the agent-authored code walkthrough for a task, or null if none exists. + * Used to backfill the store on mount — live `task.walkthrough.created` events + * can fire before the page's WS subscription is established. + */ +export async function getTaskWalkthrough(taskId: string): Promise { + const client = getWebSocketClient(); + if (!client) { + throw new Error(WS_CLIENT_UNAVAILABLE); + } + + const response = await client.request("task.walkthrough.get", { task_id: taskId }); + if (!response || typeof response !== "object" || !("id" in response)) { + return null; + } + return response as TaskWalkthrough; +} + +/** Delete the current persisted walkthrough for a task. */ +export async function deleteTaskWalkthrough(taskId: string): Promise { + const client = getWebSocketClient(); + if (!client) { + throw new Error(WS_CLIENT_UNAVAILABLE); + } + + await client.request("task.walkthrough.delete", { task_id: taskId }, 10000); +} diff --git a/apps/web/lib/diff/walkthrough-match.test.ts b/apps/web/lib/diff/walkthrough-match.test.ts new file mode 100644 index 0000000000..a05fde2738 --- /dev/null +++ b/apps/web/lib/diff/walkthrough-match.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { walkthroughFileMatches, walkthroughStepMatchesFile } from "./walkthrough-match"; + +const WEB_MAIN = "apps/web/main.ts"; +const FRONTEND = "frontend"; + +describe("walkthroughFileMatches", () => { + it("matches exact paths", () => { + expect(walkthroughFileMatches(WEB_MAIN, WEB_MAIN)).toBe(true); + }); + + it("matches path suffixes only on segment boundaries", () => { + expect(walkthroughFileMatches(WEB_MAIN, "main.ts")).toBe(true); + expect(walkthroughFileMatches("apps/web/domain.ts", "main.ts")).toBe(false); + expect(walkthroughFileMatches("apps/web/foobar.ts", "bar.ts")).toBe(false); + }); + + it("uses repository names when the step supplies one", () => { + expect( + walkthroughStepMatchesFile( + { path: WEB_MAIN, repository_name: FRONTEND }, + { file: "main.ts", repo: FRONTEND }, + ), + ).toBe(true); + expect( + walkthroughStepMatchesFile( + { path: WEB_MAIN, repository_name: "backend" }, + { file: "main.ts", repo: FRONTEND }, + ), + ).toBe(false); + expect( + walkthroughStepMatchesFile({ path: WEB_MAIN }, { file: "main.ts", repo: FRONTEND }), + ).toBe(false); + }); + + it("does not match repo-scoped files when the step omits repo", () => { + expect(walkthroughStepMatchesFile({ path: WEB_MAIN }, { file: "main.ts" })).toBe(true); + expect( + walkthroughStepMatchesFile( + { path: WEB_MAIN, repository_name: FRONTEND }, + { file: "main.ts" }, + ), + ).toBe(false); + }); + + it("rejects repo-less matches when the same suffix exists in multiple repos", () => { + const files = [ + { path: WEB_MAIN, repository_name: FRONTEND }, + { path: WEB_MAIN, repository_name: "docs" }, + ]; + + expect(walkthroughStepMatchesFile(files[0], { file: "main.ts" }, files)).toBe(false); + }); +}); diff --git a/apps/web/lib/diff/walkthrough-match.ts b/apps/web/lib/diff/walkthrough-match.ts new file mode 100644 index 0000000000..8d15a912fa --- /dev/null +++ b/apps/web/lib/diff/walkthrough-match.ts @@ -0,0 +1,70 @@ +export type WalkthroughTargetFile = { + path: string; + repository_name?: string; +}; + +export type WalkthroughTargetStep = { + file: string; + repo?: string; +}; + +function normalizePath(path: string): string[] { + return path.trim().replaceAll("\\", "/").replace(/^\.\//, "").split("/").filter(Boolean); +} + +function normalizeRepo(repo: string | undefined): string { + return (repo ?? "").trim(); +} + +function segmentsMatchSuffix(left: string[], right: string[]): boolean { + if (left.length === 0 || right.length === 0) return false; + const shorter = left.length <= right.length ? left : right; + const longer = left.length <= right.length ? right : left; + const offset = longer.length - shorter.length; + return shorter.every((segment, index) => longer[offset + index] === segment); +} + +/** + * Best-effort match between a walkthrough step's `file` and a diff/review file + * path. Exact match wins; suffix matching is allowed only on path-segment + * boundaries so `bar.ts` cannot match `foobar.ts`. + */ +export function walkthroughFileMatches(diffPath: string, stepFile: string): boolean { + const diffSegments = normalizePath(diffPath); + const stepSegments = normalizePath(stepFile); + return segmentsMatchSuffix(diffSegments, stepSegments); +} + +function repositoryMatches(file: WalkthroughTargetFile, step: WalkthroughTargetStep): boolean { + const stepRepo = normalizeRepo(step.repo); + const fileRepo = normalizeRepo(file.repository_name); + if (!stepRepo) return !fileRepo; + if (!fileRepo) return false; + return fileRepo === stepRepo; +} + +function isRepoAmbiguous( + file: WalkthroughTargetFile, + step: WalkthroughTargetStep, + allFiles: WalkthroughTargetFile[] | undefined, +): boolean { + if (step.repo || !allFiles || !file.repository_name) return false; + const repos = new Set( + allFiles + .filter((candidate) => walkthroughFileMatches(candidate.path, step.file)) + .map((candidate) => normalizeRepo(candidate.repository_name)) + .filter(Boolean), + ); + return repos.size > 1; +} + +export function walkthroughStepMatchesFile( + file: WalkthroughTargetFile, + step: WalkthroughTargetStep, + allFiles?: WalkthroughTargetFile[], +): boolean { + if (!repositoryMatches(file, step)) return false; + if (!walkthroughFileMatches(file.path, step.file)) return false; + if (isRepoAmbiguous(file, step, allFiles)) return false; + return true; +} diff --git a/apps/web/lib/diff/walkthrough-reveal.ts b/apps/web/lib/diff/walkthrough-reveal.ts new file mode 100644 index 0000000000..87cdb31511 --- /dev/null +++ b/apps/web/lib/diff/walkthrough-reveal.ts @@ -0,0 +1,20 @@ +import { setPendingCursorPosition, scrollEditorIfMounted } from "@/hooks/use-file-editors"; + +export type OpenFileFn = (path: string, repo?: string) => void | Promise; + +/** + * Open a file in an editor tab and reveal/center the given line — the walkthrough + * "pointer" to the line being explained. Uses the same pending-cursor mechanism + * as LSP go-to-definition: the position is consumed when the editor mounts (new + * tab), and scrolled live if the tab is already open. + */ +export function revealFileAtLine( + openFile: OpenFileFn, + file: string, + line: number, + repo?: string, +): void { + if (line > 0) setPendingCursorPosition(file, line, 1, repo); + void openFile(file, repo); + if (line > 0) scrollEditorIfMounted(file, null, line, 1, repo); +} diff --git a/apps/web/lib/local-storage.test.ts b/apps/web/lib/local-storage.test.ts index c0e869973e..0e02a6eb45 100644 --- a/apps/web/lib/local-storage.test.ts +++ b/apps/web/lib/local-storage.test.ts @@ -107,6 +107,27 @@ describe("global sidebar width storage", () => { }); }); +describe("task-scoped artifact notification storage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it("clears walkthrough last-seen state via cleanupTaskStorage", () => { + window.localStorage.setItem( + "kandev.walkthrough.lastSeenByTask", + JSON.stringify({ "task-a": "2026-01-01T00:00:00Z", "task-b": "2026-01-02T00:00:00Z" }), + ); + + cleanupTaskStorage("task-a", []); + + expect( + JSON.parse(window.localStorage.getItem("kandev.walkthrough.lastSeenByTask") ?? "{}"), + ).toEqual({ + "task-b": "2026-01-02T00:00:00Z", + }); + }); +}); + describe("open file tabs storage", () => { beforeEach(() => { window.sessionStorage.clear(); diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 4e0c71ede5..018ef5fe57 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -1,3 +1,5 @@ +import { setWalkthroughLastSeen } from "@/lib/walkthrough-notification-storage"; + type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; // Session Storage helpers (cleared when browser tab closes) @@ -107,28 +109,14 @@ export function setKanbanPreviewState(state: Partial): void } } -// Internal storage key for plan notifications (not exported - encapsulated) const PLAN_NOTIFICATION_KEY = "kandev.plan.lastSeenByTask"; -/** - * Plan notification state - tracks when user last viewed each task's plan - * Key is taskId, value is the plan's updated_at timestamp when last viewed - */ export type PlanNotificationState = Record; -/** - * Get the plan notification state from localStorage - * @returns Record of taskId -> last seen plan update timestamp - */ export function getPlanNotificationState(): PlanNotificationState { return getLocalStorage(PLAN_NOTIFICATION_KEY, {} as PlanNotificationState); } -/** - * Set the last seen timestamp for a specific task's plan - * @param taskId - The task ID - * @param timestamp - The plan's updated_at timestamp when viewed (or null to clear) - */ export function setPlanLastSeen(taskId: string, timestamp: string | null): void { const state = getPlanNotificationState(); if (timestamp === null) { @@ -139,11 +127,6 @@ export function setPlanLastSeen(taskId: string, timestamp: string | null): void setLocalStorage(PLAN_NOTIFICATION_KEY, state); } -/** - * Get the last seen timestamp for a specific task's plan - * @param taskId - The task ID - * @returns The last seen timestamp, or null if never viewed - */ export function getPlanLastSeen(taskId: string): string | null { const state = getPlanNotificationState(); return state[taskId] ?? null; @@ -656,6 +639,7 @@ export function cleanupTaskStorage( ): void { // Plan notification (localStorage, keyed per task inside a Record) setPlanLastSeen(taskId, null); + setWalkthroughLastSeen(taskId, null); // PR merged / closed banner dismissal (sessionStorage, keyed per task) removeSessionStorage(`${PR_MERGED_BANNER_DISMISSED_PREFIX}${taskId}`); diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 802a64d967..eb1c177641 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -46,6 +46,7 @@ export const defaultState = { pendingModel: defaultSessionState.pendingModel, activeModel: defaultSessionState.activeModel, taskPlans: defaultSessionState.taskPlans, + walkthroughs: defaultSessionState.walkthroughs, queue: defaultSessionState.queue, terminal: defaultSessionRuntimeState.terminal, shell: defaultSessionRuntimeState.shell, @@ -187,6 +188,7 @@ export function mergeInitialState(initialState?: Partial): Default pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, taskPlans: { ...defaultState.taskPlans, ...initialState.taskPlans }, + walkthroughs: { ...defaultState.walkthroughs, ...initialState.walkthroughs }, queue: { ...defaultState.queue, ...initialState.queue }, terminal: { ...defaultState.terminal, ...initialState.terminal }, shell: { ...defaultState.shell, ...initialState.shell }, diff --git a/apps/web/lib/state/slices/comments/format.test.ts b/apps/web/lib/state/slices/comments/format.test.ts index 4545ea9da4..98682544fc 100644 --- a/apps/web/lib/state/slices/comments/format.test.ts +++ b/apps/web/lib/state/slices/comments/format.test.ts @@ -3,9 +3,10 @@ import { formatPlanCommentsAsMarkdown, formatReviewCommentsAsMarkdown, formatPRFeedbackAsMarkdown, + formatWalkthroughCommentsAsMarkdown, formatCommentsForMessage, } from "./format"; -import type { PlanComment, DiffComment, PRFeedbackComment } from "./types"; +import type { PlanComment, DiffComment, PRFeedbackComment, WalkthroughComment } from "./types"; function makePlanComment(overrides: Partial = {}): PlanComment { return { @@ -141,6 +142,42 @@ describe("formatPRFeedbackAsMarkdown", () => { }); }); +describe("formatWalkthroughCommentsAsMarkdown", () => { + function makeWalkthroughComment(overrides: Partial = {}): WalkthroughComment { + return { + id: "wtc-1", + sessionId: "sess-1", + source: "walkthrough", + taskId: "task-1", + walkthroughId: "wt-1", + walkthroughTitle: "Implementation tour", + stepIndex: 1, + stepCount: 4, + repo: "frontend", + filePath: "apps/web/main.ts", + startLine: 10, + endLine: 12, + stepText: "The agent explanation", + text: "Can you simplify this?", + createdAt: new Date().toISOString(), + status: "pending", + ...overrides, + }; + } + + it("formats walkthrough feedback with step and anchor context", () => { + const result = formatWalkthroughCommentsAsMarkdown([makeWalkthroughComment()]); + + expect(result).toContain("### Walkthrough Feedback"); + expect(result).toContain("**Implementation tour · Step 2 / 4**"); + expect(result).toContain("**frontend/apps/web/main.ts:10-12**"); + expect(result).toContain("Agent walkthrough text:"); + expect(result).toContain("> The agent explanation"); + expect(result).toContain("User feedback:"); + expect(result).toContain("> Can you simplify this?"); + }); +}); + describe("formatCommentsForMessage", () => { it("separates comments by type", () => { const diff: DiffComment = { @@ -171,5 +208,6 @@ describe("formatCommentsForMessage", () => { expect(result.diffComments).toHaveLength(1); expect(result.planComments).toHaveLength(1); expect(result.prFeedbackComments).toHaveLength(0); + expect(result.walkthroughComments).toHaveLength(0); }); }); diff --git a/apps/web/lib/state/slices/comments/format.ts b/apps/web/lib/state/slices/comments/format.ts index f901a94f28..eb2451d6f6 100644 --- a/apps/web/lib/state/slices/comments/format.ts +++ b/apps/web/lib/state/slices/comments/format.ts @@ -1,5 +1,11 @@ -import type { Comment, DiffComment, PlanComment, PRFeedbackComment } from "./types"; -import { isDiffComment, isPlanComment, isPRFeedbackComment } from "./types"; +import type { + Comment, + DiffComment, + PlanComment, + PRFeedbackComment, + WalkthroughComment, +} from "./types"; +import { isDiffComment, isPlanComment, isPRFeedbackComment, isWalkthroughComment } from "./types"; /** * Format diff review comments as human-readable markdown for sending to agent. @@ -85,6 +91,34 @@ export function formatPRFeedbackAsMarkdown(comments: PRFeedbackComment[]): strin return lines.join("\n"); } +/** + * Format walkthrough feedback as markdown for sending to agent. + */ +export function formatWalkthroughCommentsAsMarkdown(comments: WalkthroughComment[]): string { + if (!comments || comments.length === 0) return ""; + + const lines: string[] = ["### Walkthrough Feedback", ""]; + for (const c of comments) { + const lineRange = c.startLine === c.endLine ? `${c.startLine}` : `${c.startLine}-${c.endLine}`; + const repoPrefix = c.repo ? `${c.repo}/` : ""; + const title = c.walkthroughTitle || "Walkthrough"; + + lines.push(`**${title} · Step ${c.stepIndex + 1} / ${c.stepCount}**`); + lines.push(`**${repoPrefix}${c.filePath}:${lineRange}**`); + lines.push(""); + lines.push("Agent walkthrough text:"); + lines.push(toBlockquote(c.stepText)); + lines.push(""); + lines.push("User feedback:"); + lines.push(toBlockquote(c.text)); + lines.push(""); + } + + lines.push("---"); + lines.push(""); + return lines.join("\n"); +} + /** * Format all pending comments for inclusion in a chat message. */ @@ -92,16 +126,19 @@ export function formatCommentsForMessage(comments: Comment[]): { diffComments: DiffComment[]; planComments: PlanComment[]; prFeedbackComments: PRFeedbackComment[]; + walkthroughComments: WalkthroughComment[]; } { const diffComments: DiffComment[] = []; const planComments: PlanComment[] = []; const prFeedbackComments: PRFeedbackComment[] = []; + const walkthroughComments: WalkthroughComment[] = []; for (const c of comments) { if (isDiffComment(c)) diffComments.push(c); else if (isPlanComment(c)) planComments.push(c); else if (isPRFeedbackComment(c)) prFeedbackComments.push(c); + else if (isWalkthroughComment(c)) walkthroughComments.push(c); } - return { diffComments, planComments, prFeedbackComments }; + return { diffComments, planComments, prFeedbackComments, walkthroughComments }; } diff --git a/apps/web/lib/state/slices/comments/index.ts b/apps/web/lib/state/slices/comments/index.ts index 5f624717cc..7c42f5e30b 100644 --- a/apps/web/lib/state/slices/comments/index.ts +++ b/apps/web/lib/state/slices/comments/index.ts @@ -5,6 +5,7 @@ export { type PlanComment, type FileEditorComment, type PRFeedbackComment, + type WalkthroughComment, type AnnotationSide, type CommentsState, type CommentsActions, @@ -13,11 +14,13 @@ export { isPlanComment, isFileEditorComment, isPRFeedbackComment, + isWalkthroughComment, } from "./types"; export { formatReviewCommentsAsMarkdown, formatPlanCommentsAsMarkdown, formatPRFeedbackAsMarkdown, + formatWalkthroughCommentsAsMarkdown, formatCommentsForMessage, } from "./format"; export { diff --git a/apps/web/lib/state/slices/comments/types.ts b/apps/web/lib/state/slices/comments/types.ts index fabf095e00..cb74e10486 100644 --- a/apps/web/lib/state/slices/comments/types.ts +++ b/apps/web/lib/state/slices/comments/types.ts @@ -57,7 +57,27 @@ export type PRFeedbackComment = CommentBase & { content: string; }; -export type Comment = DiffComment | PlanComment | FileEditorComment | PRFeedbackComment; +export type WalkthroughComment = CommentBase & { + source: "walkthrough"; + taskId: string; + walkthroughId?: string; + walkthroughTitle?: string; + stepIndex: number; + stepCount: number; + repo?: string; + filePath: string; + startLine: number; + endLine: number; + /** Markdown explanation authored by the agent for this walkthrough step. */ + stepText: string; +}; + +export type Comment = + | DiffComment + | PlanComment + | FileEditorComment + | PRFeedbackComment + | WalkthroughComment; // --------------------------------------------------------------------------- // Type guards @@ -79,6 +99,10 @@ export function isPRFeedbackComment(c: Comment): c is PRFeedbackComment { return c.source === "pr-feedback"; } +export function isWalkthroughComment(c: Comment): c is WalkthroughComment { + return c.source === "walkthrough"; +} + // --------------------------------------------------------------------------- // Store state & actions // --------------------------------------------------------------------------- diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index 51639c89e0..b326125357 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -11,6 +11,10 @@ import type { SessionRuntimeSliceState } from "@/lib/state/slices/session-runtim import { prepareResultToSessionState } from "@/lib/state/slices/session-runtime/prepare-result"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { getPlanLastSeen, setPlanLastSeen } from "@/lib/local-storage"; +import { + getWalkthroughLastSeen, + setWalkthroughLastSeen, +} from "@/lib/walkthrough-notification-storage"; const debugEnv = createDebugLogger("session:env-mapping"); @@ -137,6 +141,11 @@ export const defaultSessionState: SessionSliceState = { comparePairByTaskId: {}, lastSeenUpdatedAtByTaskId: {}, }, + walkthroughs: { + byTaskId: {}, + activeStepByTaskId: {}, + lastSeenUpdatedAtByTaskId: {}, + }, queue: { bySessionId: {}, metaBySessionId: {}, isLoading: {} }, }; @@ -340,6 +349,47 @@ function buildTaskPlanActions(set: ImmerSet, get: ImmerGet) { }; } +function buildWalkthroughActions(set: ImmerSet, get: ImmerGet) { + return { + setWalkthrough: ( + taskId: string, + walkthrough: Parameters[1], + ) => { + const shouldHydrateLastSeen = + get().walkthroughs.lastSeenUpdatedAtByTaskId[taskId] === undefined; + const storedLastSeen = shouldHydrateLastSeen ? getWalkthroughLastSeen(taskId) : null; + set((draft) => { + const previous = draft.walkthroughs.byTaskId[taskId]; + draft.walkthroughs.byTaskId[taskId] = walkthrough; + // Clamp the active step into the new step range (defaults to 0). A + // replaced/shorter tour must not leave the pointer past the last step. + const steps = walkthrough?.steps.length ?? 0; + const isReplacement = previous?.id !== walkthrough?.id; + const current = isReplacement ? 0 : (draft.walkthroughs.activeStepByTaskId[taskId] ?? 0); + draft.walkthroughs.activeStepByTaskId[taskId] = + steps === 0 ? 0 : Math.min(current, steps - 1); + if (shouldHydrateLastSeen && storedLastSeen !== null) { + draft.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] = storedLastSeen; + } + }); + }, + setWalkthroughActiveStep: (taskId: string, stepIndex: number) => + set((draft) => { + const steps = draft.walkthroughs.byTaskId[taskId]?.steps.length ?? 0; + const clamped = steps === 0 ? 0 : Math.max(0, Math.min(stepIndex, steps - 1)); + draft.walkthroughs.activeStepByTaskId[taskId] = clamped; + }), + markWalkthroughSeen: (taskId: string) => { + const wt = get().walkthroughs.byTaskId[taskId]; + const lastSeen = wt?.updated_at ?? ""; + setWalkthroughLastSeen(taskId, lastSeen); + set((draft) => { + draft.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] = lastSeen; + }); + }, + }; +} + function buildPreviewCompareActions(set: ImmerSet) { return { setPreviewRevision: (taskId: string, revisionId: string | null) => @@ -512,6 +562,7 @@ export const createSessionSlice: StateCreator< draft.activeModel.bySessionId[sessionId] = modelId; }), ...buildTaskPlanActions(set, get), + ...buildWalkthroughActions(set, get), setQueueEntries: (sessionId, entries, meta) => set((draft) => { draft.queue.bySessionId[sessionId] = entries; diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index 34ad9f087f..b22837304f 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -1,4 +1,11 @@ -import type { Message, TaskSession, Turn, TaskPlan, TaskPlanRevision } from "@/lib/types/http"; +import type { + Message, + TaskSession, + Turn, + TaskPlan, + TaskPlanRevision, + TaskWalkthrough, +} from "@/lib/types/http"; export type MessagesState = { bySession: Record; @@ -83,6 +90,15 @@ export type TaskPlansState = { lastSeenUpdatedAtByTaskId: Record; }; +export type WalkthroughsState = { + /** The current walkthrough per task (null = explicitly none). */ + byTaskId: Record; + /** The active step index per task (drives the popover position). */ + activeStepByTaskId: Record; + /** The last `updated_at` the user has opened, for the unseen-dot indicator. */ + lastSeenUpdatedAtByTaskId: Record; +}; + export type QueuedMessageMetadata = Record & { workflow_message?: boolean; workflow_auto_start?: boolean; @@ -145,6 +161,7 @@ export type SessionSliceState = { pendingModel: PendingModelState; activeModel: ActiveModelState; taskPlans: TaskPlansState; + walkthroughs: WalkthroughsState; queue: QueueState; }; @@ -212,6 +229,10 @@ export type SessionSliceActions = { setPreviewRevision: (taskId: string, revisionId: string | null) => void; toggleComparePair: (taskId: string, revisionId: string) => void; clearComparePair: (taskId: string) => void; + // Walkthrough actions + setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; + setWalkthroughActiveStep: (taskId: string, stepIndex: number) => void; + markWalkthroughSeen: (taskId: string) => void; // Queue actions setQueueEntries: (sessionId: string, entries: QueuedMessage[], meta: QueueMeta) => void; removeQueueEntry: (sessionId: string, entryId: string) => void; diff --git a/apps/web/lib/state/slices/session/walkthroughs.test.ts b/apps/web/lib/state/slices/session/walkthroughs.test.ts new file mode 100644 index 0000000000..df4c798443 --- /dev/null +++ b/apps/web/lib/state/slices/session/walkthroughs.test.ts @@ -0,0 +1,137 @@ +import { beforeEach, describe, it, expect, vi } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createSessionSlice } from "./session-slice"; +import type { SessionSlice } from "./types"; +import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http"; + +const mockGetPlanLastSeen = vi.fn(); +const mockSetPlanLastSeen = vi.fn(); +const mockGetWalkthroughLastSeen = vi.fn(); +const mockSetWalkthroughLastSeen = vi.fn(); + +vi.mock("@/lib/local-storage", () => ({ + getPlanLastSeen: (...args: unknown[]) => mockGetPlanLastSeen(...args), + setPlanLastSeen: (...args: unknown[]) => mockSetPlanLastSeen(...args), +})); + +vi.mock("@/lib/walkthrough-notification-storage", () => ({ + getWalkthroughLastSeen: (...args: unknown[]) => mockGetWalkthroughLastSeen(...args), + setWalkthroughLastSeen: (...args: unknown[]) => mockSetWalkthroughLastSeen(...args), +})); + +function makeStore() { + return create()(immer(createSessionSlice)); +} + +const TASK_ID = "task-1"; +const TS_EPOCH = "2026-04-20T00:00:00Z"; +const TS_LATER = "2026-04-20T01:00:00Z"; + +function steps(n: number): WalkthroughStep[] { + return Array.from({ length: n }, (_, i) => ({ + file: `f${i}.go`, + line: i + 1, + text: `step ${i}`, + })); +} + +function makeWalkthrough(overrides: Partial = {}): TaskWalkthrough { + return { + id: "wt-1", + task_id: TASK_ID, + title: "Walkthrough", + steps: steps(3), + created_by: "agent", + created_at: TS_EPOCH, + updated_at: TS_EPOCH, + ...overrides, + }; +} + +describe("walkthrough slice", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetPlanLastSeen.mockReturnValue(null); + mockGetWalkthroughLastSeen.mockReturnValue(null); + }); + + it("setWalkthrough stores the tour and defaults the active step to 0", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); + + expect(store.getState().walkthroughs.byTaskId[TASK_ID]?.steps).toHaveLength(3); + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + }); + + it("setWalkthroughActiveStep clamps within [0, len-1]", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); + + store.getState().setWalkthroughActiveStep(TASK_ID, 99); + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(2); + + store.getState().setWalkthroughActiveStep(TASK_ID, -5); + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + }); + + it("replacing with a shorter tour clamps the active step back into range", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); + store.getState().setWalkthroughActiveStep(TASK_ID, 2); + + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ steps: steps(1) })); + + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + }); + + it("replacing with a different tour resets the active step", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); + store.getState().setWalkthroughActiveStep(TASK_ID, 2); + + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ id: "wt-2" })); + + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + }); + + it("setWalkthrough(null) resets the active step to 0", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); + store.getState().setWalkthroughActiveStep(TASK_ID, 2); + + store.getState().setWalkthrough(TASK_ID, null); + + expect(store.getState().walkthroughs.byTaskId[TASK_ID]).toBeNull(); + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + }); + + it("markWalkthroughSeen records the current updated_at", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); + + store.getState().markWalkthroughSeen(TASK_ID); + + expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); + expect(mockSetWalkthroughLastSeen).toHaveBeenCalledWith(TASK_ID, TS_LATER); + }); + + it("setWalkthrough hydrates stored lastSeenUpdatedAtByTaskId", () => { + mockGetWalkthroughLastSeen.mockReturnValue(TS_LATER); + const store = makeStore(); + + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); + + expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); + }); + + it("setWalkthrough does not advance lastSeenUpdatedAtByTaskId on update", () => { + const store = makeStore(); + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_EPOCH })); + store.getState().markWalkthroughSeen(TASK_ID); + + store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); + + expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_EPOCH); + }); +}); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index ee8cb2a47c..5a6ba4cc35 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -8,6 +8,7 @@ import type { Message, Turn, TaskSession, + TaskWalkthrough, } from "@/lib/types/http"; import type { SystemHealthResponse } from "@/lib/types/health"; import type { UISliceActions as UIA } from "./slices/ui/types"; @@ -155,6 +156,7 @@ export type AppState = { pendingModel: (typeof defaultSessionState)["pendingModel"]; activeModel: (typeof defaultSessionState)["activeModel"]; taskPlans: (typeof defaultSessionState)["taskPlans"]; + walkthroughs: (typeof defaultSessionState)["walkthroughs"]; queue: (typeof defaultSessionState)["queue"]; // Session Runtime slice @@ -469,6 +471,10 @@ export type AppState = { setPreviewRevision: (taskId: string, revisionId: string | null) => void; toggleComparePair: (taskId: string, revisionId: string) => void; clearComparePair: (taskId: string) => void; + // Walkthrough actions + setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; + setWalkthroughActiveStep: (taskId: string, stepIndex: number) => void; + markWalkthroughSeen: (taskId: string) => void; // Queue actions setQueueEntries: ( sessionId: string, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index bd0e065352..b0938c8f4e 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -1,84 +1,8 @@ -export type BackendMessageType = - | "kanban.update" - | "task.created" - | "task.updated" - | "task.deleted" - | "task.state_changed" - | "task.plan.created" - | "task.plan.updated" - | "task.plan.deleted" - | "task.plan.revision.created" - | "task.plan.reverted" - | "agent.updated" - | "agent.available.updated" - | "agent.install.started" - | "agent.install.output" - | "agent.install.finished" - | "terminal.output" - | "diff.update" - | "session.git.event" - | "system.error" - | "system.job.update" - | "system.metrics.updated" - | "workspace.created" - | "workspace.updated" - | "workspace.deleted" - | "workflow.created" - | "workflow.updated" - | "workflow.deleted" - | "workflow.step.created" - | "workflow.step.updated" - | "workflow.step.deleted" - | "session.message.added" - | "session.message.updated" - | "session.message.deleted" - | "session.state_changed" - | "session.waiting_for_input" - | "session.agentctl_starting" - | "session.agentctl_ready" - | "session.agentctl_error" - | "session.turn.started" - | "session.turn.completed" - | "session.available_commands" - | "session.mode_changed" - | "session.agent_capabilities" - | "session.models_updated" - | "session.info_updated" - | "session.todos_updated" - | "session.prompt_usage" - | "session.poll_mode_changed" - | "executor.created" - | "executor.updated" - | "executor.deleted" - | "executor.profile.created" - | "executor.profile.updated" - | "executor.profile.deleted" - | "executor.prepare.progress" - | "executor.prepare.completed" - | "environment.created" - | "environment.updated" - | "environment.deleted" - | "agent.profile.deleted" - | "agent.profile.created" - | "agent.profile.updated" - | "user.settings.updated" - | "session.workspace.file.changes" - | "session.shell.output" - | "session.process.output" - | "session.process.status" - | "secrets.created" - | "secrets.updated" - | "secrets.deleted" - | "message.queue.status_changed" - | "github.task_pr.updated" - | "github.task_ci_options.updated" - | "github.rate_limit.updated" - | OfficeEventType - | "run.event.appended"; +export type BackendMessageType = keyof BackendMessageMap; export type { BackendMessage } from "./backend-message"; import type { BackendMessage } from "./backend-message"; -import type { OfficeEventType, OfficeBackendMessageMap } from "./office-events"; +import type { OfficeBackendMessageMap } from "./office-events"; export type { OfficeEventType, OfficeEventPayload } from "./office-events"; import type { @@ -524,116 +448,123 @@ export type QueueStatusChangedPayload = { max?: number; }; -export type BackendMessageMap = OfficeBackendMessageMap & { - "kanban.update": BackendMessage<"kanban.update", KanbanUpdatePayload>; - "task.created": BackendMessage<"task.created", TaskEventPayload>; - "task.updated": BackendMessage<"task.updated", TaskEventPayload>; - "task.deleted": BackendMessage<"task.deleted", TaskEventPayload>; - "task.state_changed": BackendMessage<"task.state_changed", TaskEventPayload>; - "task.plan.created": BackendMessage<"task.plan.created", TaskPlanEventPayload>; - "task.plan.updated": BackendMessage<"task.plan.updated", TaskPlanEventPayload>; - "task.plan.deleted": BackendMessage<"task.plan.deleted", TaskPlanEventPayload>; - "task.plan.revision.created": BackendMessage< - "task.plan.revision.created", - TaskPlanRevisionEventPayload - >; - "task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>; - "agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>; - "agent.available.updated": BackendMessage< - "agent.available.updated", - AgentAvailableUpdatedPayload - >; - "agent.install.started": BackendMessage<"agent.install.started", AgentInstallJobPayload>; - "agent.install.output": BackendMessage<"agent.install.output", AgentInstallOutputPayload>; - "agent.install.finished": BackendMessage<"agent.install.finished", AgentInstallJobPayload>; - "terminal.output": BackendMessage<"terminal.output", TerminalOutputPayload>; - "diff.update": BackendMessage<"diff.update", DiffUpdatePayload>; - "session.git.event": BackendMessage<"session.git.event", GitEventPayload>; - "system.error": BackendMessage<"system.error", SystemErrorPayload>; - "system.job.update": BackendMessage<"system.job.update", import("./system").SystemJob>; - "system.metrics.updated": BackendMessage<"system.metrics.updated", SystemMetricsSnapshot>; - "workspace.created": BackendMessage<"workspace.created", WorkspacePayload>; - "workspace.updated": BackendMessage<"workspace.updated", WorkspacePayload>; - "workspace.deleted": BackendMessage<"workspace.deleted", WorkspacePayload>; - "workflow.created": BackendMessage<"workflow.created", WorkflowPayload>; - "workflow.updated": BackendMessage<"workflow.updated", WorkflowPayload>; - "workflow.deleted": BackendMessage<"workflow.deleted", WorkflowPayload>; - "workflow.step.created": BackendMessage<"workflow.step.created", WorkflowStepEventPayload>; - "workflow.step.updated": BackendMessage<"workflow.step.updated", WorkflowStepEventPayload>; - "workflow.step.deleted": BackendMessage<"workflow.step.deleted", WorkflowStepEventPayload>; - "session.message.added": BackendMessage<"session.message.added", MessageAddedPayload>; - "session.message.updated": BackendMessage<"session.message.updated", MessageAddedPayload>; - "session.message.deleted": BackendMessage<"session.message.deleted", MessageAddedPayload>; - "session.state_changed": BackendMessage<"session.state_changed", TaskSessionStateChangedPayload>; - "session.waiting_for_input": BackendMessage< - "session.waiting_for_input", - TaskSessionWaitingForInputPayload - >; - "session.agentctl_starting": BackendMessage< - "session.agentctl_starting", - TaskSessionAgentctlPayload - >; - "session.agentctl_ready": BackendMessage<"session.agentctl_ready", TaskSessionAgentctlPayload>; - "session.agentctl_error": BackendMessage<"session.agentctl_error", TaskSessionAgentctlPayload>; - "session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>; - "session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>; - "session.available_commands": BackendMessage< - "session.available_commands", - AvailableCommandsPayload - >; - "session.mode_changed": BackendMessage<"session.mode_changed", SessionModeChangedPayload>; - "session.agent_capabilities": BackendMessage< - "session.agent_capabilities", - AgentCapabilitiesPayload - >; - "session.models_updated": BackendMessage<"session.models_updated", SessionModelsPayload>; - "session.info_updated": BackendMessage<"session.info_updated", SessionInfoPayload>; - "session.todos_updated": BackendMessage<"session.todos_updated", SessionTodosPayload>; - "session.prompt_usage": BackendMessage<"session.prompt_usage", SessionPromptUsagePayload>; - "session.poll_mode_changed": BackendMessage< - "session.poll_mode_changed", - { session_id: string; poll_mode: string } - >; - "executor.created": BackendMessage<"executor.created", ExecutorPayload>; - "executor.updated": BackendMessage<"executor.updated", ExecutorPayload>; - "executor.deleted": BackendMessage<"executor.deleted", ExecutorPayload>; - "executor.profile.created": BackendMessage<"executor.profile.created", ExecutorProfilePayload>; - "executor.profile.updated": BackendMessage<"executor.profile.updated", ExecutorProfilePayload>; - "executor.profile.deleted": BackendMessage<"executor.profile.deleted", { id: string }>; - "executor.prepare.progress": BackendMessage<"executor.prepare.progress", PrepareProgressPayload>; - "executor.prepare.completed": BackendMessage< - "executor.prepare.completed", - PrepareCompletedPayload - >; - "environment.created": BackendMessage<"environment.created", EnvironmentPayload>; - "environment.updated": BackendMessage<"environment.updated", EnvironmentPayload>; - "environment.deleted": BackendMessage<"environment.deleted", EnvironmentPayload>; - "agent.profile.deleted": BackendMessage<"agent.profile.deleted", AgentProfileDeletedPayload>; - "agent.profile.created": BackendMessage<"agent.profile.created", AgentProfileChangedPayload>; - "agent.profile.updated": BackendMessage<"agent.profile.updated", AgentProfileChangedPayload>; - "user.settings.updated": BackendMessage<"user.settings.updated", UserSettingsUpdatedPayload>; - "session.workspace.file.changes": BackendMessage< - "session.workspace.file.changes", - FileChangeNotificationPayload - >; - "session.shell.output": BackendMessage<"session.shell.output", ShellOutputPayload>; - "session.process.output": BackendMessage<"session.process.output", ProcessOutputPayload>; - "session.process.status": BackendMessage<"session.process.status", ProcessStatusPayload>; - "secrets.created": BackendMessage<"secrets.created", SecretListItem>; - "secrets.updated": BackendMessage<"secrets.updated", SecretListItem>; - "secrets.deleted": BackendMessage<"secrets.deleted", { id: string }>; - "message.queue.status_changed": BackendMessage< - "message.queue.status_changed", - QueueStatusChangedPayload - >; - "github.task_pr.updated": BackendMessage<"github.task_pr.updated", TaskPR>; - "github.task_ci_options.updated": BackendMessage< - "github.task_ci_options.updated", - TaskCIAutomationOptions - >; - "github.rate_limit.updated": BackendMessage<"github.rate_limit.updated", GitHubRateLimitUpdate>; - "run.event.appended": BackendMessage<"run.event.appended", RunEventAppendedPayload>; -}; +export type BackendMessageMap = OfficeBackendMessageMap & + import("@/lib/types/http").WalkthroughBackendMessageMap & { + "kanban.update": BackendMessage<"kanban.update", KanbanUpdatePayload>; + "task.created": BackendMessage<"task.created", TaskEventPayload>; + "task.updated": BackendMessage<"task.updated", TaskEventPayload>; + "task.deleted": BackendMessage<"task.deleted", TaskEventPayload>; + "task.state_changed": BackendMessage<"task.state_changed", TaskEventPayload>; + "task.plan.created": BackendMessage<"task.plan.created", TaskPlanEventPayload>; + "task.plan.updated": BackendMessage<"task.plan.updated", TaskPlanEventPayload>; + "task.plan.deleted": BackendMessage<"task.plan.deleted", TaskPlanEventPayload>; + "task.plan.revision.created": BackendMessage< + "task.plan.revision.created", + TaskPlanRevisionEventPayload + >; + "task.plan.reverted": BackendMessage<"task.plan.reverted", TaskPlanRevisionEventPayload>; + "agent.updated": BackendMessage<"agent.updated", AgentUpdatePayload>; + "agent.available.updated": BackendMessage< + "agent.available.updated", + AgentAvailableUpdatedPayload + >; + "agent.install.started": BackendMessage<"agent.install.started", AgentInstallJobPayload>; + "agent.install.output": BackendMessage<"agent.install.output", AgentInstallOutputPayload>; + "agent.install.finished": BackendMessage<"agent.install.finished", AgentInstallJobPayload>; + "terminal.output": BackendMessage<"terminal.output", TerminalOutputPayload>; + "diff.update": BackendMessage<"diff.update", DiffUpdatePayload>; + "session.git.event": BackendMessage<"session.git.event", GitEventPayload>; + "system.error": BackendMessage<"system.error", SystemErrorPayload>; + "system.job.update": BackendMessage<"system.job.update", import("./system").SystemJob>; + "system.metrics.updated": BackendMessage<"system.metrics.updated", SystemMetricsSnapshot>; + "workspace.created": BackendMessage<"workspace.created", WorkspacePayload>; + "workspace.updated": BackendMessage<"workspace.updated", WorkspacePayload>; + "workspace.deleted": BackendMessage<"workspace.deleted", WorkspacePayload>; + "workflow.created": BackendMessage<"workflow.created", WorkflowPayload>; + "workflow.updated": BackendMessage<"workflow.updated", WorkflowPayload>; + "workflow.deleted": BackendMessage<"workflow.deleted", WorkflowPayload>; + "workflow.step.created": BackendMessage<"workflow.step.created", WorkflowStepEventPayload>; + "workflow.step.updated": BackendMessage<"workflow.step.updated", WorkflowStepEventPayload>; + "workflow.step.deleted": BackendMessage<"workflow.step.deleted", WorkflowStepEventPayload>; + "session.message.added": BackendMessage<"session.message.added", MessageAddedPayload>; + "session.message.updated": BackendMessage<"session.message.updated", MessageAddedPayload>; + "session.message.deleted": BackendMessage<"session.message.deleted", MessageAddedPayload>; + "session.state_changed": BackendMessage< + "session.state_changed", + TaskSessionStateChangedPayload + >; + "session.waiting_for_input": BackendMessage< + "session.waiting_for_input", + TaskSessionWaitingForInputPayload + >; + "session.agentctl_starting": BackendMessage< + "session.agentctl_starting", + TaskSessionAgentctlPayload + >; + "session.agentctl_ready": BackendMessage<"session.agentctl_ready", TaskSessionAgentctlPayload>; + "session.agentctl_error": BackendMessage<"session.agentctl_error", TaskSessionAgentctlPayload>; + "session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>; + "session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>; + "session.available_commands": BackendMessage< + "session.available_commands", + AvailableCommandsPayload + >; + "session.mode_changed": BackendMessage<"session.mode_changed", SessionModeChangedPayload>; + "session.agent_capabilities": BackendMessage< + "session.agent_capabilities", + AgentCapabilitiesPayload + >; + "session.models_updated": BackendMessage<"session.models_updated", SessionModelsPayload>; + "session.info_updated": BackendMessage<"session.info_updated", SessionInfoPayload>; + "session.todos_updated": BackendMessage<"session.todos_updated", SessionTodosPayload>; + "session.prompt_usage": BackendMessage<"session.prompt_usage", SessionPromptUsagePayload>; + "session.poll_mode_changed": BackendMessage< + "session.poll_mode_changed", + { session_id: string; poll_mode: string } + >; + "executor.created": BackendMessage<"executor.created", ExecutorPayload>; + "executor.updated": BackendMessage<"executor.updated", ExecutorPayload>; + "executor.deleted": BackendMessage<"executor.deleted", ExecutorPayload>; + "executor.profile.created": BackendMessage<"executor.profile.created", ExecutorProfilePayload>; + "executor.profile.updated": BackendMessage<"executor.profile.updated", ExecutorProfilePayload>; + "executor.profile.deleted": BackendMessage<"executor.profile.deleted", { id: string }>; + "executor.prepare.progress": BackendMessage< + "executor.prepare.progress", + PrepareProgressPayload + >; + "executor.prepare.completed": BackendMessage< + "executor.prepare.completed", + PrepareCompletedPayload + >; + "environment.created": BackendMessage<"environment.created", EnvironmentPayload>; + "environment.updated": BackendMessage<"environment.updated", EnvironmentPayload>; + "environment.deleted": BackendMessage<"environment.deleted", EnvironmentPayload>; + "agent.profile.deleted": BackendMessage<"agent.profile.deleted", AgentProfileDeletedPayload>; + "agent.profile.created": BackendMessage<"agent.profile.created", AgentProfileChangedPayload>; + "agent.profile.updated": BackendMessage<"agent.profile.updated", AgentProfileChangedPayload>; + "user.settings.updated": BackendMessage<"user.settings.updated", UserSettingsUpdatedPayload>; + "session.workspace.file.changes": BackendMessage< + "session.workspace.file.changes", + FileChangeNotificationPayload + >; + "session.shell.output": BackendMessage<"session.shell.output", ShellOutputPayload>; + "session.process.output": BackendMessage<"session.process.output", ProcessOutputPayload>; + "session.process.status": BackendMessage<"session.process.status", ProcessStatusPayload>; + "secrets.created": BackendMessage<"secrets.created", SecretListItem>; + "secrets.updated": BackendMessage<"secrets.updated", SecretListItem>; + "secrets.deleted": BackendMessage<"secrets.deleted", { id: string }>; + "message.queue.status_changed": BackendMessage< + "message.queue.status_changed", + QueueStatusChangedPayload + >; + "github.task_pr.updated": BackendMessage<"github.task_pr.updated", TaskPR>; + "github.task_ci_options.updated": BackendMessage< + "github.task_ci_options.updated", + TaskCIAutomationOptions + >; + "github.rate_limit.updated": BackendMessage<"github.rate_limit.updated", GitHubRateLimitUpdate>; + "run.event.appended": BackendMessage<"run.event.appended", RunEventAppendedPayload>; + }; // Run event payload — the WS gateway forwards the bus event verbatim, // which means the run id sits at the top level and the full RunEvent diff --git a/apps/web/lib/types/context.ts b/apps/web/lib/types/context.ts index 75446a3942..0e6dc24567 100644 --- a/apps/web/lib/types/context.ts +++ b/apps/web/lib/types/context.ts @@ -1,4 +1,9 @@ -import type { DiffComment, PlanComment, PRFeedbackComment } from "@/lib/state/slices/comments"; +import type { + DiffComment, + PlanComment, + PRFeedbackComment, + WalkthroughComment, +} from "@/lib/state/slices/comments"; import type { FileAttachment } from "@/components/task/chat/file-attachment"; type ContextItemBase = { @@ -58,6 +63,12 @@ export type PRFeedbackContextItem = ContextItemBase & { onRemoveComment: (id: string) => void; }; +export type WalkthroughCommentContextItem = ContextItemBase & { + kind: "walkthrough-comment"; + comments: WalkthroughComment[]; + onRemoveComment: (id: string) => void; +}; + export type ContextItem = | PlanContextItem | FileContextItem @@ -66,6 +77,7 @@ export type ContextItem = | PlanCommentContextItem | ImageContextItem | FileAttachmentContextItem - | PRFeedbackContextItem; + | PRFeedbackContextItem + | WalkthroughCommentContextItem; export type ContextItemKind = ContextItem["kind"]; diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 267fbfb622..1fbc9e8c3a 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -17,6 +17,7 @@ export type { } from "./agent-profile"; import type { AgentProfile } from "./agent-profile"; +import type { BackendMessage } from "./backend-message"; export type TUIConfig = { command: string; @@ -290,6 +291,36 @@ export type TaskPlanResponse = { plan: TaskPlan | null; }; +/** A single anchored stop in a code walkthrough. */ +export type WalkthroughStep = { + title?: string; + repo?: string; + file: string; + line: number; + line_end?: number; + text: string; +}; + +/** An agent-authored guided code tour attached to a task. */ +export type TaskWalkthrough = { + id: string; + task_id: string; + title: string; + steps: WalkthroughStep[]; + created_by: "agent"; + created_at: string; + updated_at: string; +}; + +// Backend WS message map for walkthrough events, kept here (rather than in +// backend.ts) so backend.ts stays under its line cap — mirrors the way office +// events live in office-events.ts and fold into BackendMessageMap. +export type WalkthroughBackendMessageMap = { + "task.walkthrough.created": BackendMessage<"task.walkthrough.created", TaskWalkthrough>; + "task.walkthrough.updated": BackendMessage<"task.walkthrough.updated", TaskWalkthrough>; + "task.walkthrough.deleted": BackendMessage<"task.walkthrough.deleted", TaskWalkthrough>; +}; + // A single revision in the task plan history. `content` is optional because // list responses/WS broadcasts omit it for size; fetch detail separately. export type TaskPlanRevision = { diff --git a/apps/web/lib/types/workspace-files.ts b/apps/web/lib/types/workspace-files.ts index 743f7fe9e1..32e5a48601 100644 --- a/apps/web/lib/types/workspace-files.ts +++ b/apps/web/lib/types/workspace-files.ts @@ -47,6 +47,7 @@ export type FileChangeNotificationPayload = { export type OpenFileTab = { path: string; name: string; + repo?: string; content: string; originalContent: string; originalHash: string; diff --git a/apps/web/lib/walkthrough-editor-anchor.test.ts b/apps/web/lib/walkthrough-editor-anchor.test.ts new file mode 100644 index 0000000000..61533bd2f8 --- /dev/null +++ b/apps/web/lib/walkthrough-editor-anchor.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { + computeWalkthroughConnectorPath, + getWalkthroughEditorAnchor, + isWalkthroughAnchorTargetVisible, + setWalkthroughEditorAnchor, + subscribeWalkthroughEditorAnchor, + type WalkthroughViewportRect, +} from "./walkthrough-editor-anchor"; + +function rect(overrides: Partial): WalkthroughViewportRect { + const left = overrides.left ?? 0; + const top = overrides.top ?? 0; + const width = overrides.width ?? 100; + const height = overrides.height ?? 40; + return { + left, + top, + width, + height, + right: overrides.right ?? left + width, + bottom: overrides.bottom ?? top + height, + }; +} + +describe("computeWalkthroughConnectorPath", () => { + it("draws from the nearest card side to the walkthrough range", () => { + const path = computeWalkthroughConnectorPath( + rect({ left: 700, top: 80, width: 300, height: 220 }), + rect({ left: 100, top: 180, width: 500, height: 36 }), + ); + + expect(path).toMatch(/^M 700\.0 198\.0 C /); + expect(path).toContain("600.0 198.0"); + }); + + it("returns null for empty geometry", () => { + expect(computeWalkthroughConnectorPath(rect({ width: 0 }), rect({}))).toBeNull(); + }); +}); + +describe("isWalkthroughAnchorTargetVisible", () => { + it("returns true when the anchor point resolves inside the editor container", () => { + const container = document.createElement("div"); + const child = document.createElement("span"); + container.appendChild(child); + document.body.appendChild(container); + Object.defineProperty(container, "getBoundingClientRect", { + value: () => rect({ left: 0, top: 0, width: 300, height: 300 }), + }); + + expect( + isWalkthroughAnchorTargetVisible(container, rect({ left: 20, top: 20 }), () => child), + ).toBe(true); + + container.remove(); + }); + + it("returns false when a hidden dock tab leaves stale coordinates over another element", () => { + const container = document.createElement("div"); + const other = document.createElement("button"); + document.body.append(container, other); + Object.defineProperty(container, "getBoundingClientRect", { + value: () => rect({ left: 0, top: 0, width: 300, height: 300 }), + }); + + expect( + isWalkthroughAnchorTargetVisible(container, rect({ left: 20, top: 20 }), () => other), + ).toBe(false); + + container.remove(); + other.remove(); + }); +}); + +describe("setWalkthroughEditorAnchor", () => { + it("does not notify subscribers when the anchor did not change", () => { + const listener = vi.fn(); + const unsubscribe = subscribeWalkthroughEditorAnchor(listener); + const anchor = { + key: "task:0:repo:file.ts", + taskId: "task", + stepIndex: 0, + file: "file.ts", + repo: "repo", + line: 1, + lineEnd: 2, + rect: rect({ left: 10, top: 20 }), + container: document.createElement("div"), + }; + + setWalkthroughEditorAnchor(anchor); + setWalkthroughEditorAnchor({ ...anchor, rect: { ...anchor.rect } }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(getWalkthroughEditorAnchor()).toEqual(anchor); + setWalkthroughEditorAnchor(null); + unsubscribe(); + }); +}); diff --git a/apps/web/lib/walkthrough-editor-anchor.ts b/apps/web/lib/walkthrough-editor-anchor.ts new file mode 100644 index 0000000000..fde4f8a5d1 --- /dev/null +++ b/apps/web/lib/walkthrough-editor-anchor.ts @@ -0,0 +1,160 @@ +import { useSyncExternalStore } from "react"; + +export type WalkthroughViewportRect = { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +}; + +export type WalkthroughEditorAnchor = { + key: string; + taskId: string; + stepIndex: number; + file: string; + repo?: string; + line: number; + lineEnd: number; + rect: WalkthroughViewportRect; + container?: HTMLElement; +}; + +type Listener = () => void; +type ElementFromPoint = (x: number, y: number) => Element | null; + +let currentAnchor: WalkthroughEditorAnchor | null = null; +const listeners = new Set(); + +function emit() { + for (const listener of listeners) listener(); +} + +function rectsEqual(a: WalkthroughViewportRect, b: WalkthroughViewportRect): boolean { + return ( + a.left === b.left && + a.top === b.top && + a.right === b.right && + a.bottom === b.bottom && + a.width === b.width && + a.height === b.height + ); +} + +function anchorsEqual( + a: WalkthroughEditorAnchor | null, + b: WalkthroughEditorAnchor | null, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.key === b.key && + a.taskId === b.taskId && + a.stepIndex === b.stepIndex && + a.file === b.file && + a.repo === b.repo && + a.line === b.line && + a.lineEnd === b.lineEnd && + a.container === b.container && + rectsEqual(a.rect, b.rect) + ); +} + +export function setWalkthroughEditorAnchor(anchor: WalkthroughEditorAnchor | null): void { + if (anchorsEqual(currentAnchor, anchor)) return; + currentAnchor = anchor; + emit(); +} + +export function clearWalkthroughEditorAnchor(key: string): void { + if (currentAnchor?.key !== key) return; + currentAnchor = null; + emit(); +} + +export function getWalkthroughEditorAnchor(): WalkthroughEditorAnchor | null { + return currentAnchor; +} + +export function subscribeWalkthroughEditorAnchor(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useWalkthroughEditorAnchor(): WalkthroughEditorAnchor | null { + return useSyncExternalStore( + subscribeWalkthroughEditorAnchor, + getWalkthroughEditorAnchor, + getWalkthroughEditorAnchor, + ); +} + +function center(rect: WalkthroughViewportRect) { + return { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function rectIntersectsViewport(rect: WalkthroughViewportRect): boolean { + if (rect.width <= 0 || rect.height <= 0) return false; + if (typeof window === "undefined") return true; + return ( + rect.right > 0 && + rect.bottom > 0 && + rect.left < window.innerWidth && + rect.top < window.innerHeight + ); +} + +export function isWalkthroughAnchorTargetVisible( + container: HTMLElement | null, + rect: WalkthroughViewportRect, + elementFromPoint: ElementFromPoint = (x, y) => document.elementFromPoint(x, y), +): boolean { + if (!container?.isConnected || !rectIntersectsViewport(rect)) return false; + const containerRect = container.getBoundingClientRect(); + if (containerRect.width <= 0 || containerRect.height <= 0) return false; + const style = window.getComputedStyle(container); + if (style.display === "none" || style.visibility === "hidden") return false; + + const point = center(rect); + if (point.x < 0 || point.y < 0 || point.x > window.innerWidth || point.y > window.innerHeight) { + return false; + } + const elementAtPoint = elementFromPoint(point.x, point.y); + return !!elementAtPoint && container.contains(elementAtPoint); +} + +export function computeWalkthroughConnectorPath( + card: WalkthroughViewportRect, + anchor: WalkthroughViewportRect, +): string | null { + if (card.width <= 0 || card.height <= 0 || anchor.width <= 0 || anchor.height <= 0) return null; + + const cardCenter = center(card); + const anchorCenter = center(anchor); + const cardIsLeft = cardCenter.x < anchorCenter.x; + const source = { + x: cardIsLeft ? card.right : card.left, + y: clamp(anchorCenter.y, card.top + 24, card.bottom - 24), + }; + const target = { + x: cardIsLeft ? anchor.left : anchor.right, + y: anchorCenter.y, + }; + const direction = cardIsLeft ? 1 : -1; + const curve = Math.max(48, Math.min(220, Math.abs(target.x - source.x) * 0.45)); + + return [ + `M ${source.x.toFixed(1)} ${source.y.toFixed(1)}`, + `C ${(source.x + direction * curve).toFixed(1)} ${source.y.toFixed(1)}`, + `${(target.x - direction * curve).toFixed(1)} ${target.y.toFixed(1)}`, + `${target.x.toFixed(1)} ${target.y.toFixed(1)}`, + ].join(" "); +} diff --git a/apps/web/lib/walkthrough-editor-range.ts b/apps/web/lib/walkthrough-editor-range.ts new file mode 100644 index 0000000000..5a4f37013f --- /dev/null +++ b/apps/web/lib/walkthrough-editor-range.ts @@ -0,0 +1,28 @@ +import { + walkthroughStepMatchesFile, + type WalkthroughTargetFile, +} from "@/lib/diff/walkthrough-match"; +import type { WalkthroughStep } from "@/lib/types/http"; + +export type WalkthroughEditorRange = { + startLine: number; + endLine: number; +}; + +export type WalkthroughRangeBox = WalkthroughEditorRange & { + top: number; + left: number; + width: number; + height: number; +}; + +export function getWalkthroughEditorRange( + file: WalkthroughTargetFile, + step: WalkthroughStep | null | undefined, +): WalkthroughEditorRange | null { + if (!step || !walkthroughStepMatchesFile(file, step)) return null; + return { + startLine: Math.min(step.line, step.line_end ?? step.line), + endLine: Math.max(step.line, step.line_end ?? step.line), + }; +} diff --git a/apps/web/lib/walkthrough-notification-storage.ts b/apps/web/lib/walkthrough-notification-storage.ts new file mode 100644 index 0000000000..3f80371d22 --- /dev/null +++ b/apps/web/lib/walkthrough-notification-storage.ts @@ -0,0 +1,41 @@ +const WALKTHROUGH_NOTIFICATION_KEY = "kandev.walkthrough.lastSeenByTask"; + +export type WalkthroughNotificationState = Record; + +function readState(): WalkthroughNotificationState { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(WALKTHROUGH_NOTIFICATION_KEY); + return raw ? (JSON.parse(raw) as WalkthroughNotificationState) : {}; + } catch { + return {}; + } +} + +function writeState(state: WalkthroughNotificationState): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(WALKTHROUGH_NOTIFICATION_KEY, JSON.stringify(state)); + } catch { + // Ignore write failures. + } +} + +export function getWalkthroughNotificationState(): WalkthroughNotificationState { + return readState(); +} + +export function setWalkthroughLastSeen(taskId: string, timestamp: string | null): void { + const state = readState(); + if (timestamp === null) { + delete state[taskId]; + } else { + state[taskId] = timestamp; + } + writeState(state); +} + +export function getWalkthroughLastSeen(taskId: string): string | null { + const state = readState(); + return state[taskId] ?? null; +} diff --git a/apps/web/lib/walkthrough-open-state.test.ts b/apps/web/lib/walkthrough-open-state.test.ts new file mode 100644 index 0000000000..2088fa10b7 --- /dev/null +++ b/apps/web/lib/walkthrough-open-state.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { + clearOpenWalkthroughTaskId, + getOpenWalkthroughTaskId, + setOpenWalkthroughTaskId, + subscribeOpenWalkthroughTask, +} from "./walkthrough-open-state"; + +describe("walkthrough open state", () => { + it("tracks the currently open walkthrough task", () => { + setOpenWalkthroughTaskId(null); + + setOpenWalkthroughTaskId("task-1"); + expect(getOpenWalkthroughTaskId()).toBe("task-1"); + + clearOpenWalkthroughTaskId("other-task"); + expect(getOpenWalkthroughTaskId()).toBe("task-1"); + + clearOpenWalkthroughTaskId("task-1"); + expect(getOpenWalkthroughTaskId()).toBeNull(); + }); + + it("notifies subscribers when the open task changes", () => { + setOpenWalkthroughTaskId(null); + let calls = 0; + const unsubscribe = subscribeOpenWalkthroughTask(() => { + calls += 1; + }); + + setOpenWalkthroughTaskId("task-1"); + setOpenWalkthroughTaskId("task-1"); + clearOpenWalkthroughTaskId("task-1"); + unsubscribe(); + setOpenWalkthroughTaskId("task-2"); + + expect(calls).toBe(2); + setOpenWalkthroughTaskId(null); + }); +}); diff --git a/apps/web/lib/walkthrough-open-state.ts b/apps/web/lib/walkthrough-open-state.ts new file mode 100644 index 0000000000..dcbbaff874 --- /dev/null +++ b/apps/web/lib/walkthrough-open-state.ts @@ -0,0 +1,36 @@ +import { useCallback, useSyncExternalStore } from "react"; + +type Listener = () => void; + +let openTaskId: string | null = null; +const listeners = new Set(); + +function emit() { + for (const listener of listeners) listener(); +} + +export function setOpenWalkthroughTaskId(taskId: string | null): void { + if (openTaskId === taskId) return; + openTaskId = taskId; + emit(); +} + +export function clearOpenWalkthroughTaskId(taskId: string): void { + if (openTaskId !== taskId) return; + openTaskId = null; + emit(); +} + +export function getOpenWalkthroughTaskId(): string | null { + return openTaskId; +} + +export function subscribeOpenWalkthroughTask(listener: Listener): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useIsWalkthroughOpenForTask(taskId: string | null | undefined): boolean { + const getSnapshot = useCallback(() => !!taskId && openTaskId === taskId, [taskId]); + return useSyncExternalStore(subscribeOpenWalkthroughTask, getSnapshot, getSnapshot); +} diff --git a/apps/web/lib/walkthrough-prompt-files.test.ts b/apps/web/lib/walkthrough-prompt-files.test.ts new file mode 100644 index 0000000000..e94a96dc54 --- /dev/null +++ b/apps/web/lib/walkthrough-prompt-files.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { buildWalkthroughPromptFiles } from "./walkthrough-prompt-files"; + +const APP_PATH = "src/app.ts"; + +describe("buildWalkthroughPromptFiles", () => { + it("includes working tree, staged, committed, and PR files in review order", () => { + const files = buildWalkthroughPromptFiles({ + unstagedFiles: [{ path: APP_PATH }], + stagedFiles: [{ path: "src/staged.ts", repositoryName: "web" }], + committedFiles: { + "src/committed.ts": { path: "src/committed.ts" }, + }, + prFiles: [{ path: "src/pr.ts", repository_name: "backend" }], + }); + + expect(files).toEqual([ + { path: APP_PATH, source: "uncommitted" }, + { path: "src/staged.ts", repository_name: "web", source: "staged" }, + { path: "src/committed.ts", source: "committed" }, + { path: "src/pr.ts", repository_name: "backend", source: "pr" }, + ]); + }); + + it("keeps the freshest local source when cumulative diff repeats a file", () => { + const files = buildWalkthroughPromptFiles({ + unstagedFiles: [{ path: APP_PATH }], + stagedFiles: [], + committedFiles: { + [APP_PATH]: { path: APP_PATH }, + "src/other.ts": { path: "src/other.ts" }, + }, + prFiles: [], + }); + + expect(files).toEqual([ + { path: APP_PATH, source: "uncommitted" }, + { path: "src/other.ts", source: "committed" }, + ]); + }); + + it("falls back to composite cumulative-diff keys when the payload lacks path fields", () => { + const files = buildWalkthroughPromptFiles({ + unstagedFiles: [], + stagedFiles: [], + committedFiles: { + "backend\u0000src/server.go": {}, + }, + prFiles: [], + }); + + expect(files).toEqual([ + { path: "src/server.go", repository_name: "backend", source: "committed" }, + ]); + }); +}); diff --git a/apps/web/lib/walkthrough-prompt-files.ts b/apps/web/lib/walkthrough-prompt-files.ts new file mode 100644 index 0000000000..eb2dcb6da3 --- /dev/null +++ b/apps/web/lib/walkthrough-prompt-files.ts @@ -0,0 +1,78 @@ +import type { WalkthroughPromptFile } from "./walkthrough-request"; + +type PromptFileInput = { + path?: string; + repositoryName?: string; + repository_name?: string; +}; + +type CumulativePromptFileInput = PromptFileInput & { + diff?: string; +}; + +type PromptFileSource = "uncommitted" | "staged" | "committed" | "pr"; + +const FILE_KEY_SEP = "\u0000"; + +function splitCompositeKey(key: string): { repo: string; path: string } { + const sep = key.indexOf(FILE_KEY_SEP); + if (sep < 0) return { repo: "", path: key }; + return { repo: key.slice(0, sep), path: key.slice(sep + 1) }; +} + +function normalizePromptFile( + source: PromptFileSource, + file: PromptFileInput, + fallbackKey?: string, +): WalkthroughPromptFile | null { + const fallback = fallbackKey ? splitCompositeKey(fallbackKey) : { repo: "", path: "" }; + const path = file.path || fallback.path; + if (!path) return null; + const repo = file.repository_name ?? file.repositoryName ?? fallback.repo; + return { + path, + ...(repo ? { repository_name: repo } : {}), + source, + }; +} + +function promptKey(file: WalkthroughPromptFile): string { + return `${file.repository_name ?? file.repositoryName ?? ""}\0${file.path}`; +} + +function addPromptFile( + out: WalkthroughPromptFile[], + seen: Set, + source: PromptFileSource, + file: PromptFileInput, + fallbackKey?: string, +) { + const promptFile = normalizePromptFile(source, file, fallbackKey); + if (!promptFile) return; + const key = promptKey(promptFile); + if (seen.has(key)) return; + seen.add(key); + out.push(promptFile); +} + +export function buildWalkthroughPromptFiles({ + unstagedFiles, + stagedFiles, + committedFiles, + prFiles, +}: { + unstagedFiles: PromptFileInput[]; + stagedFiles: PromptFileInput[]; + committedFiles?: Record | null; + prFiles: PromptFileInput[]; +}): WalkthroughPromptFile[] { + const out: WalkthroughPromptFile[] = []; + const seen = new Set(); + for (const file of unstagedFiles) addPromptFile(out, seen, "uncommitted", file); + for (const file of stagedFiles) addPromptFile(out, seen, "staged", file); + for (const [key, file] of Object.entries(committedFiles ?? {})) { + addPromptFile(out, seen, "committed", file, key); + } + for (const file of prFiles) addPromptFile(out, seen, "pr", file); + return out; +} diff --git a/apps/web/lib/walkthrough-request.test.ts b/apps/web/lib/walkthrough-request.test.ts new file mode 100644 index 0000000000..28952f8c31 --- /dev/null +++ b/apps/web/lib/walkthrough-request.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + buildChangesWalkthroughPrompt, + formatChangedFilesForWalkthroughPrompt, +} from "./walkthrough-request"; + +describe("buildChangesWalkthroughPrompt", () => { + it("sends the saved prompt reference visibly and hides the expanded prompt content", () => { + const prompt = buildChangesWalkthroughPrompt("CUSTOM_PROMPT\n{{changed_files}}", [ + { path: "src/app.ts", source: "uncommitted" }, + { path: "src/review.ts", repository_name: "web", source: "pr" }, + ]); + + expect(prompt).toMatch(/^@changes-walkthrough\n\nChanged files:/); + expect(prompt).toContain(""); + expect(prompt).toContain("EXPANDED PROMPT REFERENCES"); + expect(prompt).toContain("### @changes-walkthrough"); + expect(prompt).toContain("CUSTOM_PROMPT"); + expect(prompt).toContain("- src/app.ts [uncommitted]"); + expect(prompt).toContain("- web:src/review.ts [pr]"); + expect(prompt).not.toContain("{{changed_files}}"); + }); + + it("deduplicates repeated files and caps the file list", () => { + const many = Array.from({ length: 85 }, (_, index) => ({ + path: `src/file-${index}.ts`, + source: "committed", + })); + const prompt = buildChangesWalkthroughPrompt("{{changed_files}}", [many[0], ...many]); + + expect(prompt.match(/src\/file-0\.ts/g)).toHaveLength(1); + expect(prompt).toContain("5 more file(s) omitted"); + }); + + it("escapes file metadata line breaks before prompt interpolation", () => { + const files = [ + { + path: "src/app.ts\n- injected", + repository_name: "web\nrepo", + source: "pr\rsource", + }, + ]; + + expect(formatChangedFilesForWalkthroughPrompt(files)).toContain( + "- web\\nrepo:src/app.ts\\n- injected [pr\\rsource]", + ); + }); + + it("keeps the changed file list visible when a customized prompt omits the placeholder", () => { + const prompt = buildChangesWalkthroughPrompt("CUSTOM_WITHOUT_PLACEHOLDER", [ + { path: "src/app.ts", source: "committed" }, + ]); + + expect(prompt).toMatch(/^@changes-walkthrough\n\nChanged files:/); + expect(prompt).toContain("CUSTOM_WITHOUT_PLACEHOLDER"); + expect(prompt).toContain("- src/app.ts [committed]"); + }); +}); + +describe("formatChangedFilesForWalkthroughPrompt", () => { + it("renders an explicit loading fallback when no files are available", () => { + expect(formatChangedFilesForWalkthroughPrompt([])).toContain( + "No changed files were listed by the UI", + ); + }); +}); diff --git a/apps/web/lib/walkthrough-request.ts b/apps/web/lib/walkthrough-request.ts new file mode 100644 index 0000000000..ea763a5262 --- /dev/null +++ b/apps/web/lib/walkthrough-request.ts @@ -0,0 +1,71 @@ +import { formatPromptReferenceExpansions } from "@/lib/prompts/expand-prompt-references"; + +export type WalkthroughPromptFile = { + path: string; + repository_name?: string; + repositoryName?: string; + source?: "uncommitted" | "committed" | "pr" | string; +}; + +const MAX_PROMPT_FILES = 80; +export const CHANGES_WALKTHROUGH_PROMPT_NAME = "changes-walkthrough"; +const CHANGED_FILES_PLACEHOLDER = "{{changed_files}}"; +const CHANGED_FILES_REFERENCE = "The changed files are listed in the user-visible message above."; + +function fileKey(file: WalkthroughPromptFile): string { + return `${file.repository_name ?? file.repositoryName ?? ""}\0${file.path}\0${file.source ?? ""}`; +} + +function escapePromptFilePart(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("\r", "\\r").replaceAll("\n", "\\n"); +} + +function formatPromptFile(file: WalkthroughPromptFile): string { + const repo = escapePromptFilePart(file.repository_name ?? file.repositoryName ?? ""); + const path = escapePromptFilePart(file.path); + const source = file.source ? ` [${escapePromptFilePart(file.source)}]` : ""; + return repo ? `${repo}:${path}${source}` : `${path}${source}`; +} + +export function formatChangedFilesForWalkthroughPrompt(files: WalkthroughPromptFile[]): string { + const uniqueFiles: WalkthroughPromptFile[] = []; + const seen = new Set(); + for (const file of files) { + if (!file.path) continue; + const key = fileKey(file); + if (seen.has(key)) continue; + seen.add(key); + uniqueFiles.push(file); + } + + const shown = uniqueFiles.slice(0, MAX_PROMPT_FILES); + const omitted = uniqueFiles.length - shown.length; + return shown.length > 0 + ? shown.map((file) => `- ${formatPromptFile(file)}`).join("\n") + + (omitted > 0 ? `\n- ... ${omitted} more file(s) omitted from this prompt` : "") + : "- No changed files were listed by the UI; inspect the local task state before anchoring."; +} + +export function buildChangesWalkthroughPrompt( + template: string, + files: WalkthroughPromptFile[], +): string { + const changedFiles = formatChangedFilesForWalkthroughPrompt(files); + const trimmedTemplate = template.trim(); + const expansionContent = trimmedTemplate.includes(CHANGED_FILES_PLACEHOLDER) + ? trimmedTemplate.replaceAll(CHANGED_FILES_PLACEHOLDER, CHANGED_FILES_REFERENCE) + : trimmedTemplate; + const expansionContext = formatPromptReferenceExpansions([ + { name: CHANGES_WALKTHROUGH_PROMPT_NAME, content: expansionContent }, + ]); + return [ + `@${CHANGES_WALKTHROUGH_PROMPT_NAME}`, + "", + "Changed files:", + changedFiles, + "", + "", + expansionContext, + "", + ].join("\n"); +} diff --git a/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts b/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts new file mode 100644 index 0000000000..c5e288b948 --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; + +type Listener = (state: AppState) => void; + +function makeStore() { + let state = { + kanban: { + workflowId: "wf1", + steps: [], + tasks: [{ id: "t1", primarySessionId: "sess-1", workflowId: "wf1" }], + }, + kanbanMulti: { snapshots: {}, isLoading: false }, + tasks: { + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, + environmentIdBySessionId: {}, + walkthroughs: { + byTaskId: { + t1: { + id: "wt-1", + task_id: "t1", + title: "Walkthrough", + steps: [], + created_by: "agent", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + t2: null, + }, + activeStepByTaskId: { t1: 2, t2: 1 }, + lastSeenUpdatedAtByTaskId: { t1: "old", t2: "keep" }, + }, + removeTaskFromSidebarPrefs: vi.fn(), + setTaskDeletedNotification: vi.fn(), + } as unknown as AppState; + + const listeners = new Set(); + return { + getState: () => state, + setState: (updater: AppState | ((s: AppState) => AppState)) => { + const next = + typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; + state = { ...state, ...next }; + for (const listener of listeners) listener(state); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + destroy: vi.fn(), + getInitialState: vi.fn(), + } as unknown as StoreApi & { getState: () => AppState }; +} + +function makeDeletedMessage() { + return { + id: "msg-1", + type: "notification" as const, + action: "task.deleted" as const, + payload: { task_id: "t1", workflow_id: "wf1" }, + } as Parameters["task.deleted"]>>[0]; +} + +describe("task.deleted walkthrough cleanup", () => { + beforeEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); + }); + + it("clears deleted task walkthrough state without touching other tasks", () => { + const store = makeStore(); + const handlers = registerTasksHandlers(store); + + handlers["task.deleted"]!(makeDeletedMessage()); + + const state = store.getState(); + expect(state.walkthroughs.byTaskId).not.toHaveProperty("t1"); + expect(state.walkthroughs.activeStepByTaskId).not.toHaveProperty("t1"); + expect(state.walkthroughs.lastSeenUpdatedAtByTaskId).not.toHaveProperty("t1"); + expect(state.walkthroughs.byTaskId).toHaveProperty("t2", null); + expect(state.walkthroughs.lastSeenUpdatedAtByTaskId).toHaveProperty("t2", "keep"); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index a82436f802..e7b8e244bf 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -251,6 +251,24 @@ function clearRemovedTaskSelection(state: AppState, taskId: string): AppState { return next; } +function clearDeletedTaskWalkthrough(state: AppState, taskId: string): AppState { + if (!state.walkthroughs?.byTaskId) return state; + if (!(taskId in state.walkthroughs.byTaskId)) return state; + const { [taskId]: _removedWalkthrough, ...byTaskId } = state.walkthroughs.byTaskId; + const { [taskId]: _removedStep, ...activeStepByTaskId } = state.walkthroughs.activeStepByTaskId; + const { [taskId]: _removedLastSeen, ...lastSeenUpdatedAtByTaskId } = + state.walkthroughs.lastSeenUpdatedAtByTaskId; + return { + ...state, + walkthroughs: { + ...state.walkthroughs, + byTaskId, + activeStepByTaskId, + lastSeenUpdatedAtByTaskId, + }, + }; +} + function removedTaskRedirectHref(pathname: string, taskId: string): string | null { if (isTaskDetailPath(pathname, taskId)) return "/"; const normalized = normalizePathname(pathname); @@ -392,7 +410,10 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { const wasActive = currentState.tasks.activeTaskId === deletedId; store.setState((state) => - clearRemovedTaskSelection(removeTaskFromBothKanbans(state, deletedId), deletedId), + clearDeletedTaskWalkthrough( + clearRemovedTaskSelection(removeTaskFromBothKanbans(state, deletedId), deletedId), + deletedId, + ), ); // Capture the route match before any redirect mutates the pathname. This diff --git a/apps/web/lib/ws/handlers/walkthroughs.test.ts b/apps/web/lib/ws/handlers/walkthroughs.test.ts new file mode 100644 index 0000000000..451faa3c79 --- /dev/null +++ b/apps/web/lib/ws/handlers/walkthroughs.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; + +import { registerWalkthroughsHandlers } from "./walkthroughs"; + +const TASK_ID = "task-1"; +const ACTION_CREATED = "task.walkthrough.created"; +const ACTION_UPDATED = "task.walkthrough.updated"; +const ACTION_DELETED = "task.walkthrough.deleted"; + +function makePayload(overrides: Partial> = {}) { + return { + id: "wt-1", + task_id: TASK_ID, + title: "Walkthrough", + steps: [{ file: "a.go", line: 1, text: "first" }], + created_by: "agent", + created_at: "2026-04-20T00:00:00Z", + updated_at: "2026-04-20T00:00:00Z", + ...overrides, + }; +} + +function makeMessage(action: string, payload: Record) { + return { id: "msg-1", type: "notification", action, payload }; +} + +function makeStore() { + const state = { + tasks: { activeTaskId: TASK_ID, activeSessionId: "s-1" }, + walkthroughs: { byTaskId: {}, activeStepByTaskId: {}, lastSeenUpdatedAtByTaskId: {} }, + setWalkthrough: vi.fn(), + markWalkthroughSeen: vi.fn(), + }; + return { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(), + destroy: vi.fn(), + getInitialState: vi.fn(), + } as unknown as StoreApi; +} + +describe("task.walkthrough.* handlers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("created: stores the walkthrough with its steps", () => { + const store = makeStore(); + const handlers = registerWalkthroughsHandlers(store); + + handlers[ACTION_CREATED]!( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeMessage(ACTION_CREATED, makePayload()) as any, + ); + + expect(store.getState().setWalkthrough).toHaveBeenCalledWith( + TASK_ID, + expect.objectContaining({ id: "wt-1", steps: expect.any(Array) }), + ); + expect(store.getState().markWalkthroughSeen).not.toHaveBeenCalled(); + }); + + it("updated: re-stores the walkthrough", () => { + const store = makeStore(); + const handlers = registerWalkthroughsHandlers(store); + + handlers[ACTION_UPDATED]!( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeMessage(ACTION_UPDATED, makePayload({ title: "v2" })) as any, + ); + + expect(store.getState().setWalkthrough).toHaveBeenCalledWith( + TASK_ID, + expect.objectContaining({ title: "v2" }), + ); + }); + + it("deleted: clears the walkthrough and marks seen", () => { + const store = makeStore(); + const handlers = registerWalkthroughsHandlers(store); + + handlers[ACTION_DELETED]!( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + makeMessage(ACTION_DELETED, makePayload()) as any, + ); + + expect(store.getState().setWalkthrough).toHaveBeenCalledWith(TASK_ID, null); + expect(store.getState().markWalkthroughSeen).toHaveBeenCalledWith(TASK_ID); + }); +}); diff --git a/apps/web/lib/ws/handlers/walkthroughs.ts b/apps/web/lib/ws/handlers/walkthroughs.ts new file mode 100644 index 0000000000..c596d1b05e --- /dev/null +++ b/apps/web/lib/ws/handlers/walkthroughs.ts @@ -0,0 +1,32 @@ +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import type { WsHandlers } from "@/lib/ws/handlers/types"; + +type WalkthroughMessage = + | BackendMessageMap["task.walkthrough.created"] + | BackendMessageMap["task.walkthrough.updated"]; + +function handleWalkthroughUpsert(store: StoreApi, message: WalkthroughMessage) { + const { task_id, id, title, steps, created_by, created_at, updated_at } = message.payload; + store.getState().setWalkthrough(task_id, { + id, + task_id, + title, + steps, + created_by, + created_at, + updated_at, + }); +} + +export function registerWalkthroughsHandlers(store: StoreApi): WsHandlers { + return { + "task.walkthrough.created": (message) => handleWalkthroughUpsert(store, message), + "task.walkthrough.updated": (message) => handleWalkthroughUpsert(store, message), + "task.walkthrough.deleted": (message) => { + store.getState().setWalkthrough(message.payload.task_id, null); + store.getState().markWalkthroughSeen(message.payload.task_id); + }, + }; +} diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index 216a86ceb4..7a2611e572 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -23,6 +23,7 @@ import { registerKanbanHandlers } from "@/lib/ws/handlers/kanban"; import { registerSystemEventsHandlers } from "@/lib/ws/handlers/system-events"; import { registerTasksHandlers } from "@/lib/ws/handlers/tasks"; import { registerTaskPlansHandlers } from "@/lib/ws/handlers/task-plans"; +import { registerWalkthroughsHandlers } from "@/lib/ws/handlers/walkthroughs"; import { registerTerminalsHandlers } from "@/lib/ws/handlers/terminals"; import { registerTurnsHandlers } from "@/lib/ws/handlers/turns"; import { registerSecretsHandlers } from "@/lib/ws/handlers/secrets"; @@ -37,6 +38,7 @@ export function registerWsHandlers(store: StoreApi) { ...registerKanbanHandlers(store), ...registerTasksHandlers(store), ...registerTaskPlansHandlers(store), + ...registerWalkthroughsHandlers(store), ...registerWorkflowsHandlers(store), ...registerWorkspacesHandlers(store),