Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 35 additions & 6 deletions desktop/src/components/Board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const spinner = useSpinner(task.status === "processing");
Expand All @@ -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 (
<motion.div
Expand Down Expand Up @@ -210,11 +243,7 @@ const CardSlot = memo(function CardSlot({ task, selected, projectColor, latest }
</span>
</div>
<div className="truncate text-[11px] text-muted-foreground">
{latest && (task.status === "processing" || task.status === "blocked") ? (
<span title={latest.content}>{latest.content}</span>
) : (
<span>{ageHint(task)}</span>
)}
<span title={subLine.title}>{subLine.text}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{task.pinned && <Pin className="size-3 text-amber-500 dark:text-amber-300" />}
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/components/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ export function DetailView({ taskId }: { taskId: number }) {
Status
</Button>
</div>
{task.stand && (
<div
className={`shrink-0 truncate border-b bg-surface-1 px-4 py-1.5 text-[12.5px] ${
blocked ? "text-status-blocked" : "text-muted-foreground"
}`}
title={task.stand}
>
{task.stand}
</div>
)}

<div ref={splitRef} className="flex min-h-0 flex-1 flex-col">
<div className="min-h-[140px] flex-1 overflow-y-auto px-5 py-3.5 select-text">
Expand All @@ -267,7 +277,7 @@ export function DetailView({ taskId }: { taskId: number }) {
<span className="text-xs text-muted-foreground">No description</span>
)}

{task.summary && (
{task.summary && !task.stand && (
<>
<SectionTitle>Summary</SectionTitle>
<Markdown source={task.summary} />
Expand Down
2 changes: 2 additions & 0 deletions internal/completion/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down
2 changes: 2 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
217 changes: 217 additions & 0 deletions internal/tasksummary/stand.go
Original file line number Diff line number Diff line change
@@ -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]) + "…"
}
Loading