feat(desktop): align scheduler UI, run records, and single-source notifications

- Tasks/Records tabbed view with an execution-records list + detail modal;
  run-detail resolves channel type/name and owning agent.
- Channel cards match the web console (instance friendly name as title with
  inline rename, type · id as subtitle).
- ConfirmDialog renders above the modal that spawned it (elevated z-index).
- Scheduler notifications come from a single global runs poll
  (useSchedulerNotifyPoll), deduped by run id, so a scheduled push into any
  session surfaces exactly once.

Co-authored-by: cowagent <cow@cowagent.ai>
This commit is contained in:
zhayujieandcowagent committed 2026-09-08 15:27:03 +08:00
1 parent 6f9c776553
commit d6a2ea7e22
13 files changed
+784 -87

No files matched your search

+4 -2
View File
@@ -564,11 +564,13 @@ function setupIPC() {
// Show a native OS notification (e.g. a scheduler reminder or a finished
// task). Clicking it brings the window forward and asks the renderer to open
// the given session.
ipcMain.handle('notify', (_event, payload: { title?: string; body?: string; sessionId?: string; silent?: boolean }) => {
ipcMain.handle('notify', (_event, payload: { title?: string; body?: string; sessionId?: string; silent?: boolean; force?: boolean }) => {
if (!Notification.isSupported() || !payload?.body) return false
// Skip when the window is focused: the user is already watching, so a
// notification (and sound) would just be noise, especially for short tasks.
if (mainWindow?.isFocused()) return false
// Exception: a scheduled task (force) may fire into a session the user
// isn't viewing even while the window is focused, so they must be told.
if (!payload.force && mainWindow?.isFocused()) return false
// Use the runtime app icon if one was set (via set-app-icon), so the
// notification matches the current window/Dock icon. Falls back to the
// packaged icon.
+1 -1
View File
@@ -111,7 +111,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Show a native OS notification; clicking it focuses the window and asks the
// renderer (via onOpenSession) to open the given session.
notify: (payload: { title?: string; body?: string; sessionId?: string; silent?: boolean }) =>
notify: (payload: { title?: string; body?: string; sessionId?: string; silent?: boolean; force?: boolean }) =>
ipcRenderer.invoke('notify', payload) as Promise<boolean>,
onOpenSession: (callback: (sessionId: string) => void) => {
const handler = (_event: unknown, sessionId: string) => callback(sessionId)
+4
View File
@@ -9,6 +9,7 @@ import LoginGate from './components/LoginGate'
import { useBackend } from './hooks/useBackend'
import { usePlatform } from './hooks/usePlatform'
import { usePushPoll } from './hooks/usePushPoll'
import { useSchedulerNotifyPoll } from './hooks/useSchedulerNotifyPoll'
import { useUIStore } from './store/uiStore'
import { useSessionStore } from './store/sessionStore'
import { useWorkspaceStore } from './store/workspaceStore'
@@ -141,6 +142,9 @@ const App: React.FC = () => {
// Poll for scheduler/push messages once the backend and auth are settled.
usePushPoll(backend.status === 'ready' && authState === 'ok')
// Independently watch the global runs ledger so a scheduled task firing into a
// non-active session still notifies (usePushPoll only sees the open session).
useSchedulerNotifyPoll(backend.status === 'ready' && authState === 'ok')
// A clicked OS notification asks us to open its session.
useEffect(() => {
+41
View File
@@ -10,6 +10,8 @@ import type {
MemoryPage,
MemoryDoc,
SchedulerTask,
SchedulerRun,
SchedulerRunDetail,
TaskSchedule,
TaskAction,
SchedulerInstance,
@@ -938,6 +940,45 @@ class ApiClient {
return data.tasks
}
// Execution history for scheduled tasks, newest first. In multi-Agent mode we
// send an explicit empty agent_id so the backend returns the whole team's
// history (mirroring the task list); single-Agent mode omits the param.
// Pass a taskId to narrow to one task's runs.
async getSchedulerRuns(taskId = '', limit = 100): Promise<SchedulerRun[]> {
const qs = new URLSearchParams()
if (this.activeAgentId) qs.set('agent_id', '')
if (taskId) qs.set('task_id', taskId)
if (limit) qs.set('limit', String(limit))
const query = qs.toString()
const path = query ? `/api/scheduler/runs?${query}` : '/api/scheduler/runs'
const data = await this.request<{ status: string; runs: SchedulerRun[] }>(path)
return data.runs || []
}
// Runs across ALL Agents that started after `since` (epoch seconds). Powers
// the cross-session scheduler notification poll: a scheduled task can fire
// into a session (any Agent) the user isn't viewing, so this is deliberately
// NOT scoped to the active Agent — the notifier decides what to surface.
async getSchedulerRunsSince(since: number, limit = 20): Promise<SchedulerRun[]> {
const qs = new URLSearchParams()
qs.set('since', String(Math.floor(since)))
if (limit) qs.set('limit', String(limit))
const data = await this.request<{ status: string; runs: SchedulerRun[] }>(
`/api/scheduler/runs?${qs.toString()}`
)
return data.runs || []
}
// Full detail for one run: the complete delivered body recovered from the
// receiver's session, or null (fall back to preview). Opened on demand when a
// history record is clicked, so the list stays a light index.
async getSchedulerRunDetail(runId: string): Promise<SchedulerRunDetail | null> {
const data = await this.request<{ status: string; run?: SchedulerRunDetail }>(
`/api/scheduler/runs/detail?run_id=${encodeURIComponent(runId)}`
)
return data.run || null
}
// Task mutations route to the owning Agent's store via its agent_id. Passing
// an empty string (or omitting in single-Agent mode) keeps the legacy path.
async runTask(taskId: string, agentId = ''): Promise<ApiResult> {
@@ -29,7 +29,7 @@ const ConfirmDialog: React.FC = () => {
if (!pending) return null
return (
<Modal open title={t(pending.titleKey)} onClose={() => answer(false)}>
<Modal open elevated title={t(pending.titleKey)} onClose={() => answer(false)}>
<p className="text-sm text-content-secondary leading-relaxed">{t(pending.msgKey)}</p>
<div className="flex items-center justify-end gap-2 pt-1">
<Btn onClick={() => answer(false)}>{t('config_cancel')}</Btn>
@@ -42,7 +42,13 @@ export function usePushPoll(ready: boolean): void {
if (cancelled) return
if (data.status === 'success' && data.has_content && data.content) {
const added = useChatStore.getState().receivePush(sid, data.content, data.request_id)
if (added) notify(sid, data.content)
// Scheduler executions (request_id "scheduler_<task>_<hash>") are
// notified by useSchedulerNotifyPoll instead — it forces a notice
// across ALL sessions (and manual "run now") and dedupes by run. Only
// notify here for ordinary proactive pushes, so a scheduled task the
// user happens to be viewing isn't announced twice.
const isScheduler = (data.request_id || '').startsWith('scheduler_')
if (added && !isScheduler) notify(sid, data.content)
schedule(DELAY_HIT_MS)
return
}
@@ -0,0 +1,105 @@
import { useEffect } from 'react'
import apiClient from '../api/client'
import { notifyScheduledRun } from '../lib/taskNotify'
// Poll the GLOBAL runs ledger for scheduled executions, independent of which
// session is active. usePushPoll only watches the currently open session, so a
// scheduled task firing into any other session (the common case for reminders)
// would never surface. This loop asks "any scheduled execution since I last
// checked?" and is the SINGLE source of scheduler notifications: it forces a
// notice for every client-delivered execution — timer or manual "run now",
// current session or not — and dedupes by run id (usePushPoll deliberately
// stays silent for scheduler pushes). The delivered body is already persisted
// to the session history, so clicking the notification (open-session) loads it.
const POLL_INTERVAL_MS = 10000
// Module-level guard so only ONE loop runs process-wide (StrictMode double-mount
// in dev, or the hook accidentally mounted twice, would otherwise notify twice).
let loopActive = false
// Runs we've already notified for, so overlapping polls (or a run that lingers
// in the `since` window across two ticks) never double-fire a notification.
const notifiedRunIds = new Set<string>()
export function useSchedulerNotifyPoll(ready: boolean): void {
useEffect(() => {
if (!ready || loopActive) return
loopActive = true
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
// Ignore anything that ran before this client came up: on first tick we only
// want NEW executions, not a backlog of history replayed as notifications.
let since = Math.floor(Date.now() / 1000)
const schedule = () => {
if (cancelled) return
timer = setTimeout(tick, POLL_INTERVAL_MS)
}
async function tick() {
if (cancelled) return
try {
const runs = await apiClient.getSchedulerRunsSince(since, 20)
if (cancelled) return
// Oldest first so `since` advances monotonically and notifications
// arrive in execution order.
const ordered = runs
.slice()
.sort((a, b) => (a.started_at || 0) - (b.started_at || 0))
for (const run of ordered) {
if (run.started_at && run.started_at > since) since = run.started_at
maybeNotify(run)
}
} catch {
/* transient; keep polling */
}
schedule()
}
tick()
return () => {
cancelled = true
loopActive = false
if (timer) clearTimeout(timer)
}
}, [ready])
}
function maybeNotify(run: {
run_id?: string
session_id?: string
status?: string
channel_type?: string
task_name?: string
output_preview?: string
}): void {
const sid = run.session_id
if (!sid) return
// Only client-delivered tasks: WeChat/Feishu etc. already push into the IM
// app, so re-notifying in the desktop client would be noise.
const channel = run.channel_type || ''
if (channel && channel !== 'web') return
// Skip failed runs — nothing was delivered to jump to.
if (run.status && run.status !== 'done') return
// Dedupe: a run can appear in two overlapping poll windows.
const runId = run.run_id || ''
if (runId) {
if (notifiedRunIds.has(runId)) return
notifiedRunIds.add(runId)
// Bound memory for long-lived clients with frequent tasks; the poll only
// looks back a short window, so trimmed ids can never reappear.
if (notifiedRunIds.size > 500) {
const drop = notifiedRunIds.size - 500
let i = 0
for (const id of notifiedRunIds) {
if (i++ >= drop) break
notifiedRunIds.delete(id)
}
}
}
// Always force a notification, even for the currently-open session and for
// manual "run now": a scheduled execution should announce itself wherever the
// user is. usePushPoll deliberately skips notifying for scheduler pushes so
// this is the single source of scheduler notifications (no double-fire).
notifyScheduledRun(sid, run.task_name || '', run.output_preview || '')
}
+60
View File
@@ -552,6 +552,36 @@ const translations: Record<string, Record<string, string>> = {
feishu_sdk_downloading_hint: '首次启用需下载飞书组件(约 1MB),连接可能稍慢',
tasks_title: '定时任务',
tasks_desc: '查看和管理定时任务',
tasks_tab_tasks: '任务',
tasks_tab_records: '执行记录',
records_desc: '查看定时任务的执行历史',
records_loading: '加载执行记录中...',
records_empty: '暂无执行记录',
records_empty_guide: '任务执行后,成功与失败的记录都会显示在这里',
records_refresh: '刷新',
records_status_done: '成功',
records_status_error: '失败',
records_status_running: '执行中',
records_trigger_scheduled: '定时触发',
records_trigger_manual: '手动执行',
records_col_task: '任务',
records_duration: '耗时',
records_output: '发送内容',
records_no_output: '(无内容)',
record_detail_title: '执行详情',
record_detail_loading: '加载详情中...',
record_detail_status: '状态',
record_detail_trigger: '触发方式',
record_detail_started: '开始时间',
record_detail_duration: '耗时',
record_detail_error: '错误信息',
record_detail_channel_type: '通道类型',
record_detail_channel_name: '通道名称',
record_detail_agent: '智能体',
record_channel_web_type: 'desktop',
record_channel_web_name: '客户端',
record_detail_output: '发送内容',
record_detail_close: '关闭',
tasks_active: '运行中',
tasks_paused: '已暂停',
tasks_empty: '暂无定时任务',
@@ -1220,6 +1250,36 @@ const translations: Record<string, Record<string, string>> = {
feishu_sdk_downloading_hint: 'First run downloads Feishu components (~1MB), so connecting may take a moment',
tasks_title: 'Scheduled Tasks',
tasks_desc: 'View and manage scheduled tasks',
tasks_tab_tasks: 'Tasks',
tasks_tab_records: 'History',
records_desc: 'Execution history of scheduled tasks',
records_loading: 'Loading history...',
records_empty: 'No executions yet',
records_empty_guide: 'Once tasks run, both successful and failed executions show up here',
records_refresh: 'Refresh',
records_status_done: 'Success',
records_status_error: 'Failed',
records_status_running: 'Running',
records_trigger_scheduled: 'Scheduled',
records_trigger_manual: 'Manual',
records_col_task: 'Task',
records_duration: 'Duration',
records_output: 'Delivered',
records_no_output: '(no content)',
record_detail_title: 'Execution detail',
record_detail_loading: 'Loading detail...',
record_detail_status: 'Status',
record_detail_trigger: 'Trigger',
record_detail_started: 'Started',
record_detail_duration: 'Duration',
record_detail_error: 'Error',
record_detail_channel_type: 'Channel type',
record_detail_channel_name: 'Channel name',
record_detail_agent: 'Agent',
record_channel_web_type: 'desktop',
record_channel_web_name: 'Client',
record_detail_output: 'Delivered',
record_detail_close: 'Close',
tasks_active: 'Active',
tasks_paused: 'Paused',
tasks_empty: 'No scheduled tasks',
@@ -25,3 +25,29 @@ export function notifyRunDone(sid: string, kind: 'done' | 'error', text: string)
})
.catch(() => {})
}
/**
* Fire a notification for a scheduled task that ran in a session the user may
* not be viewing. Unlike notifyRunDone this passes `force: true`, so the main
* process shows it even when the window is focused: a timer firing into another
* session is invisible otherwise, and the whole point of a reminder is to reach
* the user wherever they are. Still honors the "task notify" toggle. `title`
* prefers the task name (sessions for other Agents may not be in local state).
*/
export function notifyScheduledRun(sid: string, taskName: string, preview: string): void {
const { taskNotify, taskNotifySound } = useUIStore.getState()
if (!taskNotify) return
const prefix = t('notify_task_done')
const snippet = (preview || '').split('\n')[0].trim().slice(0, SNIPPET_LIMIT)
const sessionTitle = useSessionStore.getState().sessions.find((s) => s.session_id === sid)?.title
const title = taskName || sessionTitle || undefined
window.electronAPI
?.notify?.({
title,
body: snippet ? `${prefix}: ${snippet}` : prefix,
sessionId: sid,
silent: !taskNotifySound,
force: true,
})
.catch(() => {})
}
+88 -62
View File
@@ -422,15 +422,44 @@ const ModeTab: React.FC<{ icon: LucideIcon; label: string; active: boolean; onCl
</button>
)
// Inline rename for a channel instance's friendly name (e.g. "微信2"), mirroring
// the web console's pencil-edit. Saving only sets a display label; it never
// touches credentials or the live connection. Enter commits unless an IME
// composition is active (so typing English via a Chinese IME isn't cut short).
const InstanceNameEditor: React.FC<{ channel: ChannelInfo; onRenamed: () => void }> = ({
channel,
onRenamed,
}) => {
const current = channel.instance_name || channel.instance_id || channel.name
// The connection status dot + label (waiting-to-scan / starting / connected),
// shared by the instance and non-instance title rows.
const StatusBadge: React.FC<{ channel: ChannelInfo; pending: Pending }> = ({ channel, pending }) => (
<>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
pending !== 'none'
? 'bg-warning animate-pulse'
: channel.active
? 'bg-accent'
: 'bg-content-tertiary'
}`}
/>
{pending === 'scanning' ? (
<span className={`text-xs ${channel.login_status === 'scanned' ? 'text-accent' : 'text-warning'}`}>
{channel.login_status === 'scanned' ? t('weixin_scan_scanned') : t('weixin_scan_waiting')}
</span>
) : pending === 'starting' ? (
<span className="text-xs text-warning">{t('channels_starting')}</span>
) : channel.active ? (
<span className="text-xs text-accent">{t('channels_connected')}</span>
) : null}
</>
)
// The title row for an instance card: the friendly instance name as the heading,
// with an inline pencil-rename (mirroring the web console), followed by whatever
// status badge the caller passes as children. Saving only sets a display label;
// it never touches credentials or the live connection. Enter commits unless an
// IME composition is active (so typing English via a Chinese IME isn't cut short).
const InstanceNameEditor: React.FC<{
channel: ChannelInfo
onRenamed: () => void
children?: React.ReactNode
}> = ({ channel, onRenamed, children }) => {
// Title falls back to the type label when no friendly name is set yet, so the
// heading never reads as a bare id (matches the web console).
const current = channel.instance_name || localizedLabel(channel.label)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(current)
const [saving, setSaving] = useState(false)
@@ -452,40 +481,41 @@ const InstanceNameEditor: React.FC<{ channel: ChannelInfo; onRenamed: () => void
}
}
if (!editing) {
if (editing) {
return (
<div className="flex items-center gap-1.5 mt-0.5 group/name">
<span className="text-xs text-content-tertiary font-mono truncate">{current}</span>
<button
onClick={() => {
setValue(channel.instance_name || '')
setEditing(true)
}}
className="text-content-tertiary hover:text-content-secondary cursor-pointer opacity-0 group-hover/name:opacity-100 transition-opacity"
title={t('channels_rename_instance')}
>
<Pencil size={11} />
</button>
</div>
<input
autoFocus
value={value}
disabled={saving}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter' && !composingRef.current) void commit()
else if (e.key === 'Escape') setEditing(false)
}}
placeholder={t('channels_instance_name_placeholder')}
className="w-44 px-2 py-0.5 rounded-btn border border-strong bg-inset text-sm text-content focus:outline-none focus:border-accent"
/>
)
}
return (
<input
autoFocus
value={value}
disabled={saving}
onChange={(e) => setValue(e.target.value)}
onCompositionStart={() => (composingRef.current = true)}
onCompositionEnd={() => (composingRef.current = false)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter' && !composingRef.current) void commit()
else if (e.key === 'Escape') setEditing(false)
}}
placeholder={t('channels_instance_name_placeholder')}
className="mt-0.5 w-40 px-2 py-0.5 rounded-btn border border-strong bg-inset text-xs text-content focus:outline-none focus:border-accent"
/>
<div className="flex items-center gap-2 group/name min-w-0">
<span className="font-medium text-sm text-content truncate">{current}</span>
<button
onClick={() => {
setValue(channel.instance_name || '')
setEditing(true)
}}
className="text-content-tertiary hover:text-content-secondary cursor-pointer opacity-0 group-hover/name:opacity-100 transition-opacity flex-shrink-0"
title={t('channels_rename_instance')}
>
<Pencil size={12} />
</button>
{children}
</div>
)
}
@@ -498,6 +528,9 @@ const ChannelCard: React.FC<{
// editing the existing one of the same type.
forceNewInstance?: boolean
}> = ({ channel, onChanged, defaultExpanded = false, multiAgent = false, forceNewInstance = false }) => {
// A per-instance card (multi-Agent mode, with a concrete instance_id) is the
// one whose title is the friendly instance name and which can be renamed.
const isInstance = multiAgent && !!channel.instance_id
// Channels with no fields connect purely via QR (e.g. weixin).
const isQrLogin = channel.fields.length === 0
// QR provider supported by the desktop scan panel (weixin / feishu).
@@ -617,32 +650,25 @@ const ChannelCard: React.FC<{
<div className="flex items-center gap-3">
<ChannelIcon name={channel.name} size={40} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
<span
className={`w-2 h-2 rounded-full ${
pending !== 'none'
? 'bg-warning animate-pulse'
: channel.active
? 'bg-accent'
: 'bg-content-tertiary'
}`}
/>
{pending === 'scanning' ? (
<span className={`text-xs ${channel.login_status === 'scanned' ? 'text-accent' : 'text-warning'}`}>
{channel.login_status === 'scanned' ? t('weixin_scan_scanned') : t('weixin_scan_waiting')}
</span>
) : pending === 'starting' ? (
<span className="text-xs text-warning">{t('channels_starting')}</span>
) : channel.active ? (
<span className="text-xs text-accent">{t('channels_connected')}</span>
) : null}
</div>
{multiAgent && channel.instance_id ? (
<InstanceNameEditor channel={channel} onRenamed={onChanged} />
{/* Title row. For a multi-Agent instance the friendly instance name is
the title (with an inline rename), matching the web console; the
subtitle below then carries "type · id". A non-instance card keeps
the channel-type label as its title. */}
{isInstance ? (
<InstanceNameEditor channel={channel} onRenamed={onChanged}>
<StatusBadge channel={channel} pending={pending} />
</InstanceNameEditor>
) : (
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.instance_id || channel.name}</p>
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-content truncate">{localizedLabel(channel.label)}</span>
<StatusBadge channel={channel} pending={pending} />
</div>
)}
<p className="text-xs text-content-tertiary font-mono mt-0.5 truncate">
{isInstance
? `${localizedLabel(channel.label)} · ${channel.instance_id}`
: channel.instance_id || channel.name}
</p>
</div>
{channel.active ? (
+404 -17
View File
@@ -1,10 +1,23 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Loader2, Clock, CalendarClock, Play, Plus, RefreshCw, Check } from 'lucide-react'
import {
Loader2,
Clock,
CalendarClock,
Play,
Plus,
RefreshCw,
Check,
CheckCircle2,
XCircle,
History,
} from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
import type {
SchedulerTask,
SchedulerRun,
SchedulerRunDetail,
TaskSchedule,
TaskAction,
SchedulerInstance,
@@ -65,13 +78,20 @@ const FormError: React.FC<{ message: string }> = ({ message }) =>
</div>
) : null
type TabKey = 'tasks' | 'records'
const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
const navigate = useNavigate()
const [tab, setTab] = useState<TabKey>('tasks')
const [tasks, setTasks] = useState<SchedulerTask[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<SchedulerTask | null>(null)
const [creating, setCreating] = useState(false)
const [runningId, setRunningId] = useState('')
// The History tab owns its own data; it registers a reload fn here so the
// shared refresh control on the tab row can drive it (saving a header row).
const recordsReloadRef = React.useRef<(() => void) | null>(null)
const [recordsLoading, setRecordsLoading] = useState(false)
const multiAgent = useAgentStore(selectMultiAgent)
const loadTasks = async () => {
@@ -122,26 +142,74 @@ const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
<div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start gap-3">
<div className="min-w-0">
<h2 className="text-xl font-bold text-content">{t('tasks_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{t('tasks_desc')}</p>
<p className="text-xs text-content-tertiary mt-1">
{tab === 'tasks' ? t('tasks_desc') : t('records_desc')}
</p>
</div>
<div className="flex-1" />
<button
onClick={() => void loadTasks()}
className="p-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
title={t('tasks_refresh')}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setCreating(true)}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
<Plus size={15} />
{t('tasks_new')}
</button>
{tab === 'tasks' && (
<>
<button
onClick={() => void loadTasks()}
className="p-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
title={t('tasks_refresh')}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
<button
onClick={() => setCreating(true)}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
<Plus size={15} />
{t('tasks_new')}
</button>
</>
)}
</div>
<div className="flex-1 overflow-y-auto border-t border-default">
{/* Tab switcher: Tasks | History. Sits on the header's bottom border so the
active tab reads as a selected segment, matching the settings tabs. */}
<div className="px-6 flex-shrink-0 flex items-center gap-1 border-b border-default">
{(['tasks', 'records'] as TabKey[]).map((key) => (
<button
key={key}
onClick={() => setTab(key)}
className={`relative px-3 py-2 text-sm font-medium cursor-pointer transition-colors ${
tab === key
? 'text-content'
: 'text-content-tertiary hover:text-content-secondary'
}`}
>
<span className="inline-flex items-center gap-1.5">
{key === 'tasks' ? <CalendarClock size={14} /> : <History size={14} />}
{key === 'tasks' ? t('tasks_tab_tasks') : t('tasks_tab_records')}
</span>
{tab === key && (
<span className="absolute left-0 right-0 -bottom-px h-0.5 bg-accent rounded-full" />
)}
</button>
))}
<div className="flex-1" />
{/* History tab: refresh lives here on the tab row rather than in its own
body header, so it costs no vertical space. */}
{tab === 'records' && (
<button
onClick={() => recordsReloadRef.current?.()}
className="p-1.5 rounded-btn text-content-tertiary hover:text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
title={t('records_refresh')}
>
<RefreshCw size={14} className={recordsLoading ? 'animate-spin' : ''} />
</button>
)}
</div>
{tab === 'records' ? (
<RecordsView
registerReload={(fn) => (recordsReloadRef.current = fn)}
onLoadingChange={setRecordsLoading}
/>
) : (
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
@@ -233,6 +301,7 @@ const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
)}
</div>
</div>
)}
{editing && (
<TaskEditModal
@@ -262,6 +331,324 @@ const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
)
}
// ------------------------------------------------------------------
// Execution history tab: the runs the scheduler wrote to the global ledger,
// newest first. Read-only; each row shows outcome, when it ran, how it fired,
// and a peek at what was delivered.
// ------------------------------------------------------------------
const formatRunTime = (sec?: number | null): string => {
if (!sec) return '--'
const d = new Date(sec * 1000)
return isNaN(d.getTime()) ? '--' : d.toLocaleString()
}
// Compact elapsed time between start and end (blank while still running).
const formatDuration = (start?: number, end?: number | null): string => {
if (!start || !end || end < start) return ''
const s = end - start
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
const r = s % 60
return r ? `${m}m ${r}s` : `${m}m`
}
const RunStatusBadge: React.FC<{ status: string }> = ({ status }) => {
if (status === 'done') {
return (
<span className="inline-flex items-center gap-1 text-success">
<CheckCircle2 size={13} />
<span className="text-xs font-medium">{t('records_status_done')}</span>
</span>
)
}
if (status === 'error') {
return (
<span className="inline-flex items-center gap-1 text-danger">
<XCircle size={13} />
<span className="text-xs font-medium">{t('records_status_error')}</span>
</span>
)
}
return (
<span className="inline-flex items-center gap-1 text-content-tertiary">
<Loader2 size={13} className="animate-spin" />
<span className="text-xs font-medium">{t('records_status_running')}</span>
</span>
)
}
const RecordsView: React.FC<{
registerReload: (fn: () => void) => void
onLoadingChange: (loading: boolean) => void
}> = ({ registerReload, onLoadingChange }) => {
const navigate = useNavigate()
const [runs, setRuns] = useState<SchedulerRun[]>([])
const [loading, setLoading] = useState(true)
const [detailRun, setDetailRun] = useState<SchedulerRun | null>(null)
const multiAgent = useAgentStore(selectMultiAgent)
const loadRuns = async () => {
try {
setLoading(true)
onLoadingChange(true)
const data = await apiClient.getSchedulerRuns()
setRuns(data || [])
} catch (err) {
console.error('Failed to load run history:', err)
setRuns([])
} finally {
setLoading(false)
onLoadingChange(false)
}
}
useEffect(() => {
registerReload(() => void loadRuns())
void loadRuns()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex-1 overflow-y-auto">
<div className="max-w-3xl mx-auto px-6 py-5">
{loading ? (
<div className="flex items-center justify-center py-20 text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('records_loading')}
</div>
) : runs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<History size={32} className="mb-3 text-content-tertiary opacity-60" />
<p className="text-content font-medium mb-1">{t('records_empty')}</p>
<p className="text-sm text-content-tertiary max-w-sm mb-5">
{t('records_empty_guide')}
</p>
<button
onClick={() => navigate('/')}
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors"
>
{t('tasks_go_chat')}
</button>
</div>
) : (
<div className="grid gap-2.5">
{runs.map((run) => {
const owner = multiAgent && run.agent_id ? findAgent(run.agent_id) : null
const duration = formatDuration(run.started_at, run.ended_at)
const trigger =
run.trigger === 'manual'
? t('records_trigger_manual')
: t('records_trigger_scheduled')
return (
<div
key={run.run_id}
onClick={() => setDetailRun(run)}
className="rounded-card border border-default bg-surface p-4 cursor-pointer hover:border-strong transition-colors"
>
<div className="flex items-center gap-2 mb-1.5">
<RunStatusBadge status={run.status} />
<span className="font-medium text-sm text-content truncate">
{run.task_name || run.task_id || t('records_col_task')}
</span>
{owner && (
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-inset-2 text-content-secondary flex-shrink-0"
title={owner.name || owner.id}
>
<AgentAvatar agent={owner} size={14} />
<span className="text-[11px] max-w-[80px] truncate">
{owner.name || owner.id}
</span>
</span>
)}
<div className="flex-1" />
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-inset text-content-tertiary flex-shrink-0">
{trigger}
</span>
</div>
{run.status === 'error' && run.error ? (
<p className="text-xs text-danger mb-2 line-clamp-2 break-words">
{run.error}
</p>
) : (
<p className="text-xs text-content-secondary mb-2 line-clamp-2 break-words">
{run.output_preview || (
<span className="text-content-tertiary italic">
{t('records_no_output')}
</span>
)}
</p>
)}
<div className="flex items-center gap-2 text-xs text-content-tertiary">
<Clock size={12} />
<span>{formatRunTime(run.started_at)}</span>
{duration && (
<>
<span className="text-content-tertiary/50">·</span>
<span>
{t('records_duration')} {duration}
</span>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{detailRun && (
<RunDetailModal run={detailRun} onClose={() => setDetailRun(null)} />
)}
</div>
)
}
// The history detail dialog. Opens with the list row's data shown immediately,
// then fetches the full delivered body (recovered from the receiver's session)
// and swaps it in. Read-only.
const RunDetailModal: React.FC<{ run: SchedulerRun; onClose: () => void }> = ({
run,
onClose,
}) => {
const [detail, setDetail] = useState<SchedulerRunDetail | null>(null)
const [loading, setLoading] = useState(true)
const [instances, setInstances] = useState<SchedulerInstance[]>([])
const multiAgent = useAgentStore(selectMultiAgent)
useEffect(() => {
let cancelled = false
;(async () => {
setLoading(true)
// Detail (full body) and the instance directory (to name the channel) in
// parallel; the directory is best-effort and never blocks the body.
const [d, insts] = await Promise.all([
apiClient.getSchedulerRunDetail(run.run_id).catch(() => null),
apiClient.getSchedulerInstances().catch(() => []),
])
if (cancelled) return
setDetail(d)
setInstances(insts)
setLoading(false)
})()
return () => {
cancelled = true
}
}, [run.run_id])
const trigger =
run.trigger === 'manual' ? t('records_trigger_manual') : t('records_trigger_scheduled')
const duration = formatDuration(run.started_at, run.ended_at)
// Prefer the full body from the session; fall back to the list preview.
const body = detail?.full_output || run.output_preview || ''
// Channel type / friendly instance name / owning Agent for the meta grid.
// Web tasks come from a chat session, not a bound IM instance, so present them
// as the desktop client rather than the bare "web" type / empty name.
const rawChannelType = run.channel_type || detail?.channel_type || ''
const instanceId = run.instance_id || detail?.instance_id || ''
const instance = instances.find((i) => i.instance_id === instanceId) || null
const isWebTask = rawChannelType === 'web' || (!rawChannelType && !instanceId)
const channelType = isWebTask ? t('record_channel_web_type') : rawChannelType
const channelName = isWebTask
? t('record_channel_web_name')
: instance?.name || instanceId || ''
const owner = run.agent_id ? findAgent(run.agent_id) : null
return (
<Modal
open
size="lg"
title={run.task_name || run.task_id || t('record_detail_title')}
onClose={onClose}
footer={
<Btn variant="ghost" onClick={onClose}>
{t('record_detail_close')}
</Btn>
}
>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<div className="text-xs text-content-tertiary mb-0.5">{t('record_detail_status')}</div>
<RunStatusBadge status={run.status} />
</div>
<div>
<div className="text-xs text-content-tertiary mb-0.5">{t('record_detail_trigger')}</div>
<span className="text-content">{trigger}</span>
</div>
<div>
<div className="text-xs text-content-tertiary mb-0.5">{t('record_detail_started')}</div>
<span className="text-content">{formatRunTime(run.started_at)}</span>
</div>
{duration && (
<div>
<div className="text-xs text-content-tertiary mb-0.5">
{t('record_detail_duration')}
</div>
<span className="text-content">{duration}</span>
</div>
)}
{channelType && (
<div>
<div className="text-xs text-content-tertiary mb-0.5">
{t('record_detail_channel_type')}
</div>
<span className="text-content">{channelType}</span>
</div>
)}
{channelName && (
<div>
<div className="text-xs text-content-tertiary mb-0.5">
{t('record_detail_channel_name')}
</div>
<span className="text-content break-all">{channelName}</span>
</div>
)}
{multiAgent && owner && (
<div>
<div className="text-xs text-content-tertiary mb-0.5">
{t('record_detail_agent')}
</div>
<span className="inline-flex items-center gap-1.5 text-content">
<AgentAvatar agent={owner} size={16} />
<span className="truncate max-w-[140px]">{owner.name || owner.id}</span>
</span>
</div>
)}
</div>
{run.status === 'error' && run.error && (
<div className="mt-4">
<div className="text-xs text-content-tertiary mb-1">{t('record_detail_error')}</div>
<div className="rounded-btn border border-danger-border bg-danger-soft px-3 py-2 text-sm text-danger break-words whitespace-pre-wrap">
{run.error}
</div>
</div>
)}
<div className="mt-4">
<div className="text-xs text-content-tertiary mb-1">{t('record_detail_output')}</div>
{loading ? (
<div className="flex items-center gap-2 text-content-tertiary text-sm py-4">
<Loader2 size={14} className="animate-spin" />
{t('record_detail_loading')}
</div>
) : body ? (
<div className="rounded-btn border border-default bg-inset px-3 py-2 text-sm text-content whitespace-pre-wrap break-words max-h-80 overflow-y-auto">
{body}
</div>
) : (
<p className="text-sm text-content-tertiary italic py-2">{t('records_no_output')}</p>
)}
</div>
</Modal>
)
}
// ------------------------------------------------------------------
// Two-step recipient picker (channel instance -> recipient), shared by the
// create modal and the IM branch of the edit modal. It owns fetching the
@@ -323,11 +323,15 @@ export const Modal: React.FC<{
// Dialog width. 'md' (default) keeps the compact settings dialog; 'lg' gives
// form-heavy dialogs (e.g. the task editor) more breathing room.
size?: 'md' | 'lg'
}> = ({ open, title, onClose, children, footer, headerRight, size = 'md' }) => {
// Stack above another open modal. A confirm dialog spawned from inside a modal
// (e.g. "delete this task?" over the task editor) must sit on top; both share
// the base z-50 otherwise and the later-painted one wins, hiding the confirm.
elevated?: boolean
}> = ({ open, title, onClose, children, footer, headerRight, size = 'md', elevated = false }) => {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
className={`fixed inset-0 ${elevated ? 'z-[60]' : 'z-50'} flex items-center justify-center bg-black/40 p-4`}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose()
}}
+37 -1
View File
@@ -59,7 +59,7 @@ export interface ElectronAPI {
setAppTitle?: (title: string) => Promise<boolean>
// Show a native OS notification; clicking it focuses the window and fires
// onOpenSession with the session id.
notify?: (payload: { title?: string; body?: string; sessionId?: string; silent?: boolean }) => Promise<boolean>
notify?: (payload: { title?: string; body?: string; sessionId?: string; silent?: boolean; force?: boolean }) => Promise<boolean>
onOpenSession?: (callback: (sessionId: string) => void) => () => void
platform: string
// OS UI language (e.g. "zh-CN"); used to default the language on first run.
@@ -943,6 +943,42 @@ export interface SchedulerTask {
agent_id?: string
}
// One recorded execution of a scheduled task, read from the global runs ledger
// (task_source='scheduler'). Mirrors GET /api/scheduler/runs.
export interface SchedulerRun {
run_id: string
// The Agent that ran it. Present in multi-Agent installs; '' is the default.
agent_id?: string
session_id: string
task_id: string
// 'running' | 'done' | 'error'. Open runs (still executing) show 'running'.
status: string
// Unix seconds. ended_at is null while a run is still in flight.
started_at: number
ended_at?: number | null
error?: string
// Snapshot fields lifted from the run's extras index at record time, so
// history stays readable even after the task is renamed or deleted.
task_name?: string
action_type?: string
channel_type?: string
// The delivery channel instance that ran it; resolved to a friendly name
// client-side against the instance directory.
instance_id?: string
// How the tick fired: 'scheduled' (timer) or 'manual' (run-now).
trigger?: string
// Short, length-capped peek at what was delivered.
output_preview?: string
}
// One run plus the full delivered body, for the history detail dialog. Mirrors
// GET /api/scheduler/runs/detail. full_output is the complete message recovered
// from the receiver's session; null when it was pruned or never injected, in
// which case the UI falls back to output_preview.
export interface SchedulerRunDetail extends SchedulerRun {
full_output?: string | null
}
// A channel instance the console can deliver a scheduled task through. The
// task-create flow picks one of these first, then a recipient within it.
// Mirrors GET /api/scheduler/instances.