Skip to content

Commit 5313cb5

Browse files
feat: add task header highlight for visual status indication (#11305)
* feat: add task header highlight setting for visual status indication Add a 'Task Header Highlight' toggle under Settings > UI that colors the task header based on its current state: - Green (--vscode-charts-green) when task completes (completion_result) - Yellow (--vscode-charts-yellow) when user attention is needed (follow-up questions, tool approvals, etc.) The highlight is skipped for subtasks and partial/streaming messages, matching the same defensive logic used by sound notifications. CSS classes with !important and --vscode-foreground variable overrides ensure all child text, icons, SVGs, and the context progress bar use appropriate contrasting colors. Includes tests (37 passing) and translations for all 18 locales. * fix: address review feedback - accessibility contrast and deduplicated logic - Replace theme-dependent --vscode-charts-green/yellow backgrounds with hardcoded colors (#15803d, #ca8a04) that guarantee WCAG AA 4.5:1 contrast - Extract shared lastRelevantMessage useMemo to deduplicate findLastIndex filtering between isTaskComplete and highlightClass
1 parent 2789bab commit 5313cb5

28 files changed

Lines changed: 434 additions & 24 deletions

packages/types/src/global-settings.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ export const globalSettingsSchema = z.object({
167167
ttsSpeed: z.number().optional(),
168168
soundEnabled: z.boolean().optional(),
169169
soundVolume: z.number().optional(),
170+
taskHeaderHighlightEnabled: z.boolean().optional(),
170171

171172
maxOpenTabsContext: z.number().optional(),
172173
maxWorkspaceFiles: z.number().optional(),
@@ -368,6 +369,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
368369
ttsSpeed: 1,
369370
soundEnabled: false,
370371
soundVolume: 0.5,
372+
taskHeaderHighlightEnabled: false,
371373

372374
terminalShellIntegrationTimeout: 30000,
373375
terminalCommandDelay: 0,

packages/types/src/vscode-extension-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ export type ExtensionState = Pick<
303303
| "ttsSpeed"
304304
| "soundEnabled"
305305
| "soundVolume"
306+
| "taskHeaderHighlightEnabled"
306307
| "terminalOutputPreviewSize"
307308
| "terminalShellIntegrationTimeout"
308309
| "terminalShellIntegrationDisabled"

src/core/webview/ClineProvider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,6 +2060,7 @@ export class ClineProvider
20602060
historyPreviewCollapsed,
20612061
reasoningBlockCollapsed,
20622062
enterBehavior,
2063+
taskHeaderHighlightEnabled,
20632064
cloudUserInfo,
20642065
cloudIsAuthenticated,
20652066
sharingEnabled,
@@ -2204,6 +2205,7 @@ export class ClineProvider
22042205
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
22052206
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
22062207
enterBehavior: enterBehavior ?? "send",
2208+
taskHeaderHighlightEnabled: taskHeaderHighlightEnabled ?? false,
22072209
cloudUserInfo,
22082210
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
22092211
cloudAuthSkipModel: this.context.globalState.get<boolean>("roo-auth-skip-model") ?? false,
@@ -2442,6 +2444,7 @@ export class ClineProvider
24422444
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
24432445
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
24442446
enterBehavior: stateValues.enterBehavior ?? "send",
2447+
taskHeaderHighlightEnabled: stateValues.taskHeaderHighlightEnabled ?? false,
24452448
cloudUserInfo,
24462449
cloudIsAuthenticated,
24472450
sharingEnabled,

webview-ui/src/components/chat/TaskHeader.tsx

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,43 @@ const TaskHeader = ({
6868
todos,
6969
}: TaskHeaderProps) => {
7070
const { t } = useTranslation()
71-
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState()
71+
const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive, taskHeaderHighlightEnabled } =
72+
useExtensionState()
7273
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
7374
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
7475
const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false)
7576
const { isOpen, openUpsell, closeUpsell, handleConnect } = useCloudUpsell({
7677
autoOpenOnAuth: false,
7778
})
7879

80+
// Determine if this is a subtask (has a parent)
81+
const isSubtask = !!parentTaskId
82+
83+
// Find the last message that isn't a resume action (shared by isTaskComplete and highlightClass)
84+
const lastRelevantMessage = useMemo(() => {
85+
const msgs = clineMessages || []
86+
const idx = findLastIndex(msgs, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"))
87+
return idx !== -1 ? msgs[idx] : undefined
88+
}, [clineMessages])
89+
7990
// Check if the task is complete by looking at the last relevant message (skipping resume messages)
80-
const isTaskComplete =
81-
clineMessages && clineMessages.length > 0
82-
? (() => {
83-
const lastRelevantIndex = findLastIndex(
84-
clineMessages,
85-
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
86-
)
87-
return lastRelevantIndex !== -1
88-
? clineMessages[lastRelevantIndex]?.ask === "completion_result"
89-
: false
90-
})()
91-
: false
91+
const isTaskComplete = lastRelevantMessage?.ask === "completion_result"
92+
93+
// Compute highlight CSS class: green for task complete, yellow for user attention needed
94+
const highlightClass = useMemo(() => {
95+
if (!taskHeaderHighlightEnabled || isSubtask) return undefined
96+
if (!lastRelevantMessage || lastRelevantMessage.partial) return undefined
97+
98+
if (lastRelevantMessage.ask === "completion_result") {
99+
return "task-header-highlight-green"
100+
}
101+
102+
if (lastRelevantMessage.ask) {
103+
return "task-header-highlight-yellow"
104+
}
105+
106+
return undefined
107+
}, [taskHeaderHighlightEnabled, isSubtask, lastRelevantMessage])
92108

93109
useEffect(() => {
94110
const timer = setTimeout(() => {
@@ -141,9 +157,6 @@ const TaskHeader = ({
141157

142158
const hasTodos = todos && Array.isArray(todos) && todos.length > 0
143159

144-
// Determine if this is a subtask (has a parent)
145-
const isSubtask = !!parentTaskId
146-
147160
const handleBackToParent = () => {
148161
if (parentTaskId) {
149162
vscode.postMessage({ type: "showTaskWithId", text: parentTaskId })
@@ -174,12 +187,14 @@ const TaskHeader = ({
174187
</DismissibleUpsell>
175188
)}
176189
<div
190+
data-testid="task-header-container"
177191
className={cn(
178192
"px-3 pt-2.5 pb-2 flex flex-col gap-1.5 relative z-1 cursor-pointer",
179193
"bg-vscode-input-background hover:bg-vscode-input-background/90",
180194
"text-vscode-foreground/80 hover:text-vscode-foreground",
181195
"shadow-lg shadow-vscode-sideBar-background/50 rounded-xl",
182196
hasTodos && "border-b-0",
197+
highlightClass,
183198
)}
184199
onClick={(e) => {
185200
// Don't expand if clicking on todos section

webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ let mockExtensionState: {
4040
apiConfiguration: ProviderSettings
4141
currentTaskItem: { id: string } | null
4242
clineMessages: any[]
43+
taskHeaderHighlightEnabled?: boolean
4344
} = {
4445
apiConfiguration: {
4546
apiProvider: "anthropic",
@@ -48,6 +49,7 @@ let mockExtensionState: {
4849
} as ProviderSettings,
4950
currentTaskItem: { id: "test-task-id" },
5051
clineMessages: [],
52+
taskHeaderHighlightEnabled: false,
5153
}
5254

5355
// Mock the ExtensionStateContext
@@ -215,6 +217,7 @@ describe("TaskHeader", () => {
215217
} as ProviderSettings,
216218
currentTaskItem: { id: "test-task-id" },
217219
clineMessages: [],
220+
taskHeaderHighlightEnabled: false,
218221
}
219222
})
220223

@@ -423,6 +426,175 @@ describe("TaskHeader", () => {
423426
})
424427
})
425428

429+
describe("Task header highlight", () => {
430+
const completionMessages = [
431+
{
432+
type: "ask",
433+
ask: "completion_result",
434+
ts: Date.now(),
435+
text: "Task completed!",
436+
},
437+
]
438+
439+
beforeEach(() => {
440+
mockExtensionState = {
441+
apiConfiguration: {
442+
apiProvider: "anthropic",
443+
apiKey: "test-api-key",
444+
apiModelId: "claude-3-opus-20240229",
445+
} as ProviderSettings,
446+
currentTaskItem: { id: "test-task-id" },
447+
clineMessages: [],
448+
taskHeaderHighlightEnabled: false,
449+
}
450+
})
451+
452+
it("should apply green highlight class when task is complete and highlight is enabled", () => {
453+
mockExtensionState = {
454+
...mockExtensionState,
455+
clineMessages: completionMessages,
456+
taskHeaderHighlightEnabled: true,
457+
}
458+
459+
renderTaskHeader()
460+
461+
const container = screen.getByTestId("task-header-container")
462+
expect(container.classList.contains("task-header-highlight-green")).toBe(true)
463+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
464+
})
465+
466+
it("should apply yellow highlight class when task needs user attention and highlight is enabled", () => {
467+
mockExtensionState = {
468+
...mockExtensionState,
469+
clineMessages: [
470+
{
471+
type: "ask",
472+
ask: "tool",
473+
ts: Date.now(),
474+
text: "Need permission to use tool",
475+
},
476+
],
477+
taskHeaderHighlightEnabled: true,
478+
}
479+
480+
renderTaskHeader()
481+
482+
const container = screen.getByTestId("task-header-container")
483+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(true)
484+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
485+
})
486+
487+
it("should not apply highlight when highlight is disabled", () => {
488+
mockExtensionState = {
489+
...mockExtensionState,
490+
clineMessages: completionMessages,
491+
taskHeaderHighlightEnabled: false,
492+
}
493+
494+
renderTaskHeader()
495+
496+
const container = screen.getByTestId("task-header-container")
497+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
498+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
499+
})
500+
501+
it("should not apply highlight when task is a subtask", () => {
502+
mockExtensionState = {
503+
...mockExtensionState,
504+
clineMessages: completionMessages,
505+
taskHeaderHighlightEnabled: true,
506+
}
507+
508+
renderTaskHeader({ parentTaskId: "parent-task-123" })
509+
510+
const container = screen.getByTestId("task-header-container")
511+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
512+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
513+
})
514+
515+
it("should not apply highlight when last message is partial", () => {
516+
mockExtensionState = {
517+
...mockExtensionState,
518+
clineMessages: [
519+
{
520+
type: "ask",
521+
ask: "completion_result",
522+
ts: Date.now(),
523+
text: "Task completed!",
524+
partial: true,
525+
},
526+
],
527+
taskHeaderHighlightEnabled: true,
528+
}
529+
530+
renderTaskHeader()
531+
532+
const container = screen.getByTestId("task-header-container")
533+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
534+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
535+
})
536+
537+
it("should not apply highlight when no clineMessages exist", () => {
538+
mockExtensionState = {
539+
...mockExtensionState,
540+
clineMessages: [],
541+
taskHeaderHighlightEnabled: true,
542+
}
543+
544+
renderTaskHeader()
545+
546+
const container = screen.getByTestId("task-header-container")
547+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
548+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
549+
})
550+
551+
it("should not apply highlight when last relevant message has no ask type", () => {
552+
mockExtensionState = {
553+
...mockExtensionState,
554+
clineMessages: [{ type: "say", say: "text", ts: Date.now(), text: "Working..." }],
555+
taskHeaderHighlightEnabled: true,
556+
}
557+
558+
renderTaskHeader()
559+
560+
const container = screen.getByTestId("task-header-container")
561+
expect(container.classList.contains("task-header-highlight-green")).toBe(false)
562+
expect(container.classList.contains("task-header-highlight-yellow")).toBe(false)
563+
})
564+
565+
it("should apply green class when completion_result is followed by resume messages", () => {
566+
mockExtensionState = {
567+
...mockExtensionState,
568+
clineMessages: [
569+
{
570+
type: "ask",
571+
ask: "completion_result",
572+
ts: Date.now() - 2000,
573+
text: "Task completed!",
574+
},
575+
{
576+
type: "ask",
577+
ask: "resume_completed_task",
578+
ts: Date.now() - 1000,
579+
text: "Resume completed task?",
580+
},
581+
{
582+
type: "ask",
583+
ask: "resume_task",
584+
ts: Date.now(),
585+
text: "Resume task?",
586+
},
587+
],
588+
taskHeaderHighlightEnabled: true,
589+
}
590+
591+
renderTaskHeader()
592+
593+
const container = screen.getByTestId("task-header-container")
594+
expect(container.classList.contains("task-header-highlight-green")).toBe(true)
595+
})
596+
})
597+
426598
describe("Context window percentage calculation", () => {
427599
// The percentage should be calculated as:
428600
// contextTokens / (contextWindow - reservedForOutput) * 100

0 commit comments

Comments
 (0)