diff --git a/desktop/src/api/types.ts b/desktop/src/api/types.ts index c70a7bda..ea89503e 100644 --- a/desktop/src/api/types.ts +++ b/desktop/src/api/types.ts @@ -31,6 +31,7 @@ export interface Task { pr_number?: number; pr?: PRStatus; summary?: string; + stand?: string; created_at: string; updated_at: string; started_at?: string; diff --git a/desktop/src/components/Board.tsx b/desktop/src/components/Board.tsx index e1d0a468..1f27bfab 100644 --- a/desktop/src/components/Board.tsx +++ b/desktop/src/components/Board.tsx @@ -159,10 +159,42 @@ function cardPropsEqual(prev: CardProps, next: CardProps): boolean { a.pr?.deletions === b.pr?.deletions && a.executor === b.executor && a.project === b.project && - a.updated_at === b.updated_at + a.updated_at === b.updated_at && + a.stand === b.stand && + a.summary === b.summary ); } +function isNoiseLog(content?: string): boolean { + if (!content) return true; + const s = content.trim().toLowerCase(); + return s.includes("reconnecting to") || s.startsWith("---"); +} + +function clampStand(s: string): string { + return s.trim().split(/\s+/).filter(Boolean).slice(0, 5).join(" "); +} + +function fallbackStand(log?: LogLine): string { + if (!log || isNoiseLog(log.content)) return ""; + const line = log.content.split("\n")[0]?.trim() ?? ""; + if (log.line_type === "question" || line.endsWith("?")) return clampStand(line); + return ""; +} + +function cardSubLine(task: Task, latest?: LogLine): { text: string; title?: string } { + if (task.status === "processing") { + const crumb = latest && !isNoiseLog(latest.content) ? latest.content.split("\n")[0]?.trim() : ""; + return crumb ? { text: crumb, title: crumb } : { text: ageHint(task) }; + } + if (task.status === "blocked") { + if (task.stand) return { text: task.stand, title: task.stand }; + const fb = fallbackStand(latest); + if (fb) return { text: fb, title: fb }; + } + return { text: ageHint(task) }; +} + const CardSlot = memo(function CardSlot({ task, selected, projectColor, latest }: CardProps) { const ref = useRef(null); const spinner = useSpinner(task.status === "processing"); @@ -173,6 +205,7 @@ const CardSlot = memo(function CardSlot({ task, selected, projectColor, latest } const isQueued = task.status === "queued"; const needsInput = task.status === "blocked"; + const subLine = cardSubLine(task, latest); return (
- {latest && (task.status === "processing" || task.status === "blocked") ? ( - {latest.content} - ) : ( - {ageHint(task)} - )} + {subLine.text}
{task.pinned && } diff --git a/desktop/src/components/DetailView.tsx b/desktop/src/components/DetailView.tsx index 5b34c1c5..266a6069 100644 --- a/desktop/src/components/DetailView.tsx +++ b/desktop/src/components/DetailView.tsx @@ -258,6 +258,16 @@ export function DetailView({ taskId }: { taskId: number }) { Status
+ {task.stand && ( +
+ {task.stand} +
+ )}
@@ -267,7 +277,7 @@ export function DetailView({ taskId }: { taskId: number }) { No description )} - {task.summary && ( + {task.summary && !task.stand && ( <> Summary diff --git a/internal/completion/complete.go b/internal/completion/complete.go index c3aca0c6..e6385d33 100644 --- a/internal/completion/complete.go +++ b/internal/completion/complete.go @@ -140,6 +140,7 @@ func Complete(database *db.DB, taskID int64, summary string, opts Options) (*Out // Logged as a "question" so it lands in the blocked/needs-input lane and the // daemon sweep leaves it for the human instead of auto-completing it. database.AppendTaskLog(taskID, "question", pipeline.GateStepParkedLog) + tasksummary.KickoffRewrite(database, taskID) return &Outcome{Kind: KindGateParked}, nil } @@ -158,6 +159,7 @@ func Complete(database *db.DB, taskID int64, summary string, opts Options) (*Out reviewMsg += " " + prURL } database.AppendTaskLog(taskID, "question", reviewMsg) + tasksummary.KickoffRewrite(database, taskID) return &Outcome{Kind: KindPRReview, PRNumber: prNumber, PRURL: prURL}, nil } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 1d0fbd02..f8ab86e1 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -29,6 +29,7 @@ import ( "github.com/bborn/workflow/internal/github" "github.com/bborn/workflow/internal/hooks" "github.com/bborn/workflow/internal/pipeline" + "github.com/bborn/workflow/internal/tasksummary" ) // TaskEvent represents a change to a task. @@ -1207,6 +1208,7 @@ func (e *Executor) updateStatus(taskID int64, status string) error { if err := e.db.UpdateTaskStatus(taskID, status); err != nil { return err } + tasksummary.KickoffOnStatusChange(e.db, oldStatus, status, taskID) // Fetch updated task and broadcast task, err := e.db.GetTask(taskID) diff --git a/internal/tasksummary/stand.go b/internal/tasksummary/stand.go new file mode 100644 index 00000000..52d90717 --- /dev/null +++ b/internal/tasksummary/stand.go @@ -0,0 +1,217 @@ +package tasksummary + +import ( + "context" + "strings" + "time" + "unicode/utf8" + + "github.com/bborn/workflow/internal/db" +) + +const rewriteTimeout = 20 * time.Second + +// KickoffRewrite starts a background Force rewrite of the stand line. +func KickoffRewrite(database *db.DB, taskID int64) { + if database == nil || taskID <= 0 { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), rewriteTimeout) + defer cancel() + _, _ = GenerateAndStoreForce(ctx, database, taskID) + }() +} + +// KickoffGenerate starts a background skip-if-exists generation (freeze-on-done). +func KickoffGenerate(database *db.DB, taskID int64) { + if database == nil || taskID <= 0 { + return + } + go func() { + ctx, cancel := context.WithTimeout(context.Background(), rewriteTimeout) + defer cancel() + _, _ = GenerateAndStore(ctx, database, taskID) + }() +} + +// KickoffOnStatusChange rewrites the stand when a task first enters blocked. +func KickoffOnStatusChange(database *db.DB, oldStatus, newStatus string, taskID int64) { + if ShouldRewriteOnStatus(oldStatus, newStatus) { + KickoffRewrite(database, taskID) + } +} + +// StandMaxWords is the sticky-note length: enough to queue the brain, short +// enough to fit a kanban card without ellipsis. +const StandMaxWords = 5 + +// StandMaxChars is a hard cap after the word clamp (very long tokens). +const StandMaxChars = 40 + +// IsStandLine reports whether summary is a one-line stand (queue-the-brain), +// not an old 2–4 bullet recap. Fossils must not render as the stand. +func IsStandLine(summary string) bool { + s := strings.TrimSpace(summary) + if s == "" { + return false + } + if strings.ContainsAny(s, "\n\r") { + return false + } + if isBulletLine(s) { + return false + } + return true +} + +// DisplayStand returns the stand to show, or empty when summary is a fossil +// recap / empty. Long valid stands are clamped to StandMaxWords. +func DisplayStand(summary string) string { + if !IsStandLine(summary) { + return "" + } + return clampStand(strings.TrimSpace(summary)) +} + +// FallbackStand is the card/header fallback when there is no stand: the +// agent's question. Reconnect / continuation noise is never shown. +func FallbackStand(log *db.TaskLog) string { + if log == nil { + return "" + } + if IsNoiseLog(log.Content) { + return "" + } + line := firstLine(log.Content) + if line == "" { + return "" + } + if log.LineType == "question" || strings.HasSuffix(line, "?") { + return clampStand(line) + } + return "" +} + +// IsNoiseLog reports log lines that must never appear as a stand fallback +// (session reconnects, continuation markers, empty). +func IsNoiseLog(content string) bool { + s := strings.TrimSpace(content) + if s == "" { + return true + } + lower := strings.ToLower(s) + if strings.Contains(lower, "reconnecting to") { + return true + } + if strings.HasPrefix(s, "---") { + return true + } + return false +} + +// NeedsRefresh reports whether a stand should be rewritten for this task. +// Frozen on done/archived. Not while running (live crumb is enough). +// On blocked: rewrite fossils, or when new logs arrived after last distill. +func NeedsRefresh(task *db.Task, latest *db.TaskLog) bool { + if task == nil { + return false + } + switch task.Status { + case db.StatusDone, db.StatusArchived, db.StatusProcessing, db.StatusQueued: + return false + } + if task.Status != db.StatusBlocked { + return false + } + if !IsStandLine(task.Summary) || oversizedStand(task.Summary) { + return true + } + if latest == nil || task.LastDistilledAt == nil { + return false + } + return latest.CreatedAt.Time.After(task.LastDistilledAt.Time) +} + +// ShouldRewriteOnStatus is true on the transition into blocked. That is the +// attention-change rewrite: the stand is the question now in front of you. +func ShouldRewriteOnStatus(oldStatus, newStatus string) bool { + return newStatus == db.StatusBlocked && oldStatus != db.StatusBlocked +} + +// NormalizeStand flattens model output into a single stored stand line. +func NormalizeStand(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, `"'`) + s = strings.TrimSpace(s) + if idx := strings.IndexAny(s, "\n\r"); idx >= 0 { + s = strings.TrimSpace(s[:idx]) + } + s = strings.Trim(s, `"'`) + s = strings.TrimSpace(s) + if isBulletLine(s) { + s = stripBulletPrefix(s) + } + return clampStand(s) +} + +func oversizedStand(s string) bool { + return len(strings.Fields(strings.TrimSpace(s))) > StandMaxWords +} + +func clampStand(s string) string { + fields := strings.Fields(s) + if len(fields) > StandMaxWords { + s = strings.Join(fields[:StandMaxWords], " ") + } + return truncateRunes(s, StandMaxChars) +} + +func isBulletLine(s string) bool { + s = strings.TrimSpace(s) + for _, p := range []string{"- ", "* ", "• ", "– ", "— "} { + if strings.HasPrefix(s, p) { + return true + } + } + if len(s) >= 3 && s[0] >= '1' && s[0] <= '9' && s[1] == '.' && s[2] == ' ' { + return true + } + return false +} + +func stripBulletPrefix(s string) string { + s = strings.TrimSpace(s) + for _, p := range []string{"- ", "* ", "• ", "– ", "— "} { + if strings.HasPrefix(s, p) { + return strings.TrimSpace(s[len(p):]) + } + } + if len(s) >= 3 && s[0] >= '1' && s[0] <= '9' && s[1] == '.' && s[2] == ' ' { + return strings.TrimSpace(s[3:]) + } + return s +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if idx := strings.IndexAny(s, "\n\r"); idx >= 0 { + s = s[:idx] + } + s = strings.ReplaceAll(s, "\t", " ") + return strings.TrimSpace(s) +} + +func truncateRunes(s string, max int) string { + if max <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= max { + return s + } + r := []rune(s) + if max == 1 { + return "…" + } + return string(r[:max-1]) + "…" +} diff --git a/internal/tasksummary/stand_test.go b/internal/tasksummary/stand_test.go new file mode 100644 index 00000000..3212fc47 --- /dev/null +++ b/internal/tasksummary/stand_test.go @@ -0,0 +1,190 @@ +package tasksummary + +import ( + "strings" + "testing" + "time" + + "github.com/bborn/workflow/internal/db" +) + +func TestIsStandLine(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {" ", false}, + {"Merge email ingest PR", true}, + {"Which webhook auth?", true}, + {"Merge or close PR #12 — email ingest is ready", true}, // long one-liner still counts; display clamps + {"- User asked for email ingest\n- Agent opened a PR\n- Next: merge it", false}, + {"- User asked for email ingest", false}, + {"* bullet recap", false}, + {"1. numbered recap", false}, + {"line one\nline two", false}, + } + for _, c := range cases { + if got := IsStandLine(c.in); got != c.want { + t.Errorf("IsStandLine(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +func TestDisplayStandRejectsFossils(t *testing.T) { + fossil := "- Built the IMAP poller\n- Opened PR #12\n- Waiting on merge" + if got := DisplayStand(fossil); got != "" { + t.Errorf("fossil recap must not render as stand, got %q", got) + } + want := "Waiting on APNs key" + if got := DisplayStand(want); got != want { + t.Errorf("DisplayStand(%q) = %q", want, got) + } + if got := DisplayStand(" " + want + " "); got != want { + t.Errorf("DisplayStand should trim, got %q", got) + } +} + +func TestDisplayStandClampsToFiveWords(t *testing.T) { + long := "Waiting on the APNs key before we can test pushes" + got := DisplayStand(long) + if got != "Waiting on the APNs key" { + t.Errorf("DisplayStand(%q) = %q, want five words", long, got) + } +} + +func TestDisplayStandTruncatesLongToken(t *testing.T) { + long := strings.Repeat("a", StandMaxChars+20) + got := DisplayStand(long) + if !IsStandLine(long) { + t.Fatal("a long single line is still a stand") + } + if got == long { + t.Fatal("expected truncation") + } + if n := len([]rune(got)); n > StandMaxChars { + t.Errorf("truncated length = %d, want <= %d", n, StandMaxChars) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("truncated stand should end with ellipsis, got %q", got) + } +} + +func TestNormalizeStand(t *testing.T) { + cases := []struct { + in string + want string + }{ + {" Merge PR #12 ", "Merge PR #12"}, + {"\"Which port?\"", "Which port?"}, + {"- first bullet\n- second", "first bullet"}, + {"line one\nline two", "line one"}, + {"Waiting on the APNs key before we can test pushes", "Waiting on the APNs key"}, + } + for _, c := range cases { + if got := NormalizeStand(c.in); got != c.want { + t.Errorf("NormalizeStand(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestFallbackStand(t *testing.T) { + q := &db.TaskLog{LineType: "question", Content: "Which auth scheme should the webhook use?"} + if got := FallbackStand(q); got != "Which auth scheme should the" { + t.Errorf("question fallback = %q", got) + } + textQ := &db.TaskLog{LineType: "text", Content: "Need the destination folder?"} + if got := FallbackStand(textQ); got != textQ.Content { + t.Errorf("text question fallback = %q", got) + } + reconnect := &db.TaskLog{LineType: "system", Content: "Reconnecting to claude session abc"} + if got := FallbackStand(reconnect); got != "" { + t.Errorf("reconnect must not be fallback, got %q", got) + } + cont := &db.TaskLog{LineType: "system", Content: "--- Continuation ---"} + if got := FallbackStand(cont); got != "" { + t.Errorf("continuation marker must not be fallback, got %q", got) + } + tool := &db.TaskLog{LineType: "tool", Content: "Editing store.go"} + if got := FallbackStand(tool); got != "" { + t.Errorf("non-question tool line must not be fallback, got %q", got) + } + if got := FallbackStand(nil); got != "" { + t.Errorf("nil log = %q", got) + } +} + +func TestNeedsRefresh(t *testing.T) { + now := time.Now() + distilled := db.LocalTime{Time: now.Add(-time.Hour)} + freshLog := &db.TaskLog{CreatedAt: db.LocalTime{Time: now}} + oldLog := &db.TaskLog{CreatedAt: db.LocalTime{Time: now.Add(-2 * time.Hour)}} + stand := "Merge email ingest PR" + fossil := "- did a thing\n- another" + + blockedStand := &db.Task{Status: db.StatusBlocked, Summary: stand, LastDistilledAt: &distilled} + if NeedsRefresh(blockedStand, oldLog) { + t.Error("valid stand with no new logs should not refresh") + } + if !NeedsRefresh(blockedStand, freshLog) { + t.Error("new logs after last_distilled should refresh") + } + longStand := &db.Task{ + Status: db.StatusBlocked, + Summary: "Waiting on the APNs key before we can test pushes", + LastDistilledAt: &distilled, + } + if !NeedsRefresh(longStand, oldLog) { + t.Error("oversized stand should refresh even without new logs") + } + + blockedFossil := &db.Task{Status: db.StatusBlocked, Summary: fossil, LastDistilledAt: &distilled} + if !NeedsRefresh(blockedFossil, oldLog) { + t.Error("fossil recap on blocked should refresh") + } + emptyBlocked := &db.Task{Status: db.StatusBlocked} + if !NeedsRefresh(emptyBlocked, nil) { + t.Error("empty stand on blocked should refresh") + } + + doneFossil := &db.Task{Status: db.StatusDone, Summary: fossil, LastDistilledAt: &distilled} + if NeedsRefresh(doneFossil, freshLog) { + t.Error("done tasks freeze — no refresh") + } + processing := &db.Task{Status: db.StatusProcessing, Summary: ""} + if NeedsRefresh(processing, freshLog) { + t.Error("processing uses live crumb, not a rewrite") + } + queued := &db.Task{Status: db.StatusQueued, Summary: fossil} + if NeedsRefresh(queued, nil) { + t.Error("queued should not rewrite") + } + if NeedsRefresh(nil, nil) { + t.Error("nil task") + } +} + +func TestShouldRewriteOnStatus(t *testing.T) { + if !ShouldRewriteOnStatus(db.StatusProcessing, db.StatusBlocked) { + t.Error("processing → blocked should rewrite") + } + if ShouldRewriteOnStatus(db.StatusBlocked, db.StatusBlocked) { + t.Error("already blocked should not rewrite") + } + if ShouldRewriteOnStatus(db.StatusBlocked, db.StatusDone) { + t.Error("blocked → done freezes") + } + if ShouldRewriteOnStatus(db.StatusProcessing, db.StatusDone) { + t.Error("processing → done does not force-rewrite") + } +} + +func TestBuildSummaryPromptIsOneLine(t *testing.T) { + p := buildSummaryPrompt(&db.Task{Title: "Ship email ingest", Status: db.StatusBlocked}, nil) + if strings.Contains(p, "2-4") { + t.Errorf("prompt still asks for a recap:\n%s", p) + } + if !strings.Contains(strings.ToLower(p), "five word") { + t.Errorf("prompt should ask for five words:\n%s", p) + } +} diff --git a/internal/tasksummary/tasksummary.go b/internal/tasksummary/tasksummary.go index aeb08181..cc63723c 100644 --- a/internal/tasksummary/tasksummary.go +++ b/internal/tasksummary/tasksummary.go @@ -15,7 +15,7 @@ import ( const ( summaryModel = "claude-haiku-4-5-20251001" - summaryMaxTokens = 180 + summaryMaxTokens = 40 maxLogLines = 160 maxLogChars = 12000 maxLineChars = 300 @@ -66,6 +66,8 @@ func generateAndStore(ctx context.Context, database *db.DB, taskID int64, force if task == nil { return "", fmt.Errorf("task not found") } + // Skip-if-exists is the freeze: done tasks keep the stand written on the + // last block. Fossils stay until the next blocked rewrite (Force). if !force && strings.TrimSpace(task.Summary) != "" { return task.Summary, nil } @@ -82,7 +84,7 @@ func generateAndStore(ctx context.Context, database *db.DB, taskID int64, force return "", err } - summary = strings.TrimSpace(summary) + summary = NormalizeStand(summary) if summary == "" { return "", fmt.Errorf("summary was empty") } @@ -182,10 +184,11 @@ func (s *Service) callAPI(ctx context.Context, prompt string) (string, error) { func buildSummaryPrompt(task *db.Task, logs []*db.TaskLog) string { var sb strings.Builder - sb.WriteString("Summarize the task activity for a user who is context switching.\n") - sb.WriteString("Output 2-4 short bullet points starting with '-'.\n") - sb.WriteString("Include: the user's request (from title/body), key actions by the agent, and outcome/next step if visible.\n") - sb.WriteString("Be concise, avoid speculation, and output ONLY the bullets.\n\n") + sb.WriteString("Write FIVE WORDS that queue the user's brain for this task.\n") + sb.WriteString("A sticky note, not a sentence. Not a recap. Not bullets.\n") + sb.WriteString("The current ask or the next decision.\n") + sb.WriteString("Examples: \"Waiting on APNs key\" / \"Merge email ingest PR\" / \"Which webhook auth?\"\n") + sb.WriteString("No quotes, no prefix. Output ONLY the five words.\n\n") sb.WriteString("Task:\n") sb.WriteString(fmt.Sprintf("Title: %s\n", task.Title)) diff --git a/internal/ui/app.go b/internal/ui/app.go index cae45d9a..2414313f 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -381,8 +381,6 @@ type AppModel struct { notification string // Notification banner text notifyUntil time.Time // When to hide notification notifyTaskID int64 // Task ID that triggered the notification (for jumping to it) - lastViewedAt map[int64]time.Time - // Track task statuses to detect changes prevStatuses map[int64]string // Track tasks with active input notifications (for UI highlighting) @@ -644,7 +642,6 @@ func NewAppModel(database *db.DB, exec *executor.Executor, workingDir string, ve prevStatuses: make(map[int64]string), tasksNeedingInput: make(map[int64]bool), questionPrompts: make(map[int64]bool), - lastViewedAt: make(map[int64]time.Time), executorPrompts: make(map[int64]string), userClosedTaskIDs: make(map[int64]bool), watcher: watcher, @@ -1072,8 +1069,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } } - lastViewed, hasLast := m.lastViewedAt[msg.task.ID] - m.lastViewedAt[msg.task.ID] = now // Clean up any duplicate tmux windows for this task before switching m.executor.CleanupDuplicateWindows(msg.task.ID) // Resume task if it was suspended (blocked idle tasks get suspended to save memory) @@ -1110,9 +1105,11 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if relatedCmd := m.detailView.StartRelatedTasksLoad(); relatedCmd != nil { cmds = append(cmds, relatedCmd) } - if hasLast && now.Sub(lastViewed) > summaryRefreshAfter { - m.notification = fmt.Sprintf("%s Refreshing activity summary...", IconInProgress()) - m.notifyUntil = time.Now().Add(5 * time.Second) + var latest *db.TaskLog + if m.kanban != nil && m.kanban.latestActivity != nil { + latest = m.kanban.latestActivity[msg.task.ID] + } + if tasksummary.NeedsRefresh(msg.task, latest) { cmds = append(cmds, m.summarizeTask(msg.task.ID, true)) } } else { @@ -1320,7 +1317,7 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.notifyUntil = time.Now().Add(5 * time.Second) } else { - m.notification = fmt.Sprintf("%s Activity summary updated", IconDone()) + m.notification = fmt.Sprintf("%s Stand updated", IconDone()) m.notifyUntil = time.Now().Add(3 * time.Second) } if m.selectedTask != nil && m.selectedTask.ID == msg.taskID { @@ -1434,9 +1431,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Refresh detail view if active (for logs which may update frequently) if m.currentView == ViewDetail && m.detailView != nil { - if m.selectedTask != nil { - m.lastViewedAt[m.selectedTask.ID] = time.Time(msg) - } if cmd := m.detailView.Refresh(); cmd != nil { cmds = append(cmds, cmd) } @@ -3821,11 +3815,16 @@ func (m *AppModel) changeTaskStatus(id int64, status string) tea.Cmd { // Just set the requested status directly. Don't auto-queue to avoid // restarting the executor. Users can explicitly retry/requeue if they // want to restart execution. + oldStatus := "" + if existing, _ := database.GetTask(id); existing != nil { + oldStatus = existing.Status + } err := database.UpdateTaskStatus(id, status) if err == nil { if task, _ := database.GetTask(id); task != nil { exec.NotifyTaskChange("status_changed", task) } + tasksummary.KickoffOnStatusChange(database, oldStatus, status, id) } return taskStatusChangedMsg{err: err} } @@ -4223,8 +4222,6 @@ const maxDoneTasksInKanban = 20 // Matches the command palette's SearchTasks limit for consistency. const boardFilterDBSearchLimit = 100 -const summaryRefreshAfter = 5 * time.Minute - // refreshLatestActivity loads the most recent log line for each active task and // feeds it to the board for the per-card activity sub-line. func (m *AppModel) refreshLatestActivity() { diff --git a/internal/ui/detail.go b/internal/ui/detail.go index 0335d83d..1c0ceddb 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -26,6 +26,7 @@ import ( "github.com/bborn/workflow/internal/github" "github.com/bborn/workflow/internal/pipeline" "github.com/bborn/workflow/internal/qmd" + "github.com/bborn/workflow/internal/tasksummary" ) // shouldSkipAutoExecutor returns true if the task should NOT automatically @@ -3125,7 +3126,22 @@ func (m *DetailModel) renderHeader() string { Align(lipgloss.Right). Render(rightBlock) - return lipgloss.JoinVertical(lipgloss.Left, headerLayout, "") + stand := tasksummary.DisplayStand(t.Summary) + if stand == "" { + return lipgloss.JoinVertical(lipgloss.Left, headerLayout, "") + } + maxW := m.width - 4 + if maxW < 8 { + maxW = 8 + } + stand = truncateRunes(stand, maxW) + standColor := ColorMuted + if m.focused && t.Status == db.StatusBlocked { + standColor = ColorWarning + } else if !m.focused { + standColor = dimmedTextFg + } + return lipgloss.JoinVertical(lipgloss.Left, headerLayout, FgStyle(standColor).Render(stand), "") } // getGlamourRenderer returns a cached Glamour renderer, creating it if needed. @@ -3274,8 +3290,9 @@ func (m *DetailModel) renderContent() string { } } - // Activity summary - if t.Summary != "" && strings.TrimSpace(t.Summary) != "" { + // Fossil recaps stay in the body until rewritten. A one-line stand already + // lives in the header — don't duplicate it here. + if t.Summary != "" && !tasksummary.IsStandLine(t.Summary) { if b.Len() > 0 { b.WriteString("\n") } diff --git a/internal/ui/detail_test.go b/internal/ui/detail_test.go index 01747819..0cf642b2 100644 --- a/internal/ui/detail_test.go +++ b/internal/ui/detail_test.go @@ -323,6 +323,44 @@ func TestDetailModel_GetServerURL(t *testing.T) { } } +func TestDetailModel_RenderHeaderStand(t *testing.T) { + stand := "Merge email ingest PR" + m := &DetailModel{ + task: &db.Task{ + ID: 1, + Title: "Email ingest", + Status: db.StatusBlocked, + Summary: stand, + }, + focused: true, + width: 100, + height: 24, + } + header := m.renderHeader() + if !strings.Contains(header, stand) { + t.Errorf("detail header should show the stand, got: %q", header) + } + if strings.Contains(m.renderContent(), "Activity Summary") { + t.Error("a stand line must not be duplicated as Activity Summary in the body") + } + + fossil := &DetailModel{ + task: &db.Task{ + ID: 2, + Title: "Email ingest", + Status: db.StatusBlocked, + Summary: "- Built the IMAP poller\n- Opened a PR", + }, + focused: true, + width: 100, + height: 24, + } + out := fossil.renderHeader() + if strings.Contains(out, "Built the IMAP poller") { + t.Errorf("fossil recap must not appear in the header, got: %q", out) + } +} + // TestDetailModel_RenderHeaderWithDiffStats verifies that diff stats are // displayed in the header when a PR has additions/deletions. func TestDetailModel_RenderHeaderWithDiffStats(t *testing.T) { diff --git a/internal/ui/kanban.go b/internal/ui/kanban.go index 24512181..e83c291a 100644 --- a/internal/ui/kanban.go +++ b/internal/ui/kanban.go @@ -74,9 +74,9 @@ type KanbanBoard struct { cardCache map[uint64]string // Each card carries a live sub-line: what the running agent is doing right - // now (from latestActivity), the question it's blocked on, or an age hint - // for idle statuses. spinnerFrame drives the animated braille glyph on - // processing tasks. + // now (from latestActivity), the stand / waiting question when blocked, or + // an age hint for idle statuses. spinnerFrame drives the animated braille + // glyph on processing tasks. latestActivity map[int64]*db.TaskLog spinnerFrame int } @@ -782,6 +782,7 @@ func (k *KanbanBoard) hashTaskCard(h *sigHasher, t *db.Task) { h.str(t.Status) h.str(t.Project) h.str(t.Title) + h.str(t.Summary) h.boolean(t.Pinned) h.boolean(t.IsDangerous()) h.boolean(t.IsAutoPermission()) diff --git a/internal/ui/liveboard.go b/internal/ui/liveboard.go index ae5a560a..ecab6da8 100644 --- a/internal/ui/liveboard.go +++ b/internal/ui/liveboard.go @@ -8,12 +8,14 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/tasksummary" ) -// The board renders a live sub-line on every card: an activity line for -// running agents, an attention prompt for tasks needing input, or a concise -// age hint otherwise. Running tasks get an animated braille spinner driven by -// a 200 ms tick gated on whether any task is processing. +// The board renders a live sub-line on every card: an activity crumb for +// running agents, a one-line stand (or the waiting question) for blocked +// tasks, or a concise age hint otherwise. Running tasks get an animated +// braille spinner driven by a 200 ms tick gated on whether any task is +// processing. // SetLatestActivity updates the most-recent-log-per-task map used to render // activity lines. @@ -86,7 +88,7 @@ func (k *KanbanBoard) subLineContent(task *db.Task) (string, lipgloss.Color) { switch task.Status { case db.StatusProcessing: activity := "" - if log := k.latestActivity[task.ID]; log != nil { + if log := k.latestActivity[task.ID]; log != nil && !tasksummary.IsNoiseLog(log.Content) { activity = cleanActivityContent(log.Content) } elapsed := taskElapsedShort(task) @@ -99,6 +101,12 @@ func (k *KanbanBoard) subLineContent(task *db.Task) (string, lipgloss.Color) { return taskAgeHint(task), ColorMuted } case db.StatusBlocked: + if stand := tasksummary.DisplayStand(task.Summary); stand != "" { + return stand, ColorWarning + } + if fb := tasksummary.FallbackStand(k.latestActivity[task.ID]); fb != "" { + return fb, ColorWarning + } if k.NeedsInput(task.ID) { return IconBlocked() + " needs your input", ColorWarning } diff --git a/internal/ui/liveboard_test.go b/internal/ui/liveboard_test.go index 5fe4550e..7bbb4a5a 100644 --- a/internal/ui/liveboard_test.go +++ b/internal/ui/liveboard_test.go @@ -36,6 +36,76 @@ func TestKanbanBoard_ShowsAgeHint(t *testing.T) { } } +func TestKanbanBoard_BlockedShowsStand(t *testing.T) { + board := NewKanbanBoard(120, 50) + stand := "Merge email ingest PR" + board.SetTasks([]*db.Task{ + {ID: 11, Title: "Email ingest", Status: db.StatusBlocked, Summary: stand}, + }) + board.SetTasksNeedingInput(map[int64]bool{11: true}) + board.SetLatestActivity(map[int64]*db.TaskLog{ + 11: {TaskID: 11, LineType: "system", Content: "Reconnecting to claude session abc"}, + }) + + out := board.View() + if !strings.Contains(out, "Merge email ingest PR") { + t.Errorf("blocked card should show stand, got:\n%s", out) + } + if strings.Contains(out, "needs your input") { + t.Errorf("stand should replace the needs-input prompt, got:\n%s", out) + } + if strings.Contains(out, "Reconnecting") { + t.Errorf("reconnect log must not appear on the card, got:\n%s", out) + } +} + +func TestKanbanBoard_BlockedIgnoresFossilRecap(t *testing.T) { + board := NewKanbanBoard(120, 50) + board.SetTasks([]*db.Task{ + {ID: 12, Title: "Email ingest", Status: db.StatusBlocked, + Summary: "- Built the IMAP poller\n- Opened a PR\n- Waiting on merge"}, + }) + board.SetTasksNeedingInput(map[int64]bool{12: true}) + + out := board.View() + if strings.Contains(out, "Built the IMAP poller") { + t.Errorf("fossil recap must not render as the stand, got:\n%s", out) + } + if !strings.Contains(out, "needs your input") { + t.Errorf("without a stand, blocked+needs-input should fall back to the prompt, got:\n%s", out) + } +} + +func TestKanbanBoard_BlockedFallsBackToQuestion(t *testing.T) { + board := NewKanbanBoard(120, 50) + board.SetTasks([]*db.Task{ + {ID: 13, Title: "Webhooks", Status: db.StatusBlocked}, + }) + board.SetLatestActivity(map[int64]*db.TaskLog{ + 13: {TaskID: 13, LineType: "question", Content: "Which auth scheme should the webhook use?"}, + }) + + out := board.View() + if !strings.Contains(out, "Which auth scheme") { + t.Errorf("blocked card should fall back to the question, got:\n%s", out) + } +} + +func TestKanbanBoard_ProcessingIgnoresReconnect(t *testing.T) { + board := NewKanbanBoard(120, 50) + board.SetTasks([]*db.Task{ + {ID: 14, Title: "Refactor auth", Status: db.StatusProcessing}, + }) + board.SetLatestActivity(map[int64]*db.TaskLog{ + 14: {TaskID: 14, LineType: "system", Content: "Reconnecting to claude session abc"}, + }) + + out := board.View() + if strings.Contains(out, "Reconnecting") { + t.Errorf("processing card must not show reconnect logs, got:\n%s", out) + } +} + func TestKanbanBoard_NeedsInputPrompt(t *testing.T) { board := NewKanbanBoard(120, 50) board.SetTasks([]*db.Task{ diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 716d3233..23ec3733 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -8,6 +8,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/github" + "github.com/bborn/workflow/internal/tasksummary" ) // --- JSON helpers --- @@ -200,6 +201,14 @@ func (s *Server) handleTaskDetail(w http.ResponseWriter, r *http.Request) { logs[i], logs[j] = logs[j], logs[i] } + var latest *db.TaskLog + if len(logs) > 0 { + latest = logs[len(logs)-1] + } + if tasksummary.NeedsRefresh(task, latest) { + tasksummary.KickoffRewrite(s.db, task.ID) + } + jsonOK(w, map[string]interface{}{ "task": toTaskJSON(task), "logs": toLogJSONSlice(logs), @@ -356,10 +365,16 @@ func (s *Server) handleSetStatus(w http.ResponseWriter, r *http.Request) { return } + oldStatus := "" + if existing, _ := s.db.GetTask(id); existing != nil { + oldStatus = existing.Status + } + if err := s.db.UpdateTaskStatus(id, req.Status); err != nil { jsonErr(w, "failed to update status", http.StatusInternalServerError) return } + tasksummary.KickoffOnStatusChange(s.db, oldStatus, req.Status, id) jsonOK(w, map[string]bool{"ok": true}) } @@ -398,6 +413,9 @@ func (s *Server) handleCloseTask(w http.ResponseWriter, r *http.Request) { jsonErr(w, "failed to close task", http.StatusInternalServerError) return } + // Skip-if-exists: freeze a stand that was written on block; fill one in + // if the task never blocked. + tasksummary.KickoffGenerate(s.db, task.ID) jsonOK(w, map[string]bool{"ok": true}) } @@ -1059,6 +1077,7 @@ type taskJSON struct { PRNumber int `json:"pr_number,omitempty"` PR *prStatusJSON `json:"pr,omitempty"` Summary string `json:"summary,omitempty"` + Stand string `json:"stand,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` StartedAt string `json:"started_at,omitempty"` @@ -1113,6 +1132,7 @@ func toTaskJSON(t *db.Task) *taskJSON { PRNumber: t.PRNumber, PR: toPRStatusJSON(t.PRInfoJSON), Summary: t.Summary, + Stand: tasksummary.DisplayStand(t.Summary), CreatedAt: apiTime(t.CreatedAt.Time), UpdatedAt: apiTime(t.UpdatedAt.Time), } diff --git a/internal/web/handlers_gui_test.go b/internal/web/handlers_gui_test.go index 392701b4..31326121 100644 --- a/internal/web/handlers_gui_test.go +++ b/internal/web/handlers_gui_test.go @@ -590,6 +590,32 @@ func TestTaskJSONIncludesTerminalFields(t *testing.T) { } } +func TestTaskJSONStand(t *testing.T) { + stand := "Merge email ingest PR" + tj := toTaskJSON(&db.Task{ID: 1, Title: "email", Status: db.StatusBlocked, Summary: stand}) + if tj.Stand != stand { + t.Errorf("stand = %q, want %q", tj.Stand, stand) + } + long := toTaskJSON(&db.Task{ + ID: 3, Title: "email", Status: db.StatusBlocked, + Summary: "Waiting on the APNs key before we can test pushes", + }) + if long.Stand != "Waiting on the APNs key" { + t.Errorf("long stand should clamp to five words, got %q", long.Stand) + } + + fossil := toTaskJSON(&db.Task{ + ID: 2, Title: "email", Status: db.StatusBlocked, + Summary: "- Built the IMAP poller\n- Opened a PR", + }) + if fossil.Stand != "" { + t.Errorf("fossil recap must not be stand, got %q", fossil.Stand) + } + if fossil.Summary == "" { + t.Error("raw summary should still be present for fossils") + } +} + func TestHandleUpdateTask_PermissionAndEffort(t *testing.T) { srv, database, _ := setupServer(t) task := createTestTask(t, database, &db.Task{Title: "perm", Status: db.StatusBacklog})