mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-10-26 01:54:36 +08:00
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:
1 parent
6f9c776553
commit
d6a2ea7e22
13 files changed
+784
-87
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 || '')
|
||||
}
|
||||
@@ -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(() => {})
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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()
|
||||
}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user