feat(assistant): remove todo tool and task list UI
This commit is contained in:
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user