mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
This introduces a persisted light/dark appearance setting, maps the desktop shell onto semantic theme tokens, and reworks the highest-traffic chat/settings surfaces so the new dark mode is usable without regressing the original light theme. The same pass tightens markdown rendering for chat replies by improving inline code, table overflow handling, and safe external-link behavior so dark-mode content stays legible in real conversations. Constraint: Preserve the existing light theme while adding a user-selectable dark theme in Settings > General Constraint: Avoid introducing new dependencies for styling or markdown handling Rejected: Replacing the light palette with a single dual-purpose palette | would risk broad visual regressions across the existing desktop UI Rejected: Implementing dark mode only for shell chrome | leaves chat markdown, diffs, and permission flows visually broken Confidence: high Scope-risk: moderate Reversibility: clean Directive: New desktop UI should use semantic theme variables instead of hard-coded color literals so both themes stay aligned Tested: bun run lint; bun run test; bun run build; browser review of theme switching, provider/permission states, and chat surfaces Not-tested: Prototype-style pages with remaining hard-coded colors (scheduled/session control mock surfaces) were not fully normalized in this change
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from 'react'
|
|
import type { CronTask } from '../../types/task'
|
|
import { useTaskStore } from '../../stores/taskStore'
|
|
import { useTranslation } from '../../i18n'
|
|
import { describeCron } from '../../lib/cronDescribe'
|
|
import { TaskRunsPanel } from './TaskRunsPanel'
|
|
import { NewTaskModal } from './NewTaskModal'
|
|
|
|
type Props = {
|
|
task: CronTask
|
|
showLogs: boolean
|
|
onToggleLogs: () => void
|
|
}
|
|
|
|
type ConfirmAction = 'run' | 'toggle' | 'delete' | null
|
|
|
|
export function TaskRow({ task, showLogs, onToggleLogs }: Props) {
|
|
const { deleteTask, updateTask, runTask } = useTaskStore()
|
|
const t = useTranslation()
|
|
const [showEdit, setShowEdit] = useState(false)
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
const [isRunning, setIsRunning] = useState(false)
|
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction>(null)
|
|
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
|
const menuRef = useRef<HTMLDivElement>(null)
|
|
const confirmRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Close menu / confirm on outside click
|
|
useEffect(() => {
|
|
if (!showMenu && !confirmAction) return
|
|
const handler = (e: MouseEvent) => {
|
|
const target = e.target as Node
|
|
if (showMenu && menuRef.current && !menuRef.current.contains(target)) {
|
|
setShowMenu(false)
|
|
}
|
|
if (confirmAction && confirmRef.current && !confirmRef.current.contains(target)) {
|
|
setConfirmAction(null)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handler)
|
|
return () => document.removeEventListener('mousedown', handler)
|
|
}, [showMenu, confirmAction])
|
|
|
|
const handleRunNow = async () => {
|
|
setConfirmAction(null)
|
|
setIsRunning(true)
|
|
if (!showLogs) onToggleLogs() // open logs panel (accordion will close others)
|
|
try {
|
|
await runTask(task.id)
|
|
setLogsRefreshKey((k) => k + 1)
|
|
} catch (err) {
|
|
console.error('Failed to run task:', err)
|
|
} finally {
|
|
setIsRunning(false)
|
|
}
|
|
}
|
|
|
|
const handleToggle = () => {
|
|
setConfirmAction(null)
|
|
setShowMenu(false)
|
|
updateTask(task.id, { enabled: !task.enabled })
|
|
}
|
|
|
|
const handleDelete = () => {
|
|
setConfirmAction(null)
|
|
setShowMenu(false)
|
|
deleteTask(task.id)
|
|
}
|
|
|
|
const iconBtn = 'p-1.5 rounded-[var(--radius-sm)] transition-colors'
|
|
const menuItem = 'flex items-center gap-2.5 w-full px-3 py-2 text-xs text-left rounded-[var(--radius-sm)] transition-colors'
|
|
|
|
return (
|
|
<div className="border-b border-[var(--color-border-separator)]">
|
|
<div className="flex items-center justify-between px-4 py-3 hover:bg-[var(--color-surface-hover)] transition-colors group">
|
|
{/* Left: status + info */}
|
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">{task.name}</div>
|
|
{task.description && (
|
|
<div className="text-xs text-[var(--color-text-secondary)] truncate">{task.description}</div>
|
|
)}
|
|
<div className="flex items-center gap-3 text-[11px] text-[var(--color-text-tertiary)] mt-0.5">
|
|
<span>{t('tasks.createdAt')}{new Date(task.createdAt).toLocaleDateString()}</span>
|
|
{task.lastFiredAt && (
|
|
<span>{t('tasks.lastRunAt')}{new Date(task.lastFiredAt).toLocaleDateString()}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: cron + actions */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
<span className="text-xs text-[var(--color-text-tertiary)]" title={task.cron}>
|
|
{describeCron(task.cron, t)}
|
|
</span>
|
|
|
|
<div className="flex items-center gap-0.5">
|
|
{/* Run Now */}
|
|
<div className="relative" ref={confirmAction === 'run' ? confirmRef : undefined}>
|
|
<button
|
|
onClick={() => isRunning || !task.enabled ? undefined : setConfirmAction(confirmAction === 'run' ? null : 'run')}
|
|
disabled={isRunning || !task.enabled}
|
|
className={`${iconBtn} ${task.enabled ? 'text-[var(--color-brand)] hover:bg-[var(--color-surface-selected)]' : 'text-[var(--color-text-tertiary)] cursor-not-allowed'} disabled:opacity-50`}
|
|
title={task.enabled ? t('tasks.runNow') : undefined}
|
|
>
|
|
<span className={`material-symbols-outlined text-[18px] ${isRunning ? 'animate-spin' : ''}`}>
|
|
{isRunning ? 'sync' : 'play_arrow'}
|
|
</span>
|
|
</button>
|
|
{confirmAction === 'run' && (
|
|
<ConfirmPopover
|
|
message={t('tasks.confirmRun')}
|
|
confirmLabel={t('tasks.runNow')}
|
|
onConfirm={handleRunNow}
|
|
onCancel={() => setConfirmAction(null)}
|
|
cancelLabel={t('common.cancel')}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* View Logs */}
|
|
<button
|
|
onClick={onToggleLogs}
|
|
className={`${iconBtn} ${showLogs ? 'text-[var(--color-brand)] bg-[var(--color-surface-selected)]' : 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]'}`}
|
|
title={t('tasks.viewLogs')}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">receipt_long</span>
|
|
</button>
|
|
|
|
{/* More menu */}
|
|
<div className="relative" ref={menuRef}>
|
|
<button
|
|
onClick={() => { setShowMenu(!showMenu); setConfirmAction(null) }}
|
|
className={`${iconBtn} text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-selected)]`}
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">more_vert</span>
|
|
</button>
|
|
|
|
{showMenu && !confirmAction && (
|
|
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg py-1">
|
|
{/* Edit */}
|
|
<button
|
|
onClick={() => { setShowMenu(false); setShowEdit(true) }}
|
|
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">edit</span>
|
|
{t('tasks.edit')}
|
|
</button>
|
|
|
|
{/* Toggle */}
|
|
<button
|
|
onClick={() => setConfirmAction('toggle')}
|
|
className={`${menuItem} text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-secondary)]">
|
|
{task.enabled ? 'pause_circle' : 'play_circle'}
|
|
</span>
|
|
{task.enabled ? t('common.disable') : t('common.enable')}
|
|
</button>
|
|
|
|
<div className="my-1 h-px bg-[var(--color-border-separator)]" />
|
|
|
|
{/* Delete */}
|
|
<button
|
|
onClick={() => setConfirmAction('delete')}
|
|
className={`${menuItem} text-[var(--color-error)] hover:bg-[var(--color-error-container)]/18`}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">delete</span>
|
|
{t('common.delete')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Confirm popovers for menu actions */}
|
|
{confirmAction === 'toggle' && (
|
|
<div ref={confirmRef}>
|
|
<ConfirmPopover
|
|
message={task.enabled ? t('tasks.confirmDisable') : t('tasks.confirmEnable')}
|
|
confirmLabel={task.enabled ? t('common.disable') : t('common.enable')}
|
|
onConfirm={handleToggle}
|
|
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
|
|
cancelLabel={t('common.cancel')}
|
|
/>
|
|
</div>
|
|
)}
|
|
{confirmAction === 'delete' && (
|
|
<div ref={confirmRef}>
|
|
<ConfirmPopover
|
|
message={t('tasks.confirmDelete')}
|
|
confirmLabel={t('common.delete')}
|
|
onConfirm={handleDelete}
|
|
onCancel={() => { setConfirmAction(null); setShowMenu(false) }}
|
|
cancelLabel={t('common.cancel')}
|
|
variant="error"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Runs panel */}
|
|
{showLogs && (
|
|
<div className="px-4 pb-3">
|
|
<TaskRunsPanel taskId={task.id} onClose={onToggleLogs} refreshKey={logsRefreshKey} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit modal */}
|
|
{showEdit && (
|
|
<NewTaskModal open editTask={task} onClose={() => setShowEdit(false)} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Confirm Popover ─────────────────────────────────────────────────────────
|
|
|
|
function ConfirmPopover({ message, confirmLabel, onConfirm, onCancel, cancelLabel, variant = 'brand' }: {
|
|
message: string
|
|
confirmLabel: string
|
|
onConfirm: () => void
|
|
onCancel: () => void
|
|
cancelLabel: string
|
|
variant?: 'brand' | 'error'
|
|
}) {
|
|
return (
|
|
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
|
|
<p className="text-xs text-[var(--color-text-secondary)] mb-2.5">{message}</p>
|
|
<div className="flex justify-end gap-1.5">
|
|
<button
|
|
onClick={onCancel}
|
|
className="px-2.5 py-1 text-xs rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
|
>
|
|
{cancelLabel}
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
className={`px-2.5 py-1 text-xs rounded-[var(--radius-sm)] hover:opacity-90 transition-opacity ${
|
|
variant === 'error'
|
|
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
|
|
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)]'
|
|
}`}
|
|
>
|
|
{confirmLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|