feat(assistant): remove todo tool and task list UI

This commit is contained in:
Kaede Akino committed 2026-09-30 14:12:16 +08:00
1 parent ef95e428ec
commit 7084145c47
11 files changed
+19 -584

No files matched your search

@@ -1134,29 +1134,6 @@
}
}
},
"%lld of %lld tasks done" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$lld of %2$lld tasks done"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "已完成%1$lld/%2$lld个任务"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "已完成%1$lld/%2$lld個任務"
}
}
}
},
"%lld panes" : {
"extractionState" : "manual",
"localizations" : {
@@ -26516,29 +26493,6 @@
}
}
},
"Step %lld / %lld" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Step %1$lld / %2$lld"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "步骤%1$lld/%2$lld"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "步驟%1$lld/%2$lld"
}
}
}
},
"Still running after %llds. The output above is what it has printed so far. Call ssh_wait with this handle for the rest; leaving it unasked-about abandons it." : {
"extractionState" : "manual",
"localizations" : {
@@ -27255,46 +27209,6 @@
}
}
},
"Tasks" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "任务"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "任務"
}
}
}
},
"Tasks, %lld of %lld done" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Tasks, %1$lld of %2$lld done"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "任务,已完成%1$lld/%2$lld"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "任務,已完成%1$lld/%2$lld"
}
}
}
},
"Teal" : {
"extractionState" : "manual",
"localizations" : {
@@ -919,12 +919,6 @@ public final class ConversationModel: Identifiable {
/// that run releases ownership.
public var effectiveIsPlanning: Bool { activeRunOwner?.request.isPlanning ?? isPlanning }
/// The current task list, whole. Replaced rather than patched — see
/// `AgentTask`.
public private(set) var tasks: [AgentTask] = []
public var completedTaskCount: Int { tasks.filter { $0.status == .completed }.count }
/// How many characters of tool schema the last run sent.
///
/// Recorded when a run starts rather than derived on demand: the registry is
@@ -1363,7 +1357,6 @@ public final class ConversationModel: Identifiable {
return removedMessageIDs.contains(boundary)
}
compactions = compactionRecords.map(AgentCompactionMark.init)
tasks.removeAll()
usage = nil
contextEstimateTracker.setMeasured(nil)
publishContextEstimateImmediately()
@@ -3284,8 +3277,9 @@ public final class ConversationModel: Identifiable {
}
case .planPresented(let plan):
plans.append(AgentPlanCard(plan: plan))
case .tasksUpdated(let updated):
tasks = updated
case .tasksUpdated:
// AgentKit supports task lists, but Assistant does not register that tool.
break
case .failed(let reason):
guard let activeRunID, var run = runSummaries[activeRunID] else { break }
run.state = .failed
@@ -295,10 +295,6 @@ extension AppStrings {
"No models configured"
}
public static func ofTasksDone(completedTaskCount: Int, count: Int) -> LocalizedStringResource {
"\(completedTaskCount) of \(count) tasks done"
}
public static var onlyAskForActionsDetectedAsPotentiallyUnsafe: LocalizedStringResource {
"Only ask for actions detected as potentially unsafe"
}
@@ -463,10 +459,6 @@ extension AppStrings {
"Steering"
}
public static func step(completed: Int, total: Int) -> LocalizedStringResource {
"Step \(completed) / \(total)"
}
public static var stopCurrentRun: LocalizedStringResource {
"Stop current run — ⌘."
}
@@ -483,14 +475,6 @@ extension AppStrings {
"Take this back and edit it"
}
public static var tasks: LocalizedStringResource {
"Tasks"
}
public static func tasksOfDone(completed: Int, total: Int) -> LocalizedStringResource {
"Tasks, \(completed) of \(total) done"
}
public static var thinkingProgress: LocalizedStringResource {
"Thinking…"
}
@@ -35,7 +35,7 @@ public enum AssistantToolRegistry {
// and `scratch_fetch` read the same web the same way and differ only in
// where the page ends up.
let web = ScrubberWebClient()
// Both write through the one ordered channel the rest of the run
// Plans write through the ordered channel the rest of the run
// uses, rather than reaching into the conversation directly: a tool
// that touched `@Observable` state from a task group would be
// mutating the UI from off the main actor.
@@ -52,7 +52,6 @@ public enum AssistantToolRegistry {
web: web,
search: localSearch,
secretConsumerTools: [TermindAgentPolicy.sshExecToolName],
tasks: AgentTaskList { channel.emit(.tasksUpdated($0)) },
plans: AgentPlanRecorder { channel.emit(.planPresented($0)) },
skills: skillCatalog,
chatModels: chatModels, chats: chats, chatSource: chatSource,
+11 -163
View File
@@ -283,148 +283,6 @@ struct MobileAssistantEditMessageTextEditor: View {
}
}
private struct MobileAssistantTaskCapsule: View {
let conversation: ConversationModel
@State private var isExpanded = false
@State private var rowsHeight: CGFloat = 0
@State private var overlayHeight: CGFloat = 0
@State private var availableWidth: CGFloat = 320
private var isComplete: Bool {
conversation.completedTaskCount == conversation.tasks.count
}
var body: some View {
capsule
.frame(maxWidth: .infinity)
.overlay(alignment: .top) {
if isExpanded {
VStack(spacing: 0) {
list
Color.clear.frame(height: 4)
}
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
overlayHeight = $0
}
.offset(y: -overlayHeight)
.transition(.opacity)
}
}
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
availableWidth = $0
}
.animation(.easeOut(duration: 0.12), value: isExpanded)
}
private var capsule: some View {
Button {
isExpanded.toggle()
} label: {
HStack(spacing: 4) {
ProgressRing(
share: Double(conversation.completedTaskCount)
/ Double(max(conversation.tasks.count, 1)),
tint: isComplete ? .green : .accentColor,
diameter: 10,
lineWidth: 1.7
)
Text(
AppStrings.Assistant.step(
completed: conversation.completedTaskCount, total: conversation.tasks.count)
)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
.background(.regularMaterial, in: Capsule())
.overlay {
Capsule().stroke(.primary.opacity(0.08), lineWidth: 1).allowsHitTesting(false)
}
.contentShape(.capsule)
}
.buttonStyle(.plain)
.frame(minHeight: 44)
.accessibilityLabel(
Text(
AppStrings.Assistant.tasksOfDone(
completed: conversation.completedTaskCount, total: conversation.tasks.count))
)
.accessibilityValue(isExpanded ? Text(AppStrings.Assistant.expanded) : Text(AppStrings.Assistant.collapsed))
}
private var list: some View {
ScrollView {
VStack(alignment: .leading, spacing: 2) {
ForEach(conversation.tasks) { task in
row(task)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
rowsHeight = $0
}
}
.frame(height: min(rowsHeight, 160))
.scrollBounceBehavior(.basedOnSize)
.padding(12)
.frame(width: min(availableWidth, 320))
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(.primary.opacity(0.08), lineWidth: 1)
.allowsHitTesting(false)
}
.shadow(color: .black.opacity(0.08), radius: 16, y: 6)
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(AppStrings.Assistant.tasks))
}
private func row(_ task: AgentTask) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: symbol(task.status))
.font(.caption)
.foregroundStyle(tint(task.status))
.frame(width: 20)
Text(task.title)
.font(.callout)
.foregroundStyle(
task.status == .completed
? Color(uiColor: .secondaryLabel) : Color(uiColor: .label)
)
.strikethrough(
task.status == .completed,
color: Color(uiColor: .secondaryLabel)
)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func symbol(_ status: AgentTask.Status) -> String {
switch status {
case .pending: "circle"
case .inProgress: "circle.lefthalf.filled"
case .completed: "checkmark.circle.fill"
}
}
private func tint(_ status: AgentTask.Status) -> Color {
switch status {
case .pending: .secondary
case .inProgress: .accentColor
case .completed: .green
}
}
}
private struct MobileAssistantSteeringQueue: View {
let conversation: ConversationModel
@@ -624,7 +482,6 @@ struct MobileAssistantConversation: View {
@ViewBuilder private var bottomSurface: some View {
bottomSurfaceContent
.padding(.top, !conversation.tasks.isEmpty && acceptsAttachmentDrops ? 44 : 0)
.padding(12)
.frame(maxWidth: 840)
.frame(maxWidth: .infinity)
@@ -648,27 +505,18 @@ struct MobileAssistantConversation: View {
}
private var regularInteractionSurface: some View {
ZStack(alignment: .top) {
VStack(spacing: 8) {
if !conversation.pendingSteering.isEmpty {
MobileAssistantSteeringQueue(conversation: conversation)
}
MobileAssistantComposer(
pro: app.pro,
model: model,
conversation: conversation,
focus: composerFocus,
focusRequest: composerFocusRequest,
focusRequestHandled: composerFocusRequestHandled
)
}
.zIndex(20)
if !conversation.tasks.isEmpty {
MobileAssistantTaskCapsule(conversation: conversation)
.offset(y: -44)
.zIndex(10)
VStack(spacing: 8) {
if !conversation.pendingSteering.isEmpty {
MobileAssistantSteeringQueue(conversation: conversation)
}
MobileAssistantComposer(
pro: app.pro,
model: model,
conversation: conversation,
focus: composerFocus,
focusRequest: composerFocusRequest,
focusRequestHandled: composerFocusRequestHandled
)
}
}
@@ -234,14 +234,6 @@ struct MobileAssistantRenderingTests {
conversation.respondToApproval(approvalRequest.id, allow: false)
_ = await approval.value
conversation.handle(
.tasksUpdated([
AgentTask(
id: "verify",
title: text("Verify the selected approach", "验证所选方案", "驗證所選方案"),
status: .inProgress
)
]))
conversation.present(
AgentUserInputRequest(questions: [
AgentUserQuestion(
+1 -1
View File
@@ -461,7 +461,7 @@ struct MobileAssistantTests {
let names = Set(mobileDescriptors.map(\.name))
for name in [
"ssh_exec", "ssh_wait", "sftp_read", "sftp_write", "list_hosts", "get_metrics",
"scratch_pull", "scratch_push", "present_plan", "manage_tasks", "request_user_input", "request_user_secret",
"scratch_pull", "scratch_push", "present_plan", "request_user_input", "request_user_secret",
"terminal_suggest",
] {
#expect(names.contains(name), "Missing tool: \(name)")
-20
View File
@@ -243,26 +243,6 @@ nonisolated enum Metrics {
/// in the transcript from becoming the transcript.
static let planPreviewHeight: CGFloat = 96
/// How much of the task list is shown before it scrolls.
///
/// Taller than the steering strip because this one is read rather than
/// glanced at — it is the answer to "what is left" — but still a ceiling
/// measured from the rows, so a three-item list is three items tall.
static let taskListMaxHeight: CGFloat = 160
/// How wide the hovered task list is.
///
/// Narrow on purpose. It hangs off a capsule a few characters wide, and
/// a panel spanning the conversation would read as a new region rather
/// than as that capsule opening. Task titles are short phrases.
///
/// Two values because an overlay is proposed its *parent's* size — the
/// capsule's, which is tiny — so this cannot be a `maxWidth` that adapts
/// on its own. The compact figure is what fits inside a 260pt sidebar
/// once the composer's own insets are taken off.
static let taskListWidth: CGFloat = 320
static let compactTaskListWidth: CGFloat = 232
}
enum TransferCenter {
@@ -148,16 +148,6 @@ struct ConversationView: View {
AgentApprovalCard(request: approval, conversation: conversation)
}
} else {
if !conversation.tasks.isEmpty {
chatColumn {
AgentTaskCapsuleView(
conversation: conversation, isCompact: isCompact
)
}
// Below the composer's Slash overlay, above Steering and
// every ordinary surface in this interaction region.
.zIndex(50)
}
if !conversation.pendingSteering.isEmpty {
chatColumn {
SteeringQueueView(
@@ -368,171 +358,6 @@ private struct ConversationTranscriptSurface: View {
}
}
// MARK: - Tasks
/// What the agent is working through, as one small capsule.
///
/// Above the composer rather than in the transcript, and that difference is the
/// design. A plan is a document decided once, so it belongs where it happened. A
/// task list is live state rewritten every turn: one transcript row that
/// silently mutated would misdate itself, and one row per revision would bury
/// the conversation it describes.
///
/// A capsule rather than a strip because at rest the only thing worth saying is
/// "4 / 9". It sits above every other thing stacked over the composer — the
/// approval card, a question, the steering queue — because it is the *state* of
/// the work, and those are all interruptions to it.
///
/// The list opens on hover, as an overlay drawn upward over the transcript.
/// Inserting it into the stack instead would push the conversation every time
/// the pointer crossed the pill.
struct AgentTaskCapsuleView: View {
let conversation: ConversationModel
var isCompact = false
/// Two hover flags, not one. The list is an overlay outside the capsule's
/// own frame, so hovering it does not keep the capsule's `onHover` true —
/// and with one flag the list would vanish the moment the pointer reached
/// for it.
@State private var isHoveringCapsule = false
@State private var isHoveringList = false
@State private var isPinned = false
/// The rows' own height. A `ScrollView` reports none — left alone it takes
/// every point it is offered.
@State private var rowsHeight: CGFloat = 0
@State private var overlayHeight: CGFloat = 0
private var isComplete: Bool {
conversation.completedTaskCount == conversation.tasks.count
}
private var isExpanded: Bool { isPinned || isHoveringCapsule || isHoveringList }
var body: some View {
capsule
.overlay(alignment: .top) {
if isExpanded {
VStack(spacing: 0) {
list
// Keeps hover continuous while leaving a small visual
// gap between the floating list and its capsule.
Color.clear.frame(height: Metrics.Space.xs)
}
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
overlayHeight = $0
}
// The overlay's bottom meets the capsule's top. Using
// `.top` is important: bottom-aligning first and then
// subtracting the list height moved it upward twice.
.offset(y: -overlayHeight)
.onHover { isHoveringList = $0 }
.transition(.opacity)
}
}
.frame(maxWidth: .infinity)
.padding(.top, Metrics.Space.s)
.animation(.easeOut(duration: 0.12), value: isExpanded)
}
private var capsule: some View {
Button {
// Hover is not available to everyone, and a pinned list is also how
// you read one without holding the pointer still.
isPinned.toggle()
} label: {
HStack(spacing: Metrics.Space.xs) {
ProgressRing(
share: Double(conversation.completedTaskCount)
/ Double(max(conversation.tasks.count, 1)),
tint: isComplete ? .green : .accentColor,
diameter: 10, lineWidth: 1.7
)
Text(
AppStrings.Assistant.step(
completed: conversation.completedTaskCount, total: conversation.tasks.count)
)
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, Metrics.Space.m)
.padding(.vertical, 3)
.termindGlassCapsule(isInteractive: true)
.contentShape(.capsule)
}
.buttonStyle(.plain)
.onHover { isHoveringCapsule = $0 }
.help(
Text(
AppStrings.Assistant.ofTasksDone(
completedTaskCount: conversation.completedTaskCount, count: conversation.tasks.count))
)
.accessibilityLabel(
Text(
AppStrings.Assistant.tasksOfDone(
completed: conversation.completedTaskCount, total: conversation.tasks.count))
)
}
private var list: some View {
ScrollView {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
ForEach(conversation.tasks) { task in
row(task)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.onGeometryChange(for: CGFloat.self) {
$0.size.height
} action: {
rowsHeight = $0
}
}
.frame(height: min(rowsHeight, Metrics.Chat.taskListMaxHeight))
.scrollBounceBehavior(.basedOnSize)
.padding(Metrics.Space.l)
.frame(
width: isCompact
? Metrics.Chat.compactTaskListWidth : Metrics.Chat.taskListWidth
)
.termindGlassSurface(cornerRadius: Metrics.Chat.composerRadius)
.accessibilityElement(children: .contain)
.accessibilityLabel(Text(AppStrings.Assistant.tasks))
}
private func row(_ task: AgentTask) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Metrics.Space.s) {
Image(systemName: symbol(task.status))
.font(.caption)
.foregroundStyle(tint(task.status))
.frame(width: Metrics.Sidebar.rowIcon)
Text(task.title)
.font(.callout)
.foregroundStyle(task.status == .completed ? .secondary : .primary)
.strikethrough(task.status == .completed, color: .secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func symbol(_ status: AgentTask.Status) -> String {
switch status {
case .pending: "circle"
case .inProgress: "circle.lefthalf.filled"
case .completed: "checkmark.circle.fill"
}
}
private func tint(_ status: AgentTask.Status) -> Color {
switch status {
case .pending: .secondary
case .inProgress: .accentColor
case .completed: .green
}
}
}
// MARK: - Decisions
/// Kept as an emphasised card on purpose. This is the one place in the
@@ -257,7 +257,7 @@ struct AgentBuiltInToolAvailabilityTests {
let descriptors = AgentToolCatalog.registry(
builtIn: .init(
workspace: workspace, web: web, search: web,
tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in }
plans: AgentPlanRecorder { _ in }
),
additional: TermindAgentTools.tools(
in: .init(
@@ -289,7 +289,7 @@ struct AgentBuiltInToolAvailabilityTests {
)
let everything = AgentBuiltInToolConfiguration(
workspace: workspace, web: web, search: web,
tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in },
plans: AgentPlanRecorder { _ in },
skills: AgentSkillCatalog([
AgentSkill(name: "deploy", summary: "Deploy the app", body: "Steps")
])
@@ -302,7 +302,7 @@ struct AgentBuiltInToolAvailabilityTests {
#expect(names.contains("scratch_write"))
#expect(names.contains("request_user_secret"))
for absent in [
"fetch", "web_search", "scratch_fetch", "skill_read_file", "manage_tasks",
"fetch", "web_search", "scratch_fetch", "skill_read_file",
"present_plan",
] {
#expect(!names.contains(absent), "\(absent) survived group selection")
-101
View File
@@ -360,37 +360,6 @@ struct AgentPlanModeTests {
#expect(await ran.didRun)
}
/// Blocked by name rather than by approval policy: `manage_tasks` changes
/// nothing, so the approval gate has no reason to stop it, and it still has no business
/// running before anything has been agreed to.
@Test
func theTaskListIsRefusedWhilePlanningEvenThoughItIsReadOnly() async {
let ran = RanFlag()
let events = await drain(
tools: [
spyTool(
"manage_tasks", approvalPolicy: .approve,
availableIn: [.acting], ran: ran
)
],
turns: callingTurns(["manage_tasks"]), isPlanning: true
)
#expect(await ran.didRun == false)
#expect(results(events).first?.isError == true)
}
@Test
func theTaskListRunsOutsidePlanMode() async {
let ran = RanFlag()
_ = await drain(
tools: [spyTool("manage_tasks", approvalPolicy: .approve, ran: ran)],
turns: callingTurns(["manage_tasks"]), isPlanning: false
)
#expect(await ran.didRun)
}
/// A partially-blocked turn must still answer every call it was asked for.
/// An assistant turn with an unanswered tool call is rejected outright by
/// every provider on the next request.
@@ -512,74 +481,4 @@ struct AgentPlanModeTests {
#expect(planned.messages[2].text != AgentPlanContractInjection.contract)
#expect(planned.messages[2].text.contains("ssh_exec"))
}
// MARK: - Task arguments
@Test
func aTaskListMustNameOneThingInProgress() throws {
let two = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "c", name: "manage_tasks",
arguments: .object([
"tasks": .array([
taskJSON("a", "Check disk", "in_progress"),
taskJSON("b", "Read config", "in_progress"),
])
])
)
)
#expect(throws: AgentToolError.self) {
try Arguments(two).agentTasks()
}
}
@Test
func aTaskListRejectsDuplicateIDs() throws {
let duplicated = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "c", name: "manage_tasks",
arguments: .object([
"tasks": .array([
taskJSON("a", "Check disk", "pending"),
taskJSON("a", "Read config", "pending"),
])
])
)
)
#expect(throws: AgentToolError.self) {
try Arguments(duplicated).agentTasks()
}
}
@Test
func aWellFormedTaskListParsesInOrder() throws {
let valid = AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(
id: "c", name: "manage_tasks",
arguments: .object([
"tasks": .array([
taskJSON("a", "Check disk", "completed"),
taskJSON("b", "Read config", "in_progress"),
taskJSON("c", "Propose a fix", "pending"),
])
])
)
)
let tasks = try Arguments(valid).agentTasks()
#expect(tasks.map(\.id) == ["a", "b", "c"])
#expect(tasks[1].status == .inProgress)
}
private func taskJSON(_ id: String, _ title: String, _ status: String) -> AgentJSONValue {
.object([
"id": .string(id), "title": .string(title), "status": .string(status),
])
}
}