Summarize each to-do item; show original prompt beneath; sort oldest-first

Each open idea now gets its own short summary (model-backed with heuristic
fallback), shown emphasized with a sparkles "summarized" icon, and the full
original text beneath it — smaller and dimmer, since that text is what gets
dispatched as the agent's prompt. Lists are sorted oldest-first (first-in at
the top).

- Todo.summary (persisted, v6 migration): nil = not yet summarized, "" = no
  distinct summary, else the summary; text stays canonical as the prompt
- summarizeTodo on IntelligenceProviding (default heuristic todoLine + AFM
  override that compresses to a short imperative title)
- AppStore generates item summaries on add / on load (once) / on text edit,
  off the main loop; short ideas fold to "" so they aren't retried
- openTodos + grouping sorted by createdAt ascending (stable)
- TodoRow: summary + sparkles icon over a dimmer original snippet
- Tests: long-vs-short item summary, oldest-first ordering, summary column

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
2026-06-12 17:48:14 -07:00
co-authored by Claude Opus 4.8
parent ed91a0d278
commit 720dfe54d6
8 changed files with 172 additions and 8 deletions
@@ -119,6 +119,28 @@ struct AppleIntelligenceProvider: IntelligenceProviding {
return HeuristicSummary.todoGist(cleaned)
}
func summarizeTodo(_ text: String) async -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "" }
// Already short no summary adds value (the caller folds an unchanged result
// into "no distinct summary").
guard trimmed.split(whereSeparator: { $0 == " " || $0.isNewline }).count > 8 else {
return HeuristicSummary.todoLine(trimmed)
}
let instructions = """
You compress a developer's to-do note into a SHORT imperative title \
(max ~10 words) naming the core task. The note is your only source of \
truth — never invent detail. No quotes, no preamble, no trailing \
punctuation — output only the title.
"""
if let out = await generate(instructions: instructions, prompt: trimmed) {
let line = out.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: \.isNewline).first.map(String.init) ?? ""
if !line.isEmpty { return line }
}
return HeuristicSummary.todoLine(trimmed)
}
/// Runs one prompt through the selected Foundation Models model. Returns nil if
/// off, unavailable, or on any error (callers fall back to the heuristic).
private func generate(instructions: String, prompt: String) async -> String? {
+25 -1
View File
@@ -144,11 +144,35 @@ private struct TodoRow: View {
todo.projectID.flatMap { store.project($0) }
}
/// A model/heuristic summary distinct from the raw text (nil/"" none).
private var summary: String? {
guard let summary = todo.summary, !summary.isEmpty else { return nil }
return summary
}
var body: some View {
HStack(spacing: 10) {
Image(systemName: "lightbulb")
.foregroundStyle(.secondary)
Text(todo.text).lineLimit(2)
VStack(alignment: .leading, spacing: 2) {
if let summary {
HStack(spacing: 4) {
Image(systemName: "sparkles")
.font(.caption2)
.foregroundStyle(palette.accent)
.help("Summarized — the full text below is used as the prompt")
Text(summary).lineLimit(2)
}
// The original prompt text: smaller and dimmer, but still shown
// since it's what gets dispatched as the agent's prompt.
Text(todo.text)
.font(.caption)
.foregroundStyle(.tertiary)
.lineLimit(2)
} else {
Text(todo.text).lineLimit(2)
}
}
Spacer()
dispatchControl
Button {
+39 -5
View File
@@ -346,7 +346,9 @@ public final class AppStore {
public func loadTodos() async {
todos = (try? await database.loadTodos()) ?? []
todos.sort { $0.createdAt < $1.createdAt }
regenerateTodoSummaries()
ensureTodoItemSummaries()
}
/// Open ideas grouped by project (each project in display order, then the
@@ -414,6 +416,7 @@ public final class AppStore {
do {
try await database.saveTodo(todo)
upsertTodo(todo)
summarizeTodoItem(todo.id)
return todo
} catch {
lastError = "Add to-do failed: \(error)"
@@ -424,14 +427,16 @@ public final class AppStore {
/// Edit an idea's text and/or assigned project (e.g. from the home list).
public func updateTodo(_ id: TodoID, text: String? = nil, projectID: ProjectID?? = nil) async {
guard var todo = todos.first(where: { $0.id == id }) else { return }
var textChanged = false
if let text {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
todo.text = trimmed
if trimmed != todo.text { todo.text = trimmed; todo.summary = nil; textChanged = true }
}
if let projectID { todo.projectID = projectID }
todo.updatedAt = now()
await persistTodo(todo)
if textChanged { summarizeTodoItem(id) } // re-summarize the new text
}
public func setTodoStatus(_ id: TodoID, _ status: TodoStatus) async {
@@ -447,9 +452,9 @@ public final class AppStore {
regenerateTodoSummaries()
}
/// Open ideas (not yet dispatched or done), for the home list.
/// Open ideas (not yet dispatched or done), oldest first (first-in at the top).
public var openTodos: [Todo] {
todos.filter { $0.status == .open }.sorted { $0.updatedAt > $1.updatedAt }
todos.filter { $0.status == .open }.sorted { $0.createdAt < $1.createdAt }
}
/// Dispatch an idea: start a chat in `project` seeded with the idea's text, mark
@@ -481,12 +486,41 @@ public final class AppStore {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index] = todo
} else {
todos.insert(todo, at: 0)
todos.append(todo)
}
todos.sort { $0.updatedAt > $1.updatedAt }
// Oldest first; a stable sort keeps insertion order for equal timestamps.
todos.sort { $0.createdAt < $1.createdAt }
regenerateTodoSummaries()
}
/// Generate per-item summaries for any open ideas that lack one (after a load).
private func ensureTodoItemSummaries() {
for todo in todos where todo.summary == nil && todo.status == .open {
summarizeTodoItem(todo.id)
}
}
/// Summarize one idea's text off the main run loop and store the result. Stores
/// `""` when the text needs no distinct summary, so it isn't retried every load.
private func summarizeTodoItem(_ id: TodoID) {
guard let todo = todos.first(where: { $0.id == id }) else { return }
let text = todo.text
let intelligence = self.intelligence
Task {
let raw = await intelligence.summarizeTodo(text)
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let summary = (trimmed.isEmpty || trimmed.caseInsensitiveCompare(text) == .orderedSame)
? "" : trimmed
// Bail if the idea was edited (newer summarize will run) or already current.
guard var current = self.todos.first(where: { $0.id == id }), current.text == text,
current.summary != summary
else { return }
current.summary = summary
try? await self.database.saveTodo(current)
if let index = self.todos.firstIndex(where: { $0.id == id }) { self.todos[index] = current }
}
}
// MARK: - Dashboard
/// Aggregate stats + per-day activity for the home dashboard. Reads each
+20
View File
@@ -13,6 +13,10 @@ public protocol IntelligenceProviding: Sendable {
/// A one-line gist of a cluster of captured to-do ideas (the home to-do groups).
/// Has a heuristic default; the model-backed provider overrides it.
func summarizeTodos(_ items: [String]) async -> String
/// A short, glanceable summary of a single captured idea (shown above its full
/// text on the list). Has a heuristic default; the model-backed provider
/// overrides it.
func summarizeTodo(_ text: String) async -> String
}
extension IntelligenceProviding {
@@ -22,6 +26,12 @@ extension IntelligenceProviding {
public func summarizeTodos(_ items: [String]) async -> String {
HeuristicSummary.todoGist(items)
}
/// Default: a dependency-free single-line clip (see `summarizeTodos` for why this
/// is both a requirement and an extension default).
public func summarizeTodo(_ text: String) async -> String {
HeuristicSummary.todoLine(text)
}
}
/// Pure, local fallback no model required.
@@ -187,6 +197,16 @@ public enum HeuristicSummary {
return "\(cleaned.count) ideas: " + heads.joined(separator: "; ") + more
}
/// A single-line clip of one idea (first line, first ~12 words, no trailing
/// punctuation) the model-free fallback for a per-item to-do summary. Short
/// ideas come back unchanged; the caller treats "equals the original" as "no
/// distinct summary".
public static func todoLine(_ text: String) -> String {
let firstLine = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? text
let words = firstLine.split(separator: " ").prefix(12).joined(separator: " ")
return words.trimmingCharacters(in: CharacterSet(charactersIn: " .?!,:;\"'`"))
}
/// First ~8 words of an idea, lower-cased lead, no trailing punctuation.
private static func clause(_ text: String) -> String {
let firstLine = text.split(whereSeparator: \.isNewline).first.map(String.init) ?? text
@@ -105,6 +105,9 @@ public final class GRDBMetadataStore: SessionMetadataStore {
""")
try db.execute(sql: "CREATE INDEX idx_todo_updated ON todo(updated_at);")
}
migrator.registerMigration("v6-todo-summary") { db in
try db.execute(sql: "ALTER TABLE todo ADD COLUMN summary TEXT;")
}
return migrator
}
@@ -354,6 +357,7 @@ private struct TodoRow: Codable, FetchableRecord, PersistableRecord {
var project_id: String?
var status: String
var dispatched_session_id: String?
var summary: String?
var created_at: Date
var updated_at: Date
@@ -363,6 +367,7 @@ private struct TodoRow: Codable, FetchableRecord, PersistableRecord {
project_id = t.projectID?.rawValue
status = t.status.rawValue
dispatched_session_id = t.dispatchedSessionID?.rawValue
summary = t.summary
created_at = t.createdAt
updated_at = t.updatedAt
}
@@ -374,6 +379,7 @@ private struct TodoRow: Codable, FetchableRecord, PersistableRecord {
projectID: project_id.map(ProjectID.init(rawValue:)),
status: TodoStatus(rawValue: status) ?? .open,
dispatchedSessionID: dispatched_session_id.map(SessionID.init(rawValue:)),
summary: summary,
createdAt: created_at,
updatedAt: updated_at)
}
+6
View File
@@ -39,6 +39,10 @@ public struct Todo: Identifiable, Sendable, Codable, Equatable {
public var status: TodoStatus
/// The session spawned for this idea once dispatched.
public var dispatchedSessionID: SessionID?
/// A short, glanceable summary of `text` for display. `nil` = not yet summarized;
/// `""` = summarized but no distinct summary (text was already short). `text`
/// itself stays canonical it's what gets used as the dispatch prompt.
public var summary: String?
public let createdAt: Date
public var updatedAt: Date
@@ -48,6 +52,7 @@ public struct Todo: Identifiable, Sendable, Codable, Equatable {
projectID: ProjectID? = nil,
status: TodoStatus = .open,
dispatchedSessionID: SessionID? = nil,
summary: String? = nil,
createdAt: Date,
updatedAt: Date
) {
@@ -56,6 +61,7 @@ public struct Todo: Identifiable, Sendable, Codable, Equatable {
self.projectID = projectID
self.status = status
self.dispatchedSessionID = dispatchedSessionID
self.summary = summary
self.createdAt = createdAt
self.updatedAt = updatedAt
}
@@ -141,6 +141,56 @@ struct AppStoreTests {
#expect(store.todos.contains { $0.id == a.id } == false)
}
@Test func longTodoGetsItemSummaryShortDoesNot() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let store = makeStore(repo: repo)
let long = try #require(await store.addTodo(
"refactor the authentication module so it issues short-lived tokens and refreshes them automatically before they expire"))
let short = try #require(await store.addTodo("fix typo"))
// Per-item summaries are generated asynchronously (heuristic, no model here).
await waitFor {
store.todos.first { $0.id == long.id }?.summary != nil
&& store.todos.first { $0.id == short.id }?.summary != nil
}
let longTodo = try #require(store.todos.first { $0.id == long.id })
let shortTodo = try #require(store.todos.first { $0.id == short.id })
#expect(longTodo.summary?.isEmpty == false) // long a distinct summary
#expect(longTodo.summary != longTodo.text)
#expect(shortTodo.summary == "") // short no distinct summary
// Persisted: a reload keeps the generated summary (no re-summarize needed).
await store.loadTodos()
#expect(store.todos.first { $0.id == long.id }?.summary?.isEmpty == false)
#expect(store.todos.first { $0.id == short.id }?.summary == "")
}
@Test func openTodosSortedOldestFirst() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
let clock = LockedBox(Date(timeIntervalSince1970: 1_700_000_000))
let store = AppStore(
database: try! GRDBMetadataStore(path: nil),
worktrees: GitWorktreeManager(),
transcriptsDir: URL(fileURLWithPath: (repo.container as NSString).appendingPathComponent("t")),
now: { let date = clock.get(); clock.set(date.addingTimeInterval(1)); return date }
) { _ in ScriptedBackend { _, _ in } }
let first = try #require(await store.addTodo("first in"))
let second = try #require(await store.addTodo("second in"))
let third = try #require(await store.addTodo("third in"))
// First-in at the top.
#expect(store.openTodos.map(\.id) == [first.id, second.id, third.id])
// Grouping preserves oldest-first within a group.
let project = await store.addProject(name: "p", rootPath: repo.root, defaultBranch: "main")!
await store.updateTodo(third.id, projectID: project.id)
await store.updateTodo(first.id, projectID: project.id)
#expect(store.todoGroups.first?.todos.map(\.id) == [first.id, third.id])
}
@Test func todosGroupByProjectWithSummaries() async throws {
let repo = try await GitTestRepo()
defer { repo.cleanup() }
@@ -84,8 +84,8 @@ struct GRDBMetadataStoreTests {
let later = now.addingTimeInterval(60)
let a = Todo(text: "unassigned idea", createdAt: now, updatedAt: now)
let b = Todo(
text: "tagged idea", projectID: project.id, status: .dispatched,
dispatchedSessionID: .generate(), createdAt: later, updatedAt: later)
text: "tagged idea with a much longer body", projectID: project.id, status: .dispatched,
dispatchedSessionID: .generate(), summary: "tagged idea", createdAt: later, updatedAt: later)
try await store.saveTodo(a)
try await store.saveTodo(b)
@@ -94,6 +94,8 @@ struct GRDBMetadataStoreTests {
#expect(loaded == [b, a])
#expect(loaded.first?.projectID == project.id)
#expect(loaded.first?.status == .dispatched)
#expect(loaded.first?.summary == "tagged idea") // v6 migration column
#expect(loaded.last?.summary == nil)
#expect(loaded.last?.projectID == nil)
// Upsert: same id, mutated fields still one row.