diff --git a/taskboard-electron/src/renderer/App.tsx b/taskboard-electron/src/renderer/App.tsx
index d306a8b..950948f 100644
--- a/taskboard-electron/src/renderer/App.tsx
+++ b/taskboard-electron/src/renderer/App.tsx
@@ -8,6 +8,7 @@ import {
Moon,
Play,
Plus,
+ Radar,
Search,
Settings,
Sparkles,
@@ -177,7 +178,7 @@ const DEFAULT_TIMEOUT_SECONDS = 12000;
function FormattedOutput({ content, theme }) {
if (!content) return null;
- // 解析JSON流数据,只显示关键信息
+ // Parse the JSON stream and render only the useful signal.
const parseStreamJSON = (text) => {
const lines = text.split("\n");
const parsedLines = [];
@@ -325,7 +326,7 @@ function FormattedOutput({ content, theme }) {
}
case "result":
- // 最终结果
+ // Final result.
if (event.result) {
parsedLines.push({
type: "result",
@@ -336,7 +337,7 @@ function FormattedOutput({ content, theme }) {
break;
case "error":
- // 错误信息
+ // Error details.
parsedLines.push({
type: "error",
text: `❌ Error: ${event.error || "Unknown error"}`,
@@ -345,10 +346,10 @@ function FormattedOutput({ content, theme }) {
break;
default:
- // 其他事件类型 - 显示更多信息
+ // Other event types: surface compact context.
if (eventType) {
let displayText = `[${eventType}]`;
- // 尝试显示事件中的关键信息
+ // Try to render the key event fields.
if (event.message) {
const msg = event.message;
if (msg.content && Array.isArray(msg.content)) {
@@ -377,9 +378,9 @@ function FormattedOutput({ content, theme }) {
}
}
} catch (_error) {
- // 如果不是有效的JSON,可能是普通文本输出
+ // If it is not valid JSON, it may be plain text output.
if (line.trim() && !line.startsWith("{")) {
- // 只显示有意义的非JSON行
+ // Only render meaningful non-JSON lines.
if (line.includes("error") || line.includes("Error")) {
parsedLines.push({
type: "error",
@@ -393,7 +394,7 @@ function FormattedOutput({ content, theme }) {
style: { color: theme.green },
});
} else if (line.length > 10) {
- // 只显示较长的非JSON行
+ // Only render longer non-JSON lines.
parsedLines.push({
type: "text",
text: line,
@@ -3116,7 +3117,7 @@ function DetailPanel({ task, onClose, onResume }: any) {
if (res.ok && !cancelled) {
const data = await res.json();
const currentOutput = data.output || "";
- // 增量更新:只添加新内容
+ // Incremental update: append only new output.
if (currentOutput.length > lastOutputLength) {
const newContent = currentOutput.slice(lastOutputLength);
setLiveOutput((prev) => prev + newContent);
@@ -3126,7 +3127,7 @@ function DetailPanel({ task, onClose, onResume }: any) {
} catch {}
};
poll();
- const interval = setInterval(poll, 1000); // 缩短轮询间隔到1秒
+ const interval = setInterval(poll, 1000);
return () => {
cancelled = true;
clearInterval(interval);
@@ -3397,7 +3398,7 @@ function DetailPanel({ task, onClose, onResume }: any) {
overflow: "hidden",
}}
>
- {/* 工具栏 */}
+ {/* Toolbar */}
- {/* 输出内容区域 */}
+ {/* Output content */}
{showLiveOutput && (
- ✨ 已发送!任务正在重新唤醒,请稍候~
+ Sent. The task is waking up again.
)}
setSkillEnabled(e.target.checked)}
style={{ width: 16, height: 16, cursor: "pointer" }}
/>
- Skill Library 自动扫描
+ Skill Library automatic scans
- 定时让 agent 扫描已完成任务、检测复发模式(消耗 token,默认关闭)。
- 手动「扫一遍」按钮不受此开关影响。
+ Run scheduled sweeps over completed tasks to detect recurring patterns. This uses
+ tokens and is off by default. The manual scan button is not affected.
{skillEnabled && (
<>
-
扫描 Agent
+
Sweep Agent
setSkillSweepAgent(e.target.value)}
@@ -4111,17 +4112,19 @@ function SettingsModal({
Claude Code (claude CLI)
Codex CLI (openai/codex)
-
运行 sweep 的 agent。
+
Agent used for skill sweeps.
-
扫描节奏 (cron)
+
Sweep Cadence (cron)
setSkillSweepCron(e.target.value)}
placeholder="0 3 * * *"
style={{ ...fieldStyle, fontFamily: "monospace" }}
/>
-
默认每日凌晨 3 点。增量扫描,只看上次以来的新任务。
+
+ Default: 3 AM daily. Incremental scans only inspect tasks since the last sweep.
+
>
)}
@@ -5285,10 +5288,11 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
return (
@@ -5298,7 +5302,18 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
{p.pattern_key}
-
+
{p.summary || "—"}
- 复发 {p.recurrence_count}×
- {taskCount} 个任务
+ Recurs {p.recurrence_count}x
+
+ {taskCount} {taskCount === 1 ? "task" : "tasks"}
+
{p.status}
{ready && p.status !== "promoted" && (
- ✓ 达标
+ Ready
)}
setExpanded((v) => !v)}
@@ -5329,7 +5346,7 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
fontWeight: 700,
}}
>
- {expanded ? "收起 ▲" : "详情 ▼"}
+ {expanded ? "Hide" : "Details"}
@@ -5346,19 +5363,21 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
}}
>
- 首次
+ First seen
{(p.first_seen || "").replace("T", " ").slice(0, 19) || "—"}
- 最近
+ · Last seen
{(p.last_seen || "").replace("T", " ").slice(0, 19) || "—"}
-
贡献的任务({taskCount}):
+
+ Contributing tasks ({taskCount}):
+
{taskIds.length === 0 && — }
{taskIds.map((tid) => {
const t = (tasks || []).find((x) => x.id === tid);
return (
- #{tid} {t ? t.title : (已删除) }
+ #{tid} {t ? t.title : (deleted) }
);
})}
@@ -5378,13 +5397,15 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
border: `1px solid ${p.draft_worthy ? "rgba(34,197,94,0.3)" : "rgba(245,158,11,0.35)"}`,
}}
>
- {p.draft_worthy ? "✓ agent 建议沉淀" : "⚠ agent 认为价值有限(可仍批准或驳回)"}
- {p.draft_worthiness_reason ? `:${p.draft_worthiness_reason}` : ""}
+ {p.draft_worthy
+ ? "Agent recommends turning this into a skill"
+ : "Agent thinks this may have limited value. You can still approve or reject it."}
+ {p.draft_worthiness_reason ? `: ${p.draft_worthiness_reason}` : ""}
)}
{p.status === "promoted" && (
-
✓ 已沉淀为 Skill
+
Promoted to Skill
)}
{(p.status === "candidate" || p.status === "tracking") &&
@@ -5392,24 +5413,26 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
draftStatus !== "drafting" && (
onDraft(p.id)} style={btn(theme.accent, "#fff")}>
- {draftStatus === "error" ? "重试蒸馏" : "蒸馏成 Skill"}
+ {draftStatus === "error" ? "Retry Distill" : "Distill Skill"}
onDismiss(p.id)} style={btn("transparent", theme.textMuted)}>
- 驳回
+ Reject
{p.status === "tracking" && (
- 未达自动阈值,可手动蒸馏(agent 会判断是否值得)
+ Below the automatic threshold. You can still distill it manually.
)}
{draftStatus === "error" && (
- 蒸馏失败:{p.draft_error}
+
+ Distill failed: {p.draft_error}
+
)}
)}
{draftStatus === "drafting" && (
-
蒸馏中…
+
Distilling…
)}
{draftStatus === "ready" &&
@@ -5430,7 +5453,7 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
borderRadius: 6,
}}
>
- {fm.name || "(无 name)"}
+ {fm.name || "(no name)"}
→ ~/.claude/skills/{fm.name || "…"}/SKILL.md
@@ -5452,7 +5475,7 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
letterSpacing: 0.3,
}}
>
- SKILL.md · 可编辑(frontmatter 决定名称与触发描述)
+ SKILL.md · editable. Frontmatter controls the name and trigger description.
onDismiss(p.id)}
@@ -5490,7 +5513,7 @@ function SkillPatternCard({ p, tasks, onDraft, onApprove, onDismiss }) {
fontSize: 12,
}}
>
- 驳回
+ Reject
@@ -5522,7 +5545,7 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
const d = await res.json();
setContent(d.content ?? "");
} catch (e) {
- setContent(`(加载失败:${e.message})`);
+ setContent(`(failed to load: ${e.message})`);
} finally {
setLoading(false);
}
@@ -5532,10 +5555,11 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
return (
@@ -5556,10 +5580,21 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
fontWeight: 700,
}}
>
- {expanded ? "收起 ▲" : "查看 SKILL.md ▼"}
+ {expanded ? "Hide" : "View SKILL.md"}
-
+
{s.description || "—"}
@@ -5567,16 +5602,17 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
{s.path}
- {s.source_pattern_key && 来源 pattern:{s.source_pattern_key} }
+ {s.source_pattern_key && · Source pattern: {s.source_pattern_key} }
{sourceTaskIds.length > 0 && (
- 来源任务:
+ {" "}
+ · Source tasks:{" "}
{sourceTaskIds
.map((tid) => {
const t = (tasks || []).find((x) => x.id === tid);
- return `#${tid}${t ? "(" + t.title + ")" : ""}`;
+ return `#${tid}${t ? " (" + t.title + ")" : ""}`;
})
- .join("、")}
+ .join(", ")}
)}
@@ -5597,7 +5633,7 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
overflow: "auto",
}}
>
- {loading ? "加载中…" : content}
+ {loading ? "Loading…" : content}
)}
@@ -5619,7 +5655,7 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
onChange={(e) => onToggle(s.id, e.target.checked)}
style={{ cursor: "pointer" }}
/>
- {s.enabled ? "已启用(claude/codex 加载中)" : "已停用(symlink 已摘除)"}
+ {s.enabled ? "Enabled for Claude/Codex" : "Disabled (symlinks removed)"}
onDelete(s.id)}
@@ -5635,7 +5671,7 @@ function SkillRegistryCard({ s, tasks, onToggle, onDelete }) {
fontWeight: 700,
}}
>
- 删除
+ Delete
@@ -5646,7 +5682,7 @@ function SkillsView({
skillData,
skills,
tasks,
- onSweep,
+ filter,
onDraft,
onApprove,
onDismiss,
@@ -5656,16 +5692,59 @@ function SkillsView({
// Only recurrence >= 2 is worth surfacing; single-occurrence rows are noise.
// (The backend still tracks them so the count can accumulate across sweeps.)
const patterns = (skillData.patterns || []).filter((p) => p.recurrence_count >= 2);
+ const skillQuery = (filter || "").trim().toLowerCase();
+ const matchesQuery = (values) => {
+ if (!skillQuery) return true;
+ return values.some((value) =>
+ String(value ?? "")
+ .toLowerCase()
+ .includes(skillQuery),
+ );
+ };
+ const taskTitle = (id) => (tasks || []).find((t) => t.id === id)?.title || "";
+ const parseIds = (raw) => {
+ try {
+ return JSON.parse(raw || "[]");
+ } catch {
+ return [];
+ }
+ };
+ const filteredSkills = (skills || []).filter((s) =>
+ matchesQuery([
+ s.name,
+ s.description,
+ s.kind,
+ s.path,
+ s.source_pattern_key,
+ s.enabled ? "enabled" : "disabled",
+ ...parseIds(s.source_task_ids).map(taskTitle),
+ ]),
+ );
+ const filteredPatterns = patterns.filter((p) =>
+ matchesQuery([
+ p.pattern_key,
+ p.summary,
+ p.kind,
+ p.status,
+ p.draft_status,
+ p.draft_error,
+ p.draft_worthiness_reason,
+ p.draft_body,
+ ...parseIds(p.contributing_task_ids).map(taskTitle),
+ ]),
+ );
const sweep = skillData.sweep || {};
const running = sweep.running;
const last = sweep.last;
let lastNote = null;
- if (last) {
+ if (running) {
+ lastNote = "Sweep running…";
+ } else if (last) {
lastNote = last.error
- ? `上次扫描失败:${last.error}`
+ ? `Last sweep failed: ${last.error}`
: last.scanned === 0
- ? `上次扫描:没有已完成的任务可分析(agent ${last.agent})`
- : `上次扫描:分析 ${last.scanned} 个任务、新增 ${last.new ?? 0} 次复发、候选 ${last.candidates ?? 0}(agent ${last.agent})`;
+ ? `Last sweep: no completed tasks to analyze (agent ${last.agent})`
+ : `Last sweep: analyzed ${last.scanned} tasks, added ${last.new ?? 0} recurrences, found ${last.candidates ?? 0} candidates (agent ${last.agent})`;
}
const [showRegistry, setShowRegistry] = useState(true);
const [showPatterns, setShowPatterns] = useState(true);
@@ -5677,7 +5756,7 @@ function SkillsView({
display: "flex",
alignItems: "center",
gap: 8,
- width: "100%",
+ width: "auto",
background: "transparent",
border: "none",
cursor: "pointer",
@@ -5690,99 +5769,107 @@ function SkillsView({
>
{open ? "▼" : "▶"}
{label}
- ({count})
+ ({count})
);
return (
-
+
- 跨任务复发模式账本 · 复发 ≥2 即可手动蒸馏为 Skill(达 ≥3 且跨 ≥2 任务自动标记候选)
+ Cross-task recurrence ledger. Recurrence >= 2 can be distilled manually; recurrence
+ >= 3 across 2+ tasks becomes a candidate.
{lastNote && · {lastNote} }
-
- {running ? "扫描中…" : "扫一遍"}
-
{(skills || []).length > 0 && (
- {sectionHeader("已沉淀 Skills", skills.length, showRegistry, () =>
- setShowRegistry((v) => !v),
- )}
- {showRegistry && (
-
- {skills.map((s) => (
-
- ))}
-
+ {sectionHeader(
+ "Installed Skills",
+ skillQuery ? `${filteredSkills.length}/${skills.length}` : skills.length,
+ showRegistry,
+ () => setShowRegistry((v) => !v),
)}
+ {showRegistry &&
+ (filteredSkills.length === 0 ? (
+
+ No installed skills match this search.
+
+ ) : (
+
+ {filteredSkills.map((s) => (
+
+ ))}
+
+ ))}
)}
- {sectionHeader("检测到的模式", patterns.length, showPatterns, () =>
- setShowPatterns((v) => !v),
+ {sectionHeader(
+ "Detected Patterns",
+ skillQuery ? `${filteredPatterns.length}/${patterns.length}` : patterns.length,
+ showPatterns,
+ () => setShowPatterns((v) => !v),
)}
{showPatterns &&
- (patterns.length === 0 ? (
+ (filteredPatterns.length === 0 ? (
- 还没有复发 ≥2 的模式 — 点「扫一遍」让 agent 分析最近完成的任务(复发 1 次的暂不展示)
+ {patterns.length === 0
+ ? "No patterns with recurrence >= 2 yet. Run a scan to analyze recent completed tasks."
+ : "No detected patterns match this search."}
) : (
- {patterns.map((p) => (
+ {filteredPatterns.map((p) => (
(null);
const [heartbeatDetail, setHeartbeatDetail] = useState(null);
const [connected, setConnected] = useState(false);
- const [filter, setFilter] = useState("");
+ const [filters, setFilters] = useState({ tasks: "", heartbeats: "", skills: "" });
const [taskTimeout, setTaskTimeout] = useState(DEFAULT_TIMEOUT_SECONDS);
const [defaultAgent, setDefaultAgent] = useState(DEFAULT_AGENT);
const [feishuSettings, setFeishuSettings] = useState({});
@@ -6094,6 +6181,28 @@ export default function App() {
}
};
+ const filter =
+ activeView === "tasks"
+ ? filters.tasks
+ : activeView === "heartbeats"
+ ? filters.heartbeats
+ : filters.skills;
+ const setActiveFilter = (value) => {
+ setFilters((prev) =>
+ activeView === "tasks"
+ ? { ...prev, tasks: value }
+ : activeView === "heartbeats"
+ ? { ...prev, heartbeats: value }
+ : { ...prev, skills: value },
+ );
+ };
+ const searchPlaceholder =
+ activeView === "tasks"
+ ? "Search tasks"
+ : activeView === "heartbeats"
+ ? "Search heartbeats"
+ : "Search skills";
+
const filtered = filter
? tasks.filter(
(t) =>
@@ -6370,41 +6479,39 @@ export default function App() {
))}
- {activeView !== "skills" && (
-
+
+ setActiveFilter(e.target.value)}
style={{
- display: "flex",
- alignItems: "center",
- gap: 8,
- padding: "0 10px",
- height: 34,
- borderRadius: 8,
- border: `1px solid ${theme.border}`,
- background: theme.panelRaised,
+ border: "none",
+ background: "transparent",
+ color: theme.text,
+ fontSize: 12,
+ outline: "none",
+ width: 164,
+ fontFamily: APP_FONT_STACK,
}}
- >
-
- setFilter(e.target.value)}
- style={{
- border: "none",
- background: "transparent",
- color: theme.text,
- fontSize: 12,
- outline: "none",
- width: 164,
- fontFamily: APP_FONT_STACK,
- }}
- />
-
- )}
+ />
+
{(() => {
const cycle = { system: "light", light: "dark", dark: "system" };
@@ -6426,7 +6533,31 @@ export default function App() {
- {activeView !== "skills" && (
+ {activeView === "skills" ? (
+
+
+ {skillData.sweep?.running ? "Scanning" : "Run Scan"}
+
+ ) : (
activeView === "tasks" ? setShowNew(true) : setShowNewHeartbeat(true)
@@ -6540,7 +6671,7 @@ export default function App() {
skillData={skillData}
skills={skills}
tasks={tasks}
- onSweep={handleSweep}
+ filter={filter}
onDraft={handleSkillDraft}
onApprove={handleSkillApprove}
onDismiss={handleSkillDismiss}
@@ -6701,7 +6832,7 @@ export default function App() {
);
}
-// CSS动画定义
+// CSS animation definitions.
const styles = `
html, body, #root {
min-height: 100%;
@@ -6772,7 +6903,7 @@ const styles = `
}
`;
-// 注入样式
+// Inject styles.
if (typeof document !== "undefined" && !document.querySelector("#live-output-styles")) {
const styleEl = document.createElement("style");
styleEl.id = "live-output-styles";