feat(features): add TaskToastManager for consolidated task notifications

- Create task-toast-manager feature with singleton pattern

- Show running task list (newest first) when new task starts

- Track queued tasks status from ConcurrencyManager

- Integrate with BackgroundManager and sisyphus-task tool

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
This commit is contained in:
YeonGyu-Kim
2026-01-06 13:40:27 +09:00
parent 110dbd665a
commit 9eafe6b5f9
9 changed files with 371 additions and 48 deletions
+16 -22
View File
@@ -13,6 +13,7 @@ import {
MESSAGE_STORAGE,
} from "../hook-message-injector"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
const TASK_TTL_MS = 30 * 60 * 1000
@@ -122,17 +123,14 @@ export class BackgroundManager {
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (tuiClient.tui?.showToast) {
tuiClient.tui.showToast({
body: {
title: "Background Task Started",
message: `"${input.description}" running with ${input.agent}`,
variant: "info",
duration: 3000,
},
}).catch(() => {})
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.addTask({
id: task.id,
description: input.description,
agent: input.agent,
isBackground: true,
})
}
this.client.session.promptAsync({
@@ -378,17 +376,13 @@ export class BackgroundManager {
log("[background-agent] notifyParentSession called for task:", task.id)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (tuiClient.tui?.showToast) {
tuiClient.tui.showToast({
body: {
title: "Background Task Completed",
message: `Task "${task.description}" finished in ${duration}.`,
variant: "success",
duration: 5000,
},
}).catch(() => {})
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.showCompletionToast({
id: task.id,
description: task.description,
duration,
})
}
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
+2
View File
@@ -0,0 +1,2 @@
export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager"
export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types"
+190
View File
@@ -0,0 +1,190 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { TrackedTask, TaskStatus } from "./types"
import type { ConcurrencyManager } from "../background-agent/concurrency"
type OpencodeClient = PluginInput["client"]
export class TaskToastManager {
private tasks: Map<string, TrackedTask> = new Map()
private client: OpencodeClient
private concurrencyManager?: ConcurrencyManager
constructor(client: OpencodeClient, concurrencyManager?: ConcurrencyManager) {
this.client = client
this.concurrencyManager = concurrencyManager
}
setConcurrencyManager(manager: ConcurrencyManager): void {
this.concurrencyManager = manager
}
/**
* Register a new task and show consolidated toast
*/
addTask(task: {
id: string
description: string
agent: string
isBackground: boolean
status?: TaskStatus
}): void {
const trackedTask: TrackedTask = {
id: task.id,
description: task.description,
agent: task.agent,
status: task.status ?? "running",
startedAt: new Date(),
isBackground: task.isBackground,
}
this.tasks.set(task.id, trackedTask)
this.showTaskListToast(trackedTask)
}
/**
* Update task status
*/
updateTask(id: string, status: TaskStatus): void {
const task = this.tasks.get(id)
if (task) {
task.status = status
}
}
/**
* Remove completed/error task
*/
removeTask(id: string): void {
this.tasks.delete(id)
}
/**
* Get all running tasks (newest first)
*/
getRunningTasks(): TrackedTask[] {
const running = Array.from(this.tasks.values())
.filter((t) => t.status === "running")
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
return running
}
/**
* Get all queued tasks
*/
getQueuedTasks(): TrackedTask[] {
return Array.from(this.tasks.values())
.filter((t) => t.status === "queued")
.sort((a, b) => a.startedAt.getTime() - b.startedAt.getTime())
}
/**
* Format duration since task started
*/
private formatDuration(startedAt: Date): string {
const seconds = Math.floor((Date.now() - startedAt.getTime()) / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
/**
* Build task list message
*/
private buildTaskListMessage(newTask: TrackedTask): string {
const running = this.getRunningTasks()
const queued = this.getQueuedTasks()
const lines: string[] = []
if (running.length > 0) {
lines.push(`Running (${running.length}):`)
for (const task of running) {
const duration = this.formatDuration(task.startedAt)
const bgIcon = task.isBackground ? "⚡" : "🔄"
const isNew = task.id === newTask.id ? " ← NEW" : ""
lines.push(`${bgIcon} ${task.description} (${task.agent}) - ${duration}${isNew}`)
}
}
if (queued.length > 0) {
if (lines.length > 0) lines.push("")
lines.push(`Queued (${queued.length}):`)
for (const task of queued) {
const bgIcon = task.isBackground ? "⏳" : "⏸️"
lines.push(`${bgIcon} ${task.description} (${task.agent})`)
}
}
return lines.join("\n")
}
/**
* Show consolidated toast with all running/queued tasks
*/
private showTaskListToast(newTask: TrackedTask): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return
const message = this.buildTaskListMessage(newTask)
const running = this.getRunningTasks()
const queued = this.getQueuedTasks()
const title = newTask.isBackground
? `⚡ New Background Task`
: `🔄 New Task Executed`
tuiClient.tui.showToast({
body: {
title,
message: message || `${newTask.description} (${newTask.agent})`,
variant: "info",
duration: running.length + queued.length > 2 ? 5000 : 3000,
},
}).catch(() => {})
}
/**
* Show task completion toast
*/
showCompletionToast(task: { id: string; description: string; duration: string }): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tuiClient = this.client as any
if (!tuiClient.tui?.showToast) return
this.removeTask(task.id)
const remaining = this.getRunningTasks()
const queued = this.getQueuedTasks()
let message = `✅ "${task.description}" finished in ${task.duration}`
if (remaining.length > 0 || queued.length > 0) {
message += `\n\nStill running: ${remaining.length} | Queued: ${queued.length}`
}
tuiClient.tui.showToast({
body: {
title: "Task Completed",
message,
variant: "success",
duration: 5000,
},
}).catch(() => {})
}
}
let instance: TaskToastManager | null = null
export function getTaskToastManager(): TaskToastManager | null {
return instance
}
export function initTaskToastManager(
client: OpencodeClient,
concurrencyManager?: ConcurrencyManager
): TaskToastManager {
instance = new TaskToastManager(client, concurrencyManager)
return instance
}
+17
View File
@@ -0,0 +1,17 @@
export type TaskStatus = "running" | "queued" | "completed" | "error"
export interface TrackedTask {
id: string
description: string
agent: string
status: TaskStatus
startedAt: Date
isBackground: boolean
}
export interface TaskToastOptions {
title: string
message: string
variant: "info" | "success" | "warning" | "error"
duration?: number
}