diff --git a/desktop/ui-next/src/features/sidebar/Sidebar.tsx b/desktop/ui-next/src/features/sidebar/Sidebar.tsx index a77d6e47d..8dec0807c 100644 --- a/desktop/ui-next/src/features/sidebar/Sidebar.tsx +++ b/desktop/ui-next/src/features/sidebar/Sidebar.tsx @@ -10,11 +10,11 @@ // btn、右键菜单走 lib/contextMenu(menu 皮相)。 // 行交互:右键 = 行菜单(重命名/归档/删除二段确认)。 // 行/组头/小节折叠的呈现件收口在 listKit(三列表统一,不做两套)。 -import { IconArchive, IconFolder, IconFolderOpen, IconInbox, IconMessages, IconPlus, IconRefresh } from "@tabler/icons-react"; -import { useState, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; +import { IconArchive, IconFolder, IconFolderOpen, IconInbox, IconMessages, IconPin, IconPlus, IconRefresh } from "@tabler/icons-react"; +import { useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import { CloudTaskList, useCloudProjects, useCloudTasks, type CloudTasksFeed } from "@/features/cloud/CloudTaskList"; -import { GroupLabel, levelPad, ListRow, NEST_NO_GUIDE, SectionFold } from "@/features/sidebar/listKit"; +import { fmtCompact, GroupLabel, levelPad, ListRow, NEST_NO_GUIDE, SectionFold, showTokenPopover } from "@/features/sidebar/listKit"; import { rowStatusLabel, rowTrailing } from "@/features/sidebar/sessionStatus"; import { TODO_GROUP_KEY, TodoSection, type TodoWiring } from "@/features/todo/TodoSection"; import { Brand } from "@/features/titlebar/TitleBar"; @@ -22,18 +22,27 @@ import { useUpdate } from "@/features/update/useUpdate"; import { openMenu, type MenuItem } from "@/lib/contextMenu"; import { useI18n } from "@/lib/i18n"; import type { SessionMeta } from "@/lib/ipc/sessions"; +import { buildSessionUsageMap, sumUsage, usageStats, type TokenUsage } from "@/lib/ipc/usageStats"; import { - groupSessions, + groupLocalSessions, + newGroupId, projectKey, readArchivedProjects, readCollapsedGroups, + readCustomGroups, + readPinnedProjects, + readProjectGroups, readProjectOrder, readSessionArchivesOpen, reorderKeys, writeArchivedProjects, writeCollapsedGroups, + writeCustomGroups, + writePinnedProjects, + writeProjectGroups, writeProjectOrder, writeSessionArchivesOpen, + type CustomGroup, type ProjectGroup, } from "@/lib/util/projects"; import type { Space } from "@/lib/util/prefs"; @@ -56,12 +65,15 @@ const END_DROP_KEY = "\0end"; // (features/todo/TodoSection)按同一张表回查关联会话,留在本文件会成环。 interface RowPlumbing { + space: string; currentId: string | null; actions: SidebarActions; attentionIds?: Set; renamingId: string | null; onRenameStart: (id: string) => void; onRenameEnd: () => void; + /** 会话 id → 该会话(含归并的子代理)的 token 用量 */ + usage: ReadonlyMap; } function SessionRow({ meta, p, level }: { meta: SessionMeta; p: RowPlumbing; level?: number }) { @@ -167,8 +179,14 @@ function ProjectDetails({ onToggleArchOpen, onProjectArchiveToggle, archivedProject, + nested, drag, dropTarget, + customGroups, + projectGroups, + assignProject, + pinnedProjects, + toggleProjectPin, }: { group: ProjectGroup; p: RowPlumbing; @@ -178,6 +196,8 @@ function ProjectDetails({ onToggleArchOpen: (key: string) => void; onProjectArchiveToggle: (key: string) => void; archivedProject: boolean; + /** 在自定义分组内渲染(缩进对齐组头,与顶层项目区分) */ + nested?: boolean; drag?: { onDragStart: (key: string) => void; onDragOver: (key: string) => void; @@ -185,10 +205,45 @@ function ProjectDetails({ onDropBefore: (key: string | null) => void; }; dropTarget?: boolean; + customGroups?: readonly CustomGroup[]; + projectGroups?: Readonly>; + assignProject?: (key: string, gid: string | null) => void; + pinnedProjects?: ReadonlySet; + toggleProjectPin?: (key: string) => void; }) { const { t } = useI18n(); const waiting = group.sessions.filter((s) => s.waiting_ask).length; + const menuPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const menuItems: MenuItem[] = [ + // 项目置顶(仅 local;右键整个项目置顶,置顶排最前) + ...(p.space === "local" && pinnedProjects && toggleProjectPin + ? [ + { + label: pinnedProjects.has(group.key) ? t("sidebar.group.unpin") : t("sidebar.group.pinProject"), + run: () => toggleProjectPin(group.key), + }, + ] + : []), + // 移到自定义分组(子菜单,分组再多不撑爆右键):点开二级菜单选组 + ...(p.space === "local" && customGroups && assignProject + ? [ + { + label: `${t("sidebar.group.moveTo")} ▸`, + run: () => { + const pos = menuPosRef.current ?? { x: 0, y: 0 }; + openMenu(pos, [ + ...customGroups.map((g) => ({ + label: g.id === projectGroups?.[group.key] ? `✓ ${g.name}` : g.name, + run: () => assignProject(group.key, g.id), + })), + ...(projectGroups?.[group.key] + ? [{ label: t("sidebar.group.moveOut"), run: () => assignProject(group.key, null) }] + : []), + ]); + }, + }, + ] + : []), ...(archivedProject ? [] : [{ label: t("sidebar.project.newTaskIn"), run: () => p.actions.onNewTaskIn(group.key) }]), { label: archivedProject ? t("sidebar.project.unarchive") : t("sidebar.project.archive"), @@ -230,7 +285,7 @@ function ProjectDetails({ 分区」的手法,项目数一多就把列表撑散)。层级信号交给缩进与组头 小标签,不再靠 mb/pb 调间距(引导竖线已撤,用户定案 2026-08-10) */} drag?.onDragStart(group.key)} @@ -238,6 +293,7 @@ function ProjectDetails({ onContextMenu={(e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); + menuPosRef.current = { x: e.clientX, y: e.clientY }; openMenu({ x: e.clientX, y: e.clientY }, menuItems); }} > @@ -246,6 +302,9 @@ function ProjectDetails({ 列表就上下跳 */} {dropTarget && } + {pinnedProjects?.has(group.key) && ( + + )} {waiting > 0 && {waiting}} {/* 快捷钮常驻占位、hover 只切可见性:插入式显隐会挤动项目名,鼠标一进一出就抖 */} {!archivedProject && ( @@ -301,6 +360,268 @@ function ProjectDetails({ ); } +/** 自定义分组折叠块:组头 = 文件夹图标 + 名称 + token 合计 + 菜单; + * 成员是「项目」,以普通行样式展示(非完整项目头,展开看任务)。 */ +function CustomGroupSection({ + group, + p, + collapsedSet, + onToggleCollapsed, + onProjectArchiveToggle, + onDelete, + onRename, + assignProject, + customGroups, + projectGroups, + pinnedProjects, + toggleProjectPin, + draggedKey, + drag, +}: { + group: CustomGroup & { projects: ProjectGroup[] }; + p: RowPlumbing; + /** 完整折叠键集合:组自身用 `cg:` 键,组内项目用 `cp:` 键 */ + collapsedSet: ReadonlySet; + onToggleCollapsed: (key: string, open: boolean) => void; + onProjectArchiveToggle: (key: string) => void; + onDelete: () => void; + onRename: (id: string, name: string) => void; + assignProject: (key: string, gid: string | null) => void; + customGroups: readonly CustomGroup[]; + projectGroups: Readonly>; + pinnedProjects: ReadonlySet; + toggleProjectPin: (key: string) => void; + /** 正在被拖拽的项目 key(仅拖拽中非空):组头作为落点,放下即移入该组 */ + draggedKey: string | null; + /** 拖拽句柄(透传给组内项目行) */ + drag?: { + onDragStart: (key: string) => void; + onDragEnd: () => void; + }; +}) { + const { t } = useI18n(); + const [dragOverGroup, setDragOverGroup] = useState(false); + const groupUsage = sumUsage( + group.projects.flatMap((proj) => [...proj.sessions.map((s) => s.id), ...proj.archivedSessions.map((s) => s.id)]), + p.usage, + ); + const menuItems: MenuItem[] = [ + { + label: t("sidebar.group.rename"), + run: () => { + const name = window.prompt(t("sidebar.group.rename"), group.name); + if (name && name.trim()) onRename(group.id, name.trim()); + }, + }, + { label: t("sidebar.group.delete"), danger: true, run: onDelete }, + ]; + const groupCollapsed = collapsedSet.has("cg:" + group.id); + return ( +
  • +
    { + if (e.target !== e.currentTarget) return; + onToggleCollapsed("cg:" + group.id, e.currentTarget.open); + }} + > + { + e.preventDefault(); + e.stopPropagation(); + openMenu({ x: e.clientX, y: e.clientY }, menuItems); + }} + onDragOver={(e: DragEvent) => { + if (!draggedKey) return; + e.preventDefault(); + e.stopPropagation(); + if (!dragOverGroup) setDragOverGroup(true); + }} + onDragLeave={() => setDragOverGroup(false)} + onDrop={(e: DragEvent) => { + if (!draggedKey) return; + e.preventDefault(); + e.stopPropagation(); + setDragOverGroup(false); + assignProject(draggedKey, group.id); + drag?.onDragEnd(); // 立即清掉 draggedKey,否则源行卸载后落点残留 + }} + > + + {groupUsage && groupUsage.input + groupUsage.output > 0 && ( + + )} + +
      + {group.projects.map((proj) => ( + + ))} + {/* 拖动本组项目时显示「移出分组」落点:拖到这里即回到不分组 */} + {group.projects.some((proj) => proj.key === draggedKey) && ( +
    • +
      { + e.preventDefault(); + e.stopPropagation(); + }} + onDrop={(e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (draggedKey) { + assignProject(draggedKey, null); + drag?.onDragEnd(); // 立即清掉 draggedKey,避免落点残留 + } + }} + > + {t("sidebar.group.dropOut")} +
      +
    • + )} +
    +
    +
  • + ); +} + +/** 自定义分组内的项目行:普通行样式(小文件夹图标 + 名称 + token),展开看任务。 + * 右键:置顶/移到其他分组/移出/归档。 */ +function GroupProjectRow({ + proj, + p, + collapsed, + onToggleCollapsed, + pinnedProjects, + toggleProjectPin, + onProjectArchiveToggle, + customGroups, + projectGroups, + assignProject, + drag, +}: { + proj: ProjectGroup; + p: RowPlumbing; + collapsed: boolean; + onToggleCollapsed: (key: string, open: boolean) => void; + pinnedProjects: ReadonlySet; + toggleProjectPin: (key: string) => void; + onProjectArchiveToggle: (key: string) => void; + customGroups: readonly CustomGroup[]; + projectGroups: Readonly>; + assignProject: (key: string, gid: string | null) => void; + /** 拖拽句柄:拖到其他分组=换组,拖到顶部项目区=移出分组 */ + drag?: { + onDragStart: (key: string) => void; + onDragEnd: () => void; + }; +}) { + const { t } = useI18n(); + const usage = sumUsage( + [...proj.sessions.map((s) => s.id), ...proj.archivedSessions.map((s) => s.id)], + p.usage, + ); + const menuItems: MenuItem[] = [ + ...(p.space === "local" + ? [ + { + label: pinnedProjects.has(proj.key) ? t("sidebar.group.unpin") : t("sidebar.group.pinProject"), + run: () => toggleProjectPin(proj.key), + }, + // 移到分组(子菜单) + { + label: `${t("sidebar.group.moveTo")} ▸`, + run: () => { + const pos = menuPosRef.current ?? { x: 0, y: 0 }; + openMenu(pos, [ + ...customGroups + .filter((g) => g.id !== projectGroups?.[proj.key]) + .map((g) => ({ label: g.name, run: () => assignProject(proj.key, g.id) })), + { label: t("sidebar.group.moveOut"), run: () => assignProject(proj.key, null) }, + ]); + }, + }, + ] + : []), + { label: t("sidebar.project.archive"), run: () => onProjectArchiveToggle(proj.key) }, + ]; + const menuPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + return ( +
  • +
    { + if (e.target !== e.currentTarget) return; + onToggleCollapsed("cp:" + proj.key, e.currentTarget.open); + }} + > + drag?.onDragStart(proj.key)} + onDragEnd={() => drag?.onDragEnd()} + onContextMenu={(e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + menuPosRef.current = { x: e.clientX, y: e.clientY }; + openMenu({ x: e.clientX, y: e.clientY }, menuItems); + }} + > + + {proj.name} + {pinnedProjects.has(proj.key) && ( + + )} + {usage && usage.input + usage.output > 0 && ( + + )} + +
      + {rows(proj.sessions, p, 2)} + {proj.archivedSessions.length > 0 && rows(proj.archivedSessions, p, 2)} +
    +
    +
  • + ); +} + /** 概览块(固定,品牌头之下、列表之上):空间标题 + 一句描述 + 统计。 * 统计只给现况:总量低调,运行中/等待确认(云端:排队中)着色浮出 * (与行状态词同色语);云端 feed 由 Sidebar 注入,与列表同一份数据。 */ @@ -361,7 +682,7 @@ function Overview({ if (running > 0) stats.push({ text: t("sidebar.overview.running", { n: String(running) }), cls: "text-primary" }); if (queued > 0) stats.push({ text: t("sidebar.overview.queued", { n: String(queued) }), cls: "text-warning" }); } - if (space !== "cloud") { + if (space === "local" || space === "chat") { const running = pool.filter((m) => m.status === "running").length; const waiting = pool.filter((m) => m.waiting_ask).length; if (running > 0) stats.push({ text: t("sidebar.overview.running", { n: String(running) }), cls: "text-primary" }); @@ -472,6 +793,57 @@ export function Sidebar({ // dragOverKey 取此值 = 悬停在列表末尾的收尾落区(项目 key 是路径,不会撞) const [dragOverKey, setDragOverKey] = useState(null); const [renamingId, setRenamingId] = useState(null); + // 自定义分组(仅 local):分组列表 / 项目→组映射 / 置顶项目 + const [customGroups, setCustomGroups] = useState(readCustomGroups); + const [projectGroups, setProjectGroups] = useState>(readProjectGroups); + const [pinnedProjects, setPinnedProjects] = useState>(readPinnedProjects); + const [creatingGroup, setCreatingGroup] = useState(false); + + const commitCustomGroups = (next: CustomGroup[]) => { + setCustomGroups(next); + writeCustomGroups(next); + }; + const createGroup = (name: string) => { + commitCustomGroups([...customGroups, { id: newGroupId(), name, createdAt: Date.now() }]); + }; + const renameGroup = (id: string, name: string) => { + commitCustomGroups(customGroups.map((g) => (g.id === id ? { ...g, name } : g))); + }; + const deleteGroup = (id: string) => { + commitCustomGroups(customGroups.filter((g) => g.id !== id)); + const nextMap = { ...projectGroups }; + for (const [key, gid] of Object.entries(nextMap)) if (gid === id) delete nextMap[key]; + setProjectGroups(nextMap); + writeProjectGroups(nextMap); + }; + const assignProject = (key: string, gid: string | null) => { + const nextMap = { ...projectGroups }; + if (gid === null) delete nextMap[key]; + else nextMap[key] = gid; + setProjectGroups(nextMap); + writeProjectGroups(nextMap); + }; + const toggleProjectPin = (key: string) => { + const next = new Set(pinnedProjects); + if (next.has(key)) next.delete(key); + else next.add(key); + setPinnedProjects(next); + writePinnedProjects(next); + }; + // 会话列表变化时重拉一次用量(usage 事件由壳记账,聚合后按会话给徽标) + const [usageMap, setUsageMap] = useState>(new Map()); + useEffect(() => { + let alive = true; + void usageStats() + .then((data) => { + if (!alive) return; + setUsageMap(buildSessionUsageMap(data.sessions)); // 子代理已归入父任务 + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [sessions]); // 云端数据源(hook 无条件调用;非云端空间 enabled=false 不拉取): // 概览统计与列表共用同一份 feed,重新进入云端经 enabled 翻转刷新 @@ -480,12 +852,14 @@ export function Sidebar({ const cloudProjects = useCloudProjects(cloud?.reloadKey ?? 0, cloudEnabled); const p: RowPlumbing = { + space, currentId, actions, attentionIds, renamingId, onRenameStart: setRenamingId, onRenameEnd: () => setRenamingId(null), + usage: usageMap, }; const toggleCollapsed = (key: string, open: boolean) => { @@ -577,7 +951,7 @@ export function Sidebar({ ); } - const grouped = groupSessions(pool, order, archivedProjects); + const grouped = groupLocalSessions(pool, order, archivedProjects, customGroups, projectGroups, pinnedProjects); const visibleKeys = grouped.projects.map((g) => g.key); const drag = { onDragStart: (key: string) => setDraggedKey(key), @@ -590,6 +964,12 @@ export function Sidebar({ onDropBefore: (before: string | null) => { setDragOverKey(null); if (!draggedKey || draggedKey === before) return; + // 拖的是分组内项目 → 落到顶部项目区 = 移出分组 + if (!visibleKeys.includes(draggedKey)) { + assignProject(draggedKey, null); + setDraggedKey(null); + return; + } const next = reorderKeys(visibleKeys, draggedKey, before); setDraggedKey(null); // 结果与原序相同就什么都不做:拖到自己正下方那个组头时, @@ -607,7 +987,61 @@ export function Sidebar({ return !(next.length === visibleKeys.length && next.every((k, i) => k === visibleKeys[i])); }; return ( -
      + <> + {/* 自定义分组:新建输入 + 分组列表(项目在组内) */} +
      + + {t("sidebar.group.title")} + + +
      + {creatingGroup && ( +
      + setCreatingGroup(false)} + onKeyDown={(e) => { + if (e.key === "Enter") { + const v = (e.target as HTMLInputElement).value.trim(); + if (v) createGroup(v); + setCreatingGroup(false); + } else if (e.key === "Escape") { + setCreatingGroup(false); + } + }} + /> +
      + )} +
        + {grouped.custom.map((g) => ( + deleteGroup(g.id)} + onRename={renameGroup} + draggedKey={draggedKey} + drag={drag} + /> + ))} {todoSection} {grouped.projects.map((group) => ( ))} {/* 收尾落区:拖拽中才出现的一条 12px 空行。没有它就**排不到末位**—— @@ -664,11 +1103,17 @@ export function Sidebar({ onToggleArchOpen={toggleSessionArchOpen} onProjectArchiveToggle={toggleProjectArchive} archivedProject + customGroups={customGroups} + projectGroups={projectGroups} + assignProject={assignProject} + pinnedProjects={pinnedProjects} + toggleProjectPin={toggleProjectPin} /> ))} )} -
      +
    + ); }; diff --git a/desktop/ui-next/src/features/sidebar/listKit.tsx b/desktop/ui-next/src/features/sidebar/listKit.tsx index d681c3d4e..1a27a2715 100644 --- a/desktop/ui-next/src/features/sidebar/listKit.tsx +++ b/desktop/ui-next/src/features/sidebar/listKit.tsx @@ -13,12 +13,96 @@ // - SectionFold 小节折叠:Archive 形小节头(10px 图标行首、无计数), // 开合走 prefs 契约键持久化,收起即卸载(部分 webview 里 details 收起 // 后嵌套 ul 残留占位空间)。 -import { IconArchive, type TablerIcon } from "@tabler/icons-react"; +import { IconArchive, IconPin, type TablerIcon } from "@tabler/icons-react"; import { useState, type MouseEvent, type ReactNode } from "react"; import { openMenu, type MenuItem } from "@/lib/contextMenu"; +import { t } from "@/lib/i18n"; +import type { TokenUsage } from "@/lib/ipc/usageStats"; +import { pushEscLayer } from "@/lib/util/escLayer"; import { readFold, writeFold, type FoldKey } from "@/lib/util/prefs"; +const fmt = (n: number): string => n.toLocaleString("en-US"); + +/** 紧凑数字:12.3k / 1.2M(行宽紧,徽标只放得下短格式) */ +export const fmtCompact = (n: number): string => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + return String(n); +}; + +let tokenPopCleanup: (() => void) | null = null; + +function closeTokenPop() { + tokenPopCleanup?.(); +} + +/** 分组/项目头 token 用量弹窗:命令式 fixed 定位(行容器 overflow-hidden 会裁 + * 掉普通 dropdown,与 openMenu 同一套机制)。点击徽标弹出,点外部/Esc 关。 */ +export function showTokenPopover(pos: { x: number; y: number }, usage: TokenUsage) { + closeTokenPop(); + const backdrop = document.createElement("div"); + backdrop.className = "fixed inset-0 z-40"; + const box = document.createElement("div"); + box.className = "fixed z-50 w-60 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg"; + + const title = document.createElement("div"); + title.className = "mb-1.5 text-xs font-semibold"; + title.textContent = t("sidebar.row.tokens"); + box.appendChild(title); + + const summary = document.createElement("div"); + summary.className = "mb-2 text-[11px] text-base-content/70"; + summary.textContent = `${t("sidebar.row.tokens.input")} ${fmt(usage.input)} · ${t("sidebar.row.tokens.output")} ${fmt(usage.output)} · ${t("sidebar.row.tokens.calls")} ${fmt(usage.calls)}`; + box.appendChild(summary); + + if (usage.models.length > 0) { + const sep = document.createElement("div"); + sep.className = "border-t border-base-300 pt-1.5"; + const head = document.createElement("div"); + head.className = "mb-0.5 text-[10px] text-base-content/50"; + head.textContent = t("sidebar.row.tokens.byModel"); + sep.appendChild(head); + for (const m of usage.models) { + const row = document.createElement("div"); + row.className = "flex items-center justify-between gap-2 text-[11px]"; + const name = document.createElement("span"); + name.className = "min-w-0 truncate font-mono text-base-content/70"; + name.textContent = m.model; + const val = document.createElement("span"); + val.className = "shrink-0 tabular-nums text-base-content/60"; + val.textContent = fmt(m.input_tokens + m.output_tokens); + row.append(name, val); + sep.appendChild(row); + } + box.appendChild(sep); + } + + backdrop.addEventListener("mousedown", closeTokenPop); + backdrop.addEventListener("contextmenu", (ev) => { + ev.preventDefault(); + closeTokenPop(); + }); + const popEsc = pushEscLayer(() => { + closeTokenPop(); + return true; + }); + window.addEventListener("resize", closeTokenPop); + window.addEventListener("blur", closeTokenPop); + tokenPopCleanup = () => { + tokenPopCleanup = null; + popEsc(); + window.removeEventListener("resize", closeTokenPop); + window.removeEventListener("blur", closeTokenPop); + backdrop.remove(); + box.remove(); + }; + document.body.append(backdrop, box); + const rect = box.getBoundingClientRect(); + box.style.left = `${Math.max(0, Math.min(pos.x, window.innerWidth - rect.width - 8))}px`; + box.style.top = `${Math.max(0, Math.min(pos.y, window.innerHeight - rect.height - 8))}px`; +} + // 嵌套 ul 的缩进引导竖线:**已撤**(用户定案 2026-08-10「本地会话项目列表的 // 竖线都去掉,包括 archive 的列表」;三列表同取此件,云端/对话一并去,§6.2 // 「不做两套」)。层级只剩缩进 + 组头小标签。 @@ -75,6 +159,7 @@ export function levelPad(level = 0): string { export function ListRow({ primary, trailing, + pinned, tooltip, level = 0, active, @@ -88,6 +173,8 @@ export function ListRow({ * (用户定案 2026-08-05「文字换状态图标」),进点的 title/aria-label。 * pulse = 进行中的活态(运行中/等待确认),渲染成「实心点 + 扩散环」 */ trailing?: { tone: string; label: string; pulse?: boolean } | null; + /** 置顶:行首小图钉标记 */ + pinned?: boolean; tooltip: string; /** 缩进级(见 LEVELS):0 = 平铺行,1 = 项目内任务行,依此类推 */ level?: number; @@ -117,7 +204,10 @@ export function ListRow({ }} > {/* 活跃行走正文色(不覆写);归档降到 /55,选中态不降——选中就该看清 */} - {primary} + + {pinned && } + {primary} + {trailing && } diff --git a/desktop/ui-next/src/lib/i18n/en.ts b/desktop/ui-next/src/lib/i18n/en.ts index 6a26ea597..52455c725 100644 --- a/desktop/ui-next/src/lib/i18n/en.ts +++ b/desktop/ui-next/src/lib/i18n/en.ts @@ -42,6 +42,24 @@ export const en: Record = { "sidebar.overview.queued": "{n} queued", "sidebar.archivedTasks": "Archived tasks", "sidebar.archivedChats": "Archived chats", + "sidebar.row.tokens": "Token usage", + "sidebar.row.tokens.input": "Input", + "sidebar.row.tokens.output": "Output", + "sidebar.row.tokens.calls": "Calls", + "sidebar.row.tokens.byModel": "By model", + "sidebar.group.title": "Custom groups", + "sidebar.group.new": "New group", + "sidebar.group.name": "Group name", + "sidebar.group.hint": "Right-click to manage", + "sidebar.group.pinProject": "Pin project", + "sidebar.group.unpin": "Unpin", + "sidebar.group.pinned": "Pinned", + "sidebar.group.delete": "Delete group", + "sidebar.group.rename": "Rename group", + "sidebar.group.moveTo": "Move to group", + "sidebar.group.moveOut": "Move out of group", + "sidebar.group.dropHint": "Drop a project here to move it into this group", + "sidebar.group.dropOut": "Drop here to remove from group", "sidebar.archivedProjects": "Archived projects", "sidebar.empty.local.title": "No local projects yet", "sidebar.empty.local.detail": "Pick a folder to start your first local task.", diff --git a/desktop/ui-next/src/lib/i18n/zh.ts b/desktop/ui-next/src/lib/i18n/zh.ts index df7b625bb..322f07e6a 100644 --- a/desktop/ui-next/src/lib/i18n/zh.ts +++ b/desktop/ui-next/src/lib/i18n/zh.ts @@ -46,6 +46,24 @@ export const zh = { "sidebar.overview.queued": "{n} 排队中", "sidebar.archivedTasks": "已归档任务", "sidebar.archivedChats": "已归档会话", + "sidebar.row.tokens": "Token 用量", + "sidebar.row.tokens.input": "输入", + "sidebar.row.tokens.output": "输出", + "sidebar.row.tokens.calls": "调用次数", + "sidebar.row.tokens.byModel": "按模型", + "sidebar.group.title": "自定义分组", + "sidebar.group.new": "新建分组", + "sidebar.group.name": "分组名称", + "sidebar.group.hint": "右键管理分组", + "sidebar.group.pinProject": "置顶项目", + "sidebar.group.unpin": "取消置顶", + "sidebar.group.pinned": "已置顶", + "sidebar.group.delete": "删除分组", + "sidebar.group.rename": "重命名分组", + "sidebar.group.moveTo": "移到分组", + "sidebar.group.moveOut": "移出分组", + "sidebar.group.dropHint": "拖拽项目到此分组放下", + "sidebar.group.dropOut": "拖到此处移出分组", "sidebar.archivedProjects": "已归档项目", "sidebar.empty.local.title": "还没有本地项目", "sidebar.empty.local.detail": "选择一个文件夹,开始第一个本地任务。", diff --git a/desktop/ui-next/src/lib/ipc/usageStats.test.ts b/desktop/ui-next/src/lib/ipc/usageStats.test.ts new file mode 100644 index 000000000..9ea002519 --- /dev/null +++ b/desktop/ui-next/src/lib/ipc/usageStats.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { buildSessionUsageMap, sumUsage, type TokenUsage } from "./usageStats"; + +const sess = (session_id: string, parent: string | null, input: number, output: number, calls: number, model: string) => ({ + session_id, + parent, + title: session_id, + input_tokens: input, + output_tokens: output, + calls, + days: [], + models: [{ model, input_tokens: input, output_tokens: output, calls }], +}); + +describe("buildSessionUsageMap", () => { + it("子代理归并进父任务:父任务总量含子会话", () => { + const map = buildSessionUsageMap([ + sess("parent", null, 100, 50, 2, "gpt-5"), + sess("child", "parent", 300, 200, 5, "gpt-5"), + ]); + const parent = map.get("parent")!; + expect(parent.input).toBe(400); // 100 + 300 + expect(parent.output).toBe(250); + expect(parent.calls).toBe(7); + expect(parent.models[0]?.input_tokens).toBe(400); + // 子会话自身也有条目 + expect(map.get("child")!.input).toBe(300); + }); +}); + +describe("sumUsage", () => { + it("多会话合并成合计;无用量返回 null", () => { + const map = new Map([ + ["a", { input: 100, output: 50, calls: 2, models: [] }], + ["b", { input: 300, output: 200, calls: 5, models: [] }], + ]); + const total = sumUsage(["a", "b"], map)!; + expect(total.input).toBe(400); + expect(total.output).toBe(250); + expect(total.calls).toBe(7); + expect(sumUsage(["nope"], map)).toBeNull(); + }); +}); diff --git a/desktop/ui-next/src/lib/ipc/usageStats.ts b/desktop/ui-next/src/lib/ipc/usageStats.ts new file mode 100644 index 000000000..de44ca82e --- /dev/null +++ b/desktop/ui-next/src/lib/ipc/usageStats.ts @@ -0,0 +1,122 @@ +// 本地会话 token 用量统计(壳侧 usage 事件记账,按天/会话/模型聚合)。 +// 浏览器模式无此能力:列表类返回空聚合(静态事实)。 +import { inDesktopShell, invoke } from "./ipc"; + +export interface UsageStats { + totals: Bucket; + /** 按天汇总,倒序 */ + days: (DayRow & Bucket)[]; + /** 按模型汇总,用量倒序 */ + models: ModelRow[]; + /** 按会话汇总,用量倒序;子代理会话带 parent 可归并到父任务 */ + sessions: SessionRow[]; +} + +export interface Bucket { + input_tokens: number; + output_tokens: number; + calls: number; +} + +export interface DayRow extends Bucket { + date: string; +} + +export interface ModelRow extends Bucket { + model: string; +} + +export interface SessionRow extends Bucket { + session_id: string; + title: string; + /** 子代理会话的父会话 id;顶层任务为 null */ + parent: string | null; + days: (DayRow & Bucket)[]; + models: ModelRow[]; +} + +/** 壳内失败会抛(引擎重启时降级成空聚合会把面板洗成"全零")。 */ +export async function usageStats(): Promise { + if (!inDesktopShell()) { + return { totals: { input_tokens: 0, output_tokens: 0, calls: 0 }, days: [], models: [], sessions: [] }; + } + return invoke("usage_stats"); +} + +/** 行尾/头部展示用的 token 用量(子代理已归并进父任务)。 */ +export interface TokenUsage { + input: number; + output: number; + calls: number; + models: { model: string; input_tokens: number; output_tokens: number; calls: number }[]; +} + +interface UsageAgg { + input: number; + output: number; + calls: number; + models: Map; +} + +function addAgg(m: Map, sid: string, s: SessionRow) { + let agg = m.get(sid); + if (!agg) { + agg = { input: 0, output: 0, calls: 0, models: new Map() }; + m.set(sid, agg); + } + agg.input += s.input_tokens; + agg.output += s.output_tokens; + agg.calls += s.calls; + for (const md of s.models) { + const cur = agg.models.get(md.model); + agg.models.set(md.model, { + input: (cur?.input ?? 0) + md.input_tokens, + output: (cur?.output ?? 0) + md.output_tokens, + calls: (cur?.calls ?? 0) + md.calls, + }); + } +} + +const aggToUsage = (a: UsageAgg): TokenUsage => ({ + input: a.input, + output: a.output, + calls: a.calls, + models: a.models.size + ? [...a.models.entries()].map(([model, v]) => ({ model, input_tokens: v.input, output_tokens: v.output, calls: v.calls })) + : [], +}); + +/** 把 usage_stats 快照聚合为「会话 id → 用量」;子代理(parent 非空)归并进父任务。 */ +export function buildSessionUsageMap(sessions: UsageStats["sessions"]): Map { + const m = new Map(); + for (const s of sessions) { + addAgg(m, s.session_id, s); + if (s.parent) addAgg(m, s.parent, s); + } + const out = new Map(); + for (const [sid, agg] of m) out.set(sid, aggToUsage(agg)); + return out; +} + +/** 把一组会话 id 的用量合并成一个合计(文件夹级展示用)。 */ +export function sumUsage(ids: Iterable, map: ReadonlyMap): TokenUsage | null { + const agg: UsageAgg = { input: 0, output: 0, calls: 0, models: new Map() }; + let any = false; + for (const id of ids) { + const u = map.get(id); + if (!u) continue; + any = true; + agg.input += u.input; + agg.output += u.output; + agg.calls += u.calls; + for (const md of u.models) { + const cur = agg.models.get(md.model); + agg.models.set(md.model, { + input: (cur?.input ?? 0) + md.input_tokens, + output: (cur?.output ?? 0) + md.output_tokens, + calls: (cur?.calls ?? 0) + md.calls, + }); + } + } + return any ? aggToUsage(agg) : null; +} diff --git a/desktop/ui-next/src/lib/layoutContract.test.ts b/desktop/ui-next/src/lib/layoutContract.test.ts index 887aeec47..f5747363c 100644 --- a/desktop/ui-next/src/lib/layoutContract.test.ts +++ b/desktop/ui-next/src/lib/layoutContract.test.ts @@ -19,7 +19,7 @@ function sources(dir: string = SRC): string[] { return out; } -const rel = (p: string) => p.slice(SRC.length + 1); +const rel = (p: string) => p.slice(SRC.length + 1).replace(/\\/g, "/"); describe("LAYOUT §6.2 menu 截断铁律", () => { // daisyUI 5 的 `.menu` **和** `.menu :where(li)` 都是 `flex-flow: column wrap` diff --git a/desktop/ui-next/src/lib/util/projects.test.ts b/desktop/ui-next/src/lib/util/projects.test.ts index 71878eaaf..4ed0af66f 100644 --- a/desktop/ui-next/src/lib/util/projects.test.ts +++ b/desktop/ui-next/src/lib/util/projects.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionMeta } from "@/lib/ipc/sessions"; import { + groupLocalSessions, groupSessions, projectKey, projectName, @@ -9,6 +10,7 @@ import { readProjectOrder, reorderKeys, writeProjectOrder, + type CustomGroup, } from "./projects"; let store: Map; @@ -107,3 +109,35 @@ describe("折叠态契约键归一(旧 UI 写的是裸 workdir)", () => { expect(JSON.parse(store.get("mc.sessionArchivesOpen") ?? "")).toEqual(["/p/b"]); }); }); + +describe("自定义分组 groupLocalSessions", () => { + const customGroups: CustomGroup[] = [ + { id: "g1", name: "甲组", createdAt: 1 }, + { id: "g2", name: "乙组", createdAt: 2 }, + ]; + + it("项目级归组:分配给自定义组的项目其会话脱离自动项目分组", () => { + const sessions = [ + meta({ id: "s1", workdir: "/p/proj" }), + meta({ id: "s2", workdir: "/p/proj" }), + meta({ id: "s3", workdir: "/p/other" }), + ]; + const r = groupLocalSessions(sessions, [], new Set(), customGroups, { "/p/proj": "g1" }, new Set()); + const g1 = r.custom.find((g) => g.id === "g1")!; + expect(g1.projects[0]!.sessions.map((s) => s.id)).toEqual(["s1", "s2"]); + // 未归组的项目仍走自动项目分组 + expect(r.projects[0]!.key).toBe("/p/other"); + expect(r.assigned.has("/p/proj")).toBe(true); + }); + + it("置顶项目排最前;空分组不渲染", () => { + const sessions = [ + meta({ id: "s1", workdir: "/p/b" }), + meta({ id: "s2", workdir: "/p/a" }), + meta({ id: "s3", workdir: "/p/g" }), + ]; + const r = groupLocalSessions(sessions, [], new Set(), customGroups, { "/p/g": "g1" }, new Set(["/p/b"])); + expect(r.projects[0]!.key).toBe("/p/b"); // 置顶在前 + expect(r.custom.filter((g) => g.projects.length).map((g) => g.id)).toEqual(["g1"]); + }); +}); diff --git a/desktop/ui-next/src/lib/util/projects.ts b/desktop/ui-next/src/lib/util/projects.ts index 728db475b..0475cfab7 100644 --- a/desktop/ui-next/src/lib/util/projects.ts +++ b/desktop/ui-next/src/lib/util/projects.ts @@ -119,6 +119,75 @@ export interface ProjectGroup { archivedSessions: SessionMeta[]; } +/** 自定义分组(仅 local 空间):用户手动建的分组文件夹,项目(文件夹)可移入归组。 */ +export interface CustomGroup { + id: string; + name: string; + createdAt: number; +} + +const CUSTOM_GROUPS_KEY = "mc.customGroups"; +const PROJECT_GROUPS_KEY = "mc.projectGroups"; +const PINNED_PROJECTS_KEY = "mc.pinnedProjects"; + +export function readCustomGroups(): CustomGroup[] { + try { + const value: unknown = JSON.parse(localStorage.getItem(CUSTOM_GROUPS_KEY) || "[]"); + if (!Array.isArray(value)) return []; + return value.filter( + (g): g is CustomGroup => typeof g === "object" && g !== null && typeof (g as CustomGroup).id === "string" && typeof (g as CustomGroup).name === "string", + ); + } catch { + return []; + } +} + +export function writeCustomGroups(groups: readonly CustomGroup[]): void { + try { + localStorage.setItem(CUSTOM_GROUPS_KEY, JSON.stringify(groups)); + } catch { + // 只丢持久化 + } +} + +/** 项目(归一 key)→ 自定义分组 id 映射。 */ +export function readProjectGroups(): Record { + try { + const value: unknown = JSON.parse(localStorage.getItem(PROJECT_GROUPS_KEY) || "{}"); + if (typeof value !== "object" || value === null) return {}; + const out: Record = {}; + for (const [k, v] of Object.entries(value)) if (typeof v === "string") out[k] = v; + return out; + } catch { + return {}; + } +} + +export function writeProjectGroups(map: Record): void { + try { + localStorage.setItem(PROJECT_GROUPS_KEY, JSON.stringify(map)); + } catch { + // 只丢持久化 + } +} + +/** 置顶项目(归一 key 集合):置顶项目排在最前。 */ +export function readPinnedProjects(): Set { + return new Set(readStringArray(PINNED_PROJECTS_KEY)); +} + +export function writePinnedProjects(keys: ReadonlySet): void { + try { + localStorage.setItem(PINNED_PROJECTS_KEY, JSON.stringify([...keys])); + } catch { + // 只丢持久化 + } +} + +export function newGroupId(): string { + return `g-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + export interface GroupedSessions { projects: ProjectGroup[]; archivedProjects: ProjectGroup[]; @@ -168,3 +237,88 @@ export function groupSessions( for (const group of all) (archivedProjects.has(group.key) ? archived : projects).push(group); return { projects, archivedProjects: archived }; } + +export interface CustomGrouped { + /** 自定义分组:含其成员项目(项目 key + 组内项目分组) */ + custom: (CustomGroup & { projects: ProjectGroup[] })[]; + projects: ProjectGroup[]; + archivedProjects: ProjectGroup[]; + /** 已分配给自定义分组的项目 key(从自动项目分组里剔除) */ + assigned: ReadonlySet; +} + +/** 置顶项目排最前。 */ +function pinFirstKeys(keys: readonly string[], pinned: ReadonlySet): string[] { + const pinnedList: string[] = []; + const rest: string[] = []; + for (const k of keys) (pinned.has(k) ? pinnedList : rest).push(k); + return [...pinnedList, ...rest]; +} + +/** local 空间分组:自定义分组优先(项目分配进自定义组则脱离自动项目分组)。 + * projectGroups: 项目 key → 自定义组 id;置顶作用于项目(组)级。 */ +export function groupLocalSessions( + sessions: readonly SessionMeta[], + order: readonly string[], + archivedProjects: ReadonlySet, + customGroups: readonly CustomGroup[], + projectGroups: Record, + pinnedProjects: ReadonlySet, +): CustomGrouped { + // 按项目聚合所有会话(未区分归档,项目级决定归属) + const byProject = new Map(); + for (const meta of sessions) { + const key = projectKey(meta.workdir); + let list = byProject.get(key); + if (!list) { + list = []; + byProject.set(key, list); + } + list.push(meta); + } + const buildProject = (key: string): ProjectGroup => { + const members = byProject.get(key) ?? []; + const name = projectName(key); + return { + key, + name, + sessions: members.filter((m) => !m.archived), + archivedSessions: members.filter((m) => m.archived), + }; + }; + + const assigned = new Set(); + const byGroup = new Map(); + for (const key of byProject.keys()) { + const gid = projectGroups[key]; + if (!gid) continue; + assigned.add(key); + let list = byGroup.get(gid); + if (!list) { + list = []; + byGroup.set(gid, list); + } + list.push(key); + } + + const custom = customGroups.map((g) => { + const keys = pinFirstKeys(byGroup.get(g.id) ?? [], pinnedProjects); + return { ...g, projects: keys.map(buildProject) }; + }); + // 空分组也保留(用户刚建、还没放项目时分组必须可见) + + // 未分配自定义组的项目 → 走原自动项目分组(上游排序:活跃排前 + 手动序)。 + // 置顶项目再提到最前,保持 groupSessions 的相对顺序。 + const { projects: allProjects, archivedProjects: archived } = groupSessions( + sessions.filter((m) => !assigned.has(projectKey(m.workdir))), + order, + archivedProjects, + ); + const projects = [...allProjects].sort((a, b) => { + const pa = pinnedProjects.has(a.key) ? 0 : 1; + const pb = pinnedProjects.has(b.key) ? 0 : 1; + if (pa !== pb) return pa - pb; + return allProjects.indexOf(a) - allProjects.indexOf(b); + }); + return { custom, projects, archivedProjects: archived, assigned }; +}