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:
@@ -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? {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user