diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d6..ff17692bfc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2398,6 +2398,9 @@ function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { win.once('ready-to-show', () => win.show()); + setTimeout(() => { + if (!win.isDestroyed() && !win.isVisible()) win.show(); + }, 1200); // Never opens a window; hands the URL to the OS browser instead. // diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d2dbb7e01d..897d0714b5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -98,6 +98,7 @@ export function App() { // Initial config load useEffect(() => { let cancelled = false; + if (!window.cth?.getConfig) return; window.cth.getConfig().then(c => { if (cancelled) return; setConfig(c); diff --git a/src/renderer/src/components/TasksKanban.tsx b/src/renderer/src/components/TasksKanban.tsx index 0486fdc050..234bfa160d 100644 --- a/src/renderer/src/components/TasksKanban.tsx +++ b/src/renderer/src/components/TasksKanban.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PixelPanel } from './PixelPanel'; import { PixelButton } from './PixelButton'; @@ -121,6 +121,10 @@ export function TasksKanban() { const { t } = useTranslation(); const agents = useStore((s) => s.agents); const [tasks, setTasks] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [assigneeFilter, setAssigneeFilter] = useState<'all' | 'unassigned' | string>('all'); + const [onlyNeedsHuman, setOnlyNeedsHuman] = useState(false); + // Detail view: cards show just the title — clicking one opens the full // breakdown as an APP-WIDE overlay over the office floor (see // TaskDetailOverlay) — the content grows (contracts, deps, human Q&A), so it @@ -154,68 +158,217 @@ export function TasksKanban() { /** Resolve an assignee id to a display name — falls back to the restorable * roster so a done card keeps its author's name even after that worker's * terminal is gone, then to the raw id. */ - const nameFor = (id?: string): string | undefined => + const nameFor = useCallback((id?: string): string | undefined => id ? (agents.find((a) => a.id === id)?.name ?? restorableAgents.find((a) => a.id === id)?.name ?? id) - : undefined; + : undefined, + [agents, restorableAgents] + ); + + const availableAssignees = useMemo(() => { + const set = new Set(); + for (const task of tasks) { + if (task.assignee) set.add(task.assignee); + } + return Array.from(set).sort((a, b) => (nameFor(a) ?? a).localeCompare(nameFor(b) ?? b)); + }, [tasks, nameFor]); + + const filteredTasks = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + return tasks.filter((task) => { + if (assigneeFilter === 'unassigned') { + if (task.assignee) return false; + } else if (assigneeFilter !== 'all') { + if (task.assignee !== assigneeFilter) return false; + } + + if (onlyNeedsHuman && !waitsOnHuman(task)) { + return false; + } + + if (q) { + const titleMatch = task.title?.toLowerCase().includes(q); + const idMatch = task.id?.toLowerCase().includes(q); + const descMatch = task.description?.toLowerCase().includes(q); + const aName = nameFor(task.assignee)?.toLowerCase(); + const assigneeMatch = aName ? aName.includes(q) : false; + if (!titleMatch && !idMatch && !descMatch && !assigneeMatch) return false; + } + + return true; + }); + }, [tasks, searchQuery, assigneeFilter, onlyNeedsHuman, nameFor]); + + const isFiltered = !!searchQuery.trim() || assigneeFilter !== 'all' || onlyNeedsHuman; return (
- {/* Toolbar — read-only: the god is the ledger's writer. New work enters - through the dispatch box (which mails the god), not by the human - inserting cards the orchestrator never heard about. */} + {/* Toolbar — search, filters, task count, and hint */}
- - {t('kanban.count', { count: tasks.length })} - - - {t('kanban.newWorkHint')} + {/* Search Input */} +
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Escape') setSearchQuery(''); }} + placeholder={t('kanban.filterPlaceholder')} + style={{ + width: '100%', + padding: '4px 22px 4px 8px', + fontFamily: 'var(--cth-font-ui)', + fontSize: 12, + background: 'var(--cth-paper-100)', + color: 'var(--cth-ink-900)', + border: 'none', + boxShadow: 'inset 0 0 0 1px var(--cth-ink-300)', + borderRadius: 2, + outline: 'none' + }} + /> + {searchQuery && ( + + )} +
+ + {/* Assignee filter dropdown */} + + + {/* Quick filter chip for Needs Human Answer */} + + + {/* Task counts */} + + {isFiltered + ? t('kanban.filterCount', { filtered: filteredTasks.length, total: tasks.length }) + : t('kanban.count', { count: tasks.length })}
- {/* Columns */} -
- {COLUMNS.map((col) => { - const cards = tasks.filter((t) => t.status === col.key); - return ( -
-
+ + {t('kanban.noMatchingTasks')} + + { + setSearchQuery(''); + setAssigneeFilter('all'); + setOnlyNeedsHuman(false); + }} + > + {t('kanban.clearFilter')} + +
+ ) : ( +
+ {COLUMNS.map((col) => { + const cards = filteredTasks.filter((t) => t.status === col.key); + return ( +
- {t(col.labelKey)} - {cards.length} -
-
- {cards.length === 0 && ( -
—
- )} - {cards.map((t) => ( - openTaskDetail(t.id)} - onDismiss={() => dismissTask(t.id)} - /> - ))} +
+ {t(col.labelKey)} + {cards.length} +
+
+ {cards.length === 0 && ( +
—
+ )} + {cards.map((t) => ( + openTaskDetail(t.id)} + onDismiss={() => dismissTask(t.id)} + /> + ))} +
-
- ); - })} -
+ ); + })} +
+ )}
); } diff --git a/src/renderer/src/hooks/useResolvedGodName.ts b/src/renderer/src/hooks/useResolvedGodName.ts index d29f75ecb8..576fd3d66e 100644 --- a/src/renderer/src/hooks/useResolvedGodName.ts +++ b/src/renderer/src/hooks/useResolvedGodName.ts @@ -14,6 +14,7 @@ export function useResolvedGodName(): string { const [godName, setGodName] = useState(DEFAULT_GOD_NAME); useEffect(() => { let cancelled = false; + if (!window.cth?.hiveRegistry) return; void window.cth.hiveRegistry().then((reg) => { if (!cancelled) setGodName(resolveGodName(reg?.agents?.[GOD_ID]?.name)); }).catch(() => { /* keep the default while unknown */ }); diff --git a/src/renderer/src/i18n/locales/ar.json b/src/renderer/src/i18n/locales/ar.json index fe74ab7ebc..8ab46aefa7 100644 --- a/src/renderer/src/i18n/locales/ar.json +++ b/src/renderer/src/i18n/locales/ar.json @@ -1294,7 +1294,14 @@ "awaitingAnswer": "بانتظار إجابتك — تبويب اسألني", "dependsOn": "يعتمد على", "assign": "إسناد", - "priority": "الأولوية {{level}}/5" + "priority": "الأولوية {{level}}/5", + "filterPlaceholder": "تصفية المهام…", + "filterAll": "الكل", + "filterBlocked": "يحتاج إجابة", + "filterAssigneeAll": "جميع المُسنَد إليهم", + "filterCount": "{{filtered}} من {{total}} مهمة", + "noMatchingTasks": "لا توجد مهام تطابق التصفية", + "clearFilter": "إزالة التصفية" }, "queueComposer": { "recording": "● يسجّل — انقر إيقاف للتفريغ النصي", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 68aac27438..a68dbd2752 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1294,7 +1294,14 @@ "awaitingAnswer": "AWAITING YOUR ANSWER — ASK ME TAB", "dependsOn": "DEPENDS ON", "assign": "assign", - "priority": "Priority {{level}}/5" + "priority": "Priority {{level}}/5", + "filterPlaceholder": "Filter tasks…", + "filterAll": "all", + "filterBlocked": "needs answer", + "filterAssigneeAll": "all assignees", + "filterCount": "{{filtered}} of {{total}} tasks", + "noMatchingTasks": "No tasks match your filter", + "clearFilter": "clear filter" }, "queueComposer": { "recording": "● recording — click stop to transcribe", diff --git a/src/renderer/src/i18n/locales/zh-CN.json b/src/renderer/src/i18n/locales/zh-CN.json index 0db2c7f25d..554623a9b8 100644 --- a/src/renderer/src/i18n/locales/zh-CN.json +++ b/src/renderer/src/i18n/locales/zh-CN.json @@ -1294,7 +1294,14 @@ "awaitingAnswer": "等待你的回答——「问我」标签页", "dependsOn": "依赖", "assign": "分配", - "priority": "优先级 {{level}}/5" + "priority": "优先级 {{level}}/5", + "filterPlaceholder": "筛选任务…", + "filterAll": "全部", + "filterBlocked": "等待回答", + "filterAssigneeAll": "所有被分配者", + "filterCount": "{{filtered}} / {{total}} 项任务", + "noMatchingTasks": "没有匹配筛选条件的任务", + "clearFilter": "清除筛选" }, "queueComposer": { "recording": "● 录音中——点击停止转写", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 9dd5401ac4..1fafa69f25 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -23,8 +23,23 @@ if (splashMark) { const root = document.getElementById('root'); if (!root) throw new Error('No root element'); -createRoot(root).render( - - - -); +if (typeof window !== 'undefined' && !window.cth) { + root.innerHTML = ` +
+

MUNDER DIFFLIN

+

+ You opened the local Vite dev URL (http://localhost:5173) in a web browser. +

+

+ Munder Difflin is a desktop application powered by Electron (which coordinates real CLI processes and PTYs). + Please switch to the Munder Difflin desktop window on your taskbar. +

+
+ `; +} else { + createRoot(root).render( + + + + ); +}