fold the Deny & simplify denial instead of a red error (T-340)
A user-initiated denial (Deny & simplify) came back as an isError tool_result and rendered as a prominent expanded-red "Bash · error" block — pure noise, since the user chose it. It now folds to a muted, collapsed "denied" card. Built as a reusable filter rather than string-matching the note: DenyTool carries a `quiet` flag, the session collects quiet denials' tool_use_ids, and ConversationView renders any error whose id is in that set folded + muted. Genuine tool failures (ids not in the set) keep the expanded-red treatment. Adding future "expected error" cases is just adding ids. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3188,3 +3188,4 @@ INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, chang
|
|||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB0TNQM7F2TBJJV1F2KP7XR8', 'status', 'in_progress', 'done', NULL, '2026-06-10 13:55:30', '2026-06-10 13:55:30', '2026-06-10 13:55:30', NULL, 'f618ee8a3a33dabfa822bd0cebae4e03', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JAXDKZMS0805MMEYB6820', 'status', 'ready', 'done', NULL, '2026-06-10 14:03:10', '2026-06-10 14:03:10', '2026-06-10 14:03:10', NULL, '96365bc3ef7dbf505448be2dad16e4b6', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'ready', 'done', NULL, '2026-06-10 14:14:33', '2026-06-10 14:14:33', '2026-06-10 14:14:33', NULL, 'd028dbbdbad663a682c5ed4d92dc7cc2', 2) ON CONFLICT(hash) DO NOTHING;
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3JM0AXK1CTWD720RV1AVZ0', 'status', 'ready', 'done', NULL, '2026-06-10 14:14:33', '2026-06-10 14:14:33', '2026-06-10 14:14:33', NULL, 'd028dbbdbad663a682c5ed4d92dc7cc2', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'status', 'ready', 'done', NULL, '2026-06-10 14:29:07', '2026-06-10 14:29:07', '2026-06-10 14:29:07', NULL, 'b7a07e0516cc232a959ff1930f338f15', 2) ON CONFLICT(hash) DO NOTHING;
|
||||||
|
|||||||
@@ -2606,3 +2606,19 @@ Acceptance:
|
|||||||
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
|
- Picking up a ticket with a live Claude pane injects the prompt AND moves the ticket to in_progress, with the sidebar reflecting the new state.
|
||||||
- Picking up with no live session leaves the ticket untouched.
|
- Picking up with no live session leaves the ticket untouched.
|
||||||
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 14:14:33', NULL, 'd615d07653f4916b50514993935b90da', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
- Test coverage on _onTicketPickUp for both the accepted and no-session paths.', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:48:18', '2026-06-10 14:14:33', NULL, 'd615d07653f4916b50514993935b90da', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB3KS499THAD899M0NWN7E3R', 'bug', '06FB0TNQM5TWC00GW0P3X02HZW', 'Fold the Deny & simplify denial note instead of showing it as an expanded error', 'The ''Deny & simplify'' permission option (T-311/T-328) denies the action carrying a preformatted note (_kDenySimplifyNote in lib/builtin/claude/src/prompt_card.dart:27). Because it comes back as a tool_result with isError, the Claude pane renders it as a prominent red ''Bash · error'' block, expanded by default — see _toolResult() in lib/builtin/claude/src/conversation_view.dart:698, specifically collapsedByDefault: false (line 715).
|
||||||
|
|
||||||
|
But this denial is user-initiated (the user clicked ''Deny & simplify''), so the full red confirmation is noise — the user already knows what they did and just wants Claude to retry simpler. Either suppress this specific error block, or fold it into the collapsible (collapsedByDefault: true, with the first line as the collapsed summary) so it doesn''t shout.
|
||||||
|
|
||||||
|
Scope: ONLY the Deny & simplify-spawned error. Genuine Bash/tool errors must keep their current expanded-by-default behaviour (T-168). So this needs a way to distinguish a user-initiated deny-simplify denial from a real tool error — e.g. tag the DenyTool result so the renderer can recognise it, rather than string-matching the note text.
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
- Mark the deny-simplify result as a user-initiated denial when constructing DenyTool(note) in _permDenySimplify() (prompt_card.dart:234), carrying a flag through the tool_result envelope.
|
||||||
|
- In _toolResult(), when that flag is set, render collapsed-by-default (or suppress) instead of the expanded error path.
|
||||||
|
- Avoid brittle string matching against _kDenySimplifyNote.
|
||||||
|
|
||||||
|
Acceptance:
|
||||||
|
- Clicking ''Deny & simplify'' produces a collapsed/quiet card, not an expanded red error.
|
||||||
|
- A real Bash error still renders expanded by default.
|
||||||
|
|
||||||
|
Reference screenshot: paste-1781099488608.png (the expanded red ''Denied — this action is too complex...'' block over a follow-up retry).', 'done', 'medium', NULL, NULL, NULL, '2026-06-10 13:53:23', '2026-06-10 14:29:07', NULL, '6fad665013ede83fb4e538bb73308aa6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **"Deny & simplify" no longer shows a loud red error.** A denial the user
|
||||||
|
deliberately chose (Deny & simplify) folds into a muted, collapsed "denied"
|
||||||
|
card instead of the prominent expanded-red block reserved for genuine tool
|
||||||
|
failures — which still render expanded. Driven by a reusable per-result
|
||||||
|
"quiet error" flag, not by matching the note text. (T-340)
|
||||||
- **Sub-agent prompts no longer render as a blue "you" card.** In live
|
- **Sub-agent prompts no longer render as a blue "you" card.** In live
|
||||||
(stream-json) sessions a sub-agent's spawning prompt is tagged with
|
(stream-json) sessions a sub-agent's spawning prompt is tagged with
|
||||||
`parent_tool_use_id` rather than the transcript's `isSidechain`/`parentUuid`,
|
`parent_tool_use_id` rather than the transcript's `isSidechain`/`parentUuid`,
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ class _ClaudePaneState extends State<ClaudePane> {
|
|||||||
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
foldLevel: foldLevelFromName(_kernel()?.settings.get<String>(kActivityFoldLevelKey)),
|
||||||
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
hiddenToolUseIds: _session?.promptedToolUseIds ?? const <String>{},
|
||||||
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
toolUseOutcomes: _session?.toolUseOutcomes ?? const <String, bool>{},
|
||||||
|
quietErrorToolUseIds: _session?.quietErrorToolUseIds ?? const <String>{},
|
||||||
emptyState: ClaudeBanner(
|
emptyState: ClaudeBanner(
|
||||||
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
role: widget.isPrimary ? 'primary' : 'session ${widget.secondaryIndex}',
|
||||||
workspace: _repoRoot,
|
workspace: _repoRoot,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ConversationView extends StatefulWidget {
|
|||||||
this.emptyState,
|
this.emptyState,
|
||||||
this.hiddenToolUseIds = const <String>{},
|
this.hiddenToolUseIds = const <String>{},
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
this.foldLevel = FoldLevel.tools,
|
this.foldLevel = FoldLevel.tools,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,6 +54,13 @@ class ConversationView extends StatefulWidget {
|
|||||||
/// border instead of being hidden (D-78).
|
/// border instead of being hidden (D-78).
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
|
||||||
|
/// tool_use_ids whose error result should render folded + muted instead of as
|
||||||
|
/// a loud red failure (T-340) — expected, user-initiated denials the user
|
||||||
|
/// already understands (Deny & simplify). Genuine tool errors (ids not in
|
||||||
|
/// here) keep the prominent expanded-red treatment (T-168). A reusable filter:
|
||||||
|
/// add ids to quiet more error kinds without string-matching their text.
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
|
|
||||||
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
|
/// Whether to wrap the list in its own [ClideSelectionArea]. The team
|
||||||
/// grid sets this false and wraps all tiles in one shared area so
|
/// grid sets this false and wraps all tiles in one shared area so
|
||||||
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
/// selection spans tiles — nesting SelectionAreas is illegal (T-140).
|
||||||
@@ -305,6 +313,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
collapseTools: true,
|
collapseTools: true,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -315,6 +324,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
items: items,
|
items: items,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -325,6 +335,7 @@ class _ConversationViewState extends State<ConversationView> {
|
|||||||
edits: edits,
|
edits: edits,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: widget.toolUseOutcomes,
|
toolUseOutcomes: widget.toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: widget.quietErrorToolUseIds,
|
||||||
toolUseById: widget.controller.toolUseById,
|
toolUseById: widget.controller.toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: fold.promptsByToolUseId,
|
promptsByToolUseId: fold.promptsByToolUseId,
|
||||||
@@ -407,6 +418,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
required this.tokens,
|
required this.tokens,
|
||||||
this.collapseTools = false,
|
this.collapseTools = false,
|
||||||
this.toolUseOutcomes = const <String, bool>{},
|
this.toolUseOutcomes = const <String, bool>{},
|
||||||
|
this.quietErrorToolUseIds = const <String>{},
|
||||||
this.toolUseById = const <String, AssistantToolUse>{},
|
this.toolUseById = const <String, AssistantToolUse>{},
|
||||||
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
this.resultByToolUseId = const <String, ToolResultMessage>{},
|
||||||
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
this.promptsByToolUseId = const <String, List<UserMessage>>{},
|
||||||
@@ -427,6 +439,10 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
|
EdgeInsetsGeometry get _childMargin => collapseTools ? const EdgeInsets.only(bottom: 14) : const EdgeInsets.only(bottom: kClideCardHeaderPadH);
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
|
||||||
|
/// tool_use_ids whose error result folds quietly instead of expanded-red
|
||||||
|
/// (T-340) — see [ConversationView.quietErrorToolUseIds].
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
|
|
||||||
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
|
/// Index from toolUseId → AssistantToolUse, for result-card pairing (T-168).
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
|
|
||||||
@@ -627,6 +643,7 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
item: r,
|
item: r,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
@@ -717,22 +734,28 @@ class _ConversationTurn extends StatelessWidget {
|
|||||||
// Error result: render the error message prominently (T-168). If we have
|
// Error result: render the error message prominently (T-168). If we have
|
||||||
// the paired tool_use, show the tool name as a sub-label so the user can
|
// the paired tool_use, show the tool name as a sub-label so the user can
|
||||||
// see what failed without expanding.
|
// see what failed without expanding.
|
||||||
|
//
|
||||||
|
// Exception (T-340): an expected, user-initiated denial (Deny & simplify)
|
||||||
|
// is noise as a loud red error — the user already knows what they did. Fold
|
||||||
|
// it to a muted, collapsed card. Genuine failures keep the expanded-red look.
|
||||||
if (t.isError) {
|
if (t.isError) {
|
||||||
|
final quiet = quietErrorToolUseIds.contains(t.toolUseId);
|
||||||
final multiline = t.content.contains('\n');
|
final multiline = t.content.contains('\n');
|
||||||
|
final errLabel = quiet ? 'denied' : label;
|
||||||
return ConversationCard(
|
return ConversationCard(
|
||||||
variant: ConversationCardVariant.bordered,
|
variant: ConversationCardVariant.bordered,
|
||||||
accent: accent,
|
accent: quiet ? tokens.globalTextMuted : accent,
|
||||||
borderColor: tokens.statusError,
|
borderColor: quiet ? tokens.panelBorder : tokens.statusError,
|
||||||
label: paired != null ? '${paired.name} · $label' : label,
|
label: paired != null ? '${paired.name} · $errLabel' : errLabel,
|
||||||
copyText: t.content,
|
copyText: t.content,
|
||||||
collapsible: multiline,
|
collapsible: quiet || multiline,
|
||||||
collapsedByDefault: false, // errors default expanded so they're visible
|
collapsedByDefault: quiet, // genuine errors stay expanded; a denial folds
|
||||||
collapsedSummary: multiline ? _firstLine(t.content) : null,
|
collapsedSummary: (quiet || multiline) ? _firstLine(t.content) : null,
|
||||||
body: ClideText(
|
body: ClideText(
|
||||||
t.content,
|
t.content,
|
||||||
fontSize: clideFontMeta,
|
fontSize: clideFontMeta,
|
||||||
fontFamily: clideMonoFamily,
|
fontFamily: clideMonoFamily,
|
||||||
color: tokens.statusError,
|
color: quiet ? tokens.globalTextMuted : tokens.statusError,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -790,6 +813,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
required this.items,
|
required this.items,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
required this.toolUseOutcomes,
|
required this.toolUseOutcomes,
|
||||||
|
required this.quietErrorToolUseIds,
|
||||||
required this.toolUseById,
|
required this.toolUseById,
|
||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
@@ -799,6 +823,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
final List<ConversationItem> items;
|
final List<ConversationItem> items;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
@@ -819,6 +844,7 @@ class _ActivityCard extends StatelessWidget {
|
|||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
@@ -851,6 +877,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
required this.edits,
|
required this.edits,
|
||||||
required this.tokens,
|
required this.tokens,
|
||||||
required this.toolUseOutcomes,
|
required this.toolUseOutcomes,
|
||||||
|
required this.quietErrorToolUseIds,
|
||||||
required this.toolUseById,
|
required this.toolUseById,
|
||||||
required this.resultByToolUseId,
|
required this.resultByToolUseId,
|
||||||
required this.promptsByToolUseId,
|
required this.promptsByToolUseId,
|
||||||
@@ -860,6 +887,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
final List<ConversationItem> edits;
|
final List<ConversationItem> edits;
|
||||||
final SurfaceTokens tokens;
|
final SurfaceTokens tokens;
|
||||||
final Map<String, bool> toolUseOutcomes;
|
final Map<String, bool> toolUseOutcomes;
|
||||||
|
final Set<String> quietErrorToolUseIds;
|
||||||
final Map<String, AssistantToolUse> toolUseById;
|
final Map<String, AssistantToolUse> toolUseById;
|
||||||
final Map<String, ToolResultMessage> resultByToolUseId;
|
final Map<String, ToolResultMessage> resultByToolUseId;
|
||||||
final Map<String, List<UserMessage>> promptsByToolUseId;
|
final Map<String, List<UserMessage>> promptsByToolUseId;
|
||||||
@@ -880,6 +908,7 @@ class _EditRunCard extends StatelessWidget {
|
|||||||
item: item,
|
item: item,
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
toolUseOutcomes: toolUseOutcomes,
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
toolUseById: toolUseById,
|
toolUseById: toolUseById,
|
||||||
resultByToolUseId: resultByToolUseId,
|
resultByToolUseId: resultByToolUseId,
|
||||||
promptsByToolUseId: promptsByToolUseId,
|
promptsByToolUseId: promptsByToolUseId,
|
||||||
|
|||||||
@@ -234,7 +234,9 @@ class _ToolPromptCardState extends State<ToolPromptCard> {
|
|||||||
void _permDenySimplify() {
|
void _permDenySimplify() {
|
||||||
final user = _permNote();
|
final user = _permNote();
|
||||||
final note = user == null ? _kDenySimplifyNote : '$_kDenySimplifyNote\n\nUser note: $user';
|
final note = user == null ? _kDenySimplifyNote : '$_kDenySimplifyNote\n\nUser note: $user';
|
||||||
widget.onResolve(widget.prompt.promptId, DenyTool(note));
|
// Quiet: the user deliberately chose this, so its denial folds rather than
|
||||||
|
// shouting as a red error (T-340).
|
||||||
|
widget.onResolve(widget.prompt.promptId, DenyTool(note, quiet: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
|
(Color, String, List<Widget>) _permission(SurfaceTokens tokens) {
|
||||||
|
|||||||
@@ -183,9 +183,15 @@ final class AllowTool extends ToolDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Deny the tool with a user-facing [message] (required by the protocol).
|
/// Deny the tool with a user-facing [message] (required by the protocol).
|
||||||
|
///
|
||||||
|
/// [quiet] marks a deliberate, user-initiated denial that the user already
|
||||||
|
/// understands (e.g. "Deny & simplify", T-340) — the resulting error tool-result
|
||||||
|
/// should fold to a muted card rather than shout as a red failure. Off by
|
||||||
|
/// default, so a genuine/unexpected denial still renders prominently.
|
||||||
final class DenyTool extends ToolDecision {
|
final class DenyTool extends ToolDecision {
|
||||||
const DenyTool(this.message);
|
const DenyTool(this.message, {this.quiet = false});
|
||||||
final String message;
|
final String message;
|
||||||
|
final bool quiet;
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
Map<String, dynamic> toJson() => {'behavior': 'deny', 'message': message};
|
||||||
}
|
}
|
||||||
@@ -246,9 +252,16 @@ class StreamJsonSession {
|
|||||||
/// green/red border (D-78).
|
/// green/red border (D-78).
|
||||||
final _toolUseOutcome = <String, bool>{};
|
final _toolUseOutcome = <String, bool>{};
|
||||||
|
|
||||||
|
/// tool_use_ids whose error result should render folded + muted rather than as
|
||||||
|
/// a loud red failure (T-340): expected, user-initiated denials (Deny &
|
||||||
|
/// simplify, today) that the user already understands. The reusable extension
|
||||||
|
/// point — add an id here at the moment you know its error is non-alarming.
|
||||||
|
final _quietErrorToolUses = <String>{};
|
||||||
|
|
||||||
/// Read-only views for the conversation view.
|
/// Read-only views for the conversation view.
|
||||||
Set<String> get promptedToolUseIds => _promptedToolUses;
|
Set<String> get promptedToolUseIds => _promptedToolUses;
|
||||||
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
Map<String, bool> get toolUseOutcomes => _toolUseOutcome;
|
||||||
|
Set<String> get quietErrorToolUseIds => _quietErrorToolUses;
|
||||||
|
|
||||||
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
/// Whether a turn is in flight (between a send and claude's `result`). Drives
|
||||||
/// the composer's Stop affordance.
|
/// the composer's Stop affordance.
|
||||||
@@ -542,7 +555,10 @@ class StreamJsonSession {
|
|||||||
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
final idx = _queue.indexWhere((p) => p.promptId == promptId);
|
||||||
if (idx < 0) return; // unknown / already resolved
|
if (idx < 0) return; // unknown / already resolved
|
||||||
final prompt = _queue.removeAt(idx);
|
final prompt = _queue.removeAt(idx);
|
||||||
if (prompt.toolUseId.isNotEmpty) _toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
|
if (prompt.toolUseId.isNotEmpty) {
|
||||||
|
_toolUseOutcome[prompt.toolUseId] = decision is AllowTool;
|
||||||
|
if (decision is DenyTool && decision.quiet) _quietErrorToolUses.add(prompt.toolUseId);
|
||||||
|
}
|
||||||
_proc.writeLine(jsonEncode({
|
_proc.writeLine(jsonEncode({
|
||||||
'type': 'control_response',
|
'type': 'control_response',
|
||||||
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
'response': {'subtype': 'success', 'request_id': promptId, 'response': decision.toJson()},
|
||||||
|
|||||||
@@ -149,7 +149,10 @@ void main() {
|
|||||||
tearDown(() => f.dispose());
|
tearDown(() => f.dispose());
|
||||||
|
|
||||||
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
Future<ConversationController> pumpWith(WidgetTester tester, List<ConversationItem> items,
|
||||||
{Set<String> hiddenToolUseIds = const {}, Map<String, bool> toolUseOutcomes = const {}, FoldLevel foldLevel = FoldLevel.none}) async {
|
{Set<String> hiddenToolUseIds = const {},
|
||||||
|
Map<String, bool> toolUseOutcomes = const {},
|
||||||
|
Set<String> quietErrorToolUseIds = const {},
|
||||||
|
FoldLevel foldLevel = FoldLevel.none}) async {
|
||||||
tester.view.physicalSize = const Size(900, 700);
|
tester.view.physicalSize = const Size(900, 700);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
addTearDown(() {
|
addTearDown(() {
|
||||||
@@ -166,7 +169,12 @@ void main() {
|
|||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) => MediaQuery(
|
builder: (ctx) => MediaQuery(
|
||||||
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
|
||||||
child: ConversationView(controller: c, hiddenToolUseIds: hiddenToolUseIds, toolUseOutcomes: toolUseOutcomes, foldLevel: foldLevel),
|
child: ConversationView(
|
||||||
|
controller: c,
|
||||||
|
hiddenToolUseIds: hiddenToolUseIds,
|
||||||
|
toolUseOutcomes: toolUseOutcomes,
|
||||||
|
quietErrorToolUseIds: quietErrorToolUseIds,
|
||||||
|
foldLevel: foldLevel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
@@ -697,6 +705,33 @@ void main() {
|
|||||||
expect(find.text('Bash · error'), findsOneWidget);
|
expect(find.text('Bash · error'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('a quiet (user-initiated) denial folds to a muted "denied" card (T-340)', (tester) async {
|
||||||
|
await pumpWith(
|
||||||
|
tester,
|
||||||
|
[
|
||||||
|
_tool('Bash', {'command': 'rm -rf x'}),
|
||||||
|
_result('Denied — too complex\nretry simpler', isError: true),
|
||||||
|
],
|
||||||
|
quietErrorToolUseIds: {'x1'}, // the toolUseId shared by _tool/_result
|
||||||
|
);
|
||||||
|
// Labelled "denied", not the loud "error", and folded by default so the
|
||||||
|
// body (its second line) is hidden behind a collapsed summary.
|
||||||
|
expect(find.text('Bash · denied'), findsOneWidget);
|
||||||
|
expect(find.text('Bash · error'), findsNothing);
|
||||||
|
expect(find.textContaining('retry simpler'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a genuine error (not in the quiet set) stays expanded red (T-340)', (tester) async {
|
||||||
|
await pumpWith(tester, [
|
||||||
|
_tool('Bash', {'command': 'rm -rf x'}),
|
||||||
|
_result('Denied — too complex\nretry simpler', isError: true),
|
||||||
|
]);
|
||||||
|
// Same content, but not flagged quiet → the normal expanded error path.
|
||||||
|
expect(find.text('Bash · error'), findsOneWidget);
|
||||||
|
expect(find.text('Bash · denied'), findsNothing);
|
||||||
|
expect(find.textContaining('retry simpler'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
|
testWidgets('result without a paired tool_use uses plain "result" label (T-168)', (tester) async {
|
||||||
// Orphan result (no matching tool_use in the controller).
|
// Orphan result (no matching tool_use in the controller).
|
||||||
await pumpWith(tester, [
|
await pumpWith(tester, [
|
||||||
|
|||||||
Reference in New Issue
Block a user