diff --git a/src/App.tsx b/src/App.tsx index b0381ab..ae2babe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react' import { auth } from './auth' import { store, useStore } from './store' -import { ProjectNav } from './components/ProjectNav' +import { TaskSidebar } from './components/TaskSidebar' import { Conversation } from './components/Conversation' import { Workbench } from './components/Workbench' import { Workspaces, workspaceShortcuts } from './components/Workspaces' @@ -113,15 +113,18 @@ export function App() { {!ui.leftCollapsed ? ( <>
- +
) : (
- - + + +
diff --git a/src/api.ts b/src/api.ts index a1c3189..2ac22d5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -24,6 +24,10 @@ export const api = { deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }), listConversations: (projectId: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`).then(r => json(r)), + // 跨项目任务列表(方案 A 侧栏):默认仅未归档;includeArchived 含归档。 + listTasks: (includeArchived?: boolean) => auth.fetch(`${BASE}/api/conversations${includeArchived ? '?archived=1' : ''}`).then(r => json(r)), + setConversationFlags: (id: string, flags: { pinned?: boolean; archived?: boolean }) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(flags) }).then(r => { if (!r.ok) throw new Error(`${r.status}: ${r.statusText}`) }), + me: () => auth.fetch(`${BASE}/api/me`).then(r => json<{ owner: string; name: string; auth: boolean }>(r)), createConversation: (projectId: string, title?: string) => auth.fetch(`${BASE}/api/projects/${projectId}/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title ?? '' }) }).then(r => json(r)), deleteConversation: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'DELETE' }), setConversationProfile: (id: string, profileId: string) => auth.fetch(`${BASE}/api/conversations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profileId }) }).then(r => json(r)), diff --git a/src/auth.ts b/src/auth.ts index af2f88e..cbfe5e8 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -30,6 +30,11 @@ export const auth = { headers.set('Authorization', `Bearer ${key}`) return fetch(input, { ...init, headers }) }, + // 退出登录:清凭证并整页回登录门(服务端会话/密钥均为无状态凭证,无需服务端注销)。 + logout() { + this.clear() + try { location.reload() } catch { /* 非 DOM 环境(测试)忽略 */ } + }, // 无法带头的资源 URL(EventSource / / 下载):追加 ?key=。 withKey(url: string): string { const key = this.apiKey() diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 42511a2..8b25a26 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -213,7 +213,7 @@ function CommandMenu({ text, onPick }: { text: string; onPick: (cmd: string) => export function Conversation() { const currentConvId = useStore(s => s.currentConvId) - const conversation = useStore(s => s.conversations.find(c => c.id === s.currentConvId)) + const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId)) const messages = useStore(s => s.messages) const activeHitl = useStore(s => s.activeHitl) const live = useStore(s => s.live) diff --git a/src/components/ProjectNav.tsx b/src/components/ProjectNav.tsx deleted file mode 100644 index 9bd04c9..0000000 --- a/src/components/ProjectNav.tsx +++ /dev/null @@ -1,74 +0,0 @@ -// 左栏:项目折叠组 → 会话列表;底部账户/设置入口(4.4)。 -import { useEffect, useState } from 'react' -import { store, useStore } from '../store' -import { workspaceEntries } from './Workspaces' - -export function ProjectNav() { - const projects = useStore(s => s.projects) - const currentProjectId = useStore(s => s.currentProjectId) - const conversations = useStore(s => s.conversations) - const currentConvId = useStore(s => s.currentConvId) - const [collapsedProjects, setCollapsedProjects] = useState>({}) - const [creating, setCreating] = useState(false) - const [newName, setNewName] = useState('') - - useEffect(() => { setNewName(`项目 ${projects.length + 1}`) }, [projects.length]) - - return ( - <> -
- Agent-System - -
-
-
工作区
-
- {workspaceEntries().map(e => ( - - ))} -
-
- {creating && ( -
- setNewName(e.target.value)} - onKeyDown={async e => { - if (e.key === 'Enter' && newName.trim()) { await store.createProject(newName.trim()); setCreating(false) } - }} placeholder="项目名称" autoFocus /> - -
- )} -
- {projects.length === 0 &&
尚无项目——点击 ✚ 创建
} - {projects.map(p => { - const open = !collapsedProjects[p.id] - return ( -
-
{ - setCollapsedProjects(c => ({ ...c, [p.id]: !(c[p.id] ?? p.id === currentProjectId ? false : true) })) - await store.openProject(p.id) - }}> - ▶ - {p.name} -
- {open && p.id === currentProjectId && conversations.map(c => ( -
store.openConversation(c.id)} title={c.title}> - {c.title} -
- ))} -
- ) - })} -
-
- - -
- - ) -} diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx new file mode 100644 index 0000000..961364e --- /dev/null +++ b/src/components/TaskSidebar.tsx @@ -0,0 +1,242 @@ +// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+ +// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。 +// 工作区快捷图标条保留(D25:⌘ 与左栏共享同一清单)。 +import { useEffect, useRef, useState } from 'react' +import { store, useStore } from '../store' +import { auth } from '../auth' +import type { Conversation } from '../contracts' +import { workspaceEntries } from './Workspaces' +import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist' + +const GROUPS_KEY = 'as.taskGroups' + +function loadCollapsed(): Record { + try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} } +} + +/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */ +function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) { + const ref = useRef(null) + useEffect(() => { + if (!anchor || !ref.current) return + const r = anchor.getBoundingClientRect() + const el = ref.current + let x = r.left, y = r.bottom + 6 + if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8 + if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6 + el.style.left = `${Math.max(8, x)}px` + el.style.top = `${y}px` + const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() } + window.addEventListener('mousedown', h) + return () => window.removeEventListener('mousedown', h) + }, [anchor]) + if (!anchor) return null + return
e.stopPropagation()}>{children}
+} + +function MenuItem({ onClick, danger, children }: { onClick: () => void; danger?: boolean; children: React.ReactNode }) { + return +} + +export function TaskSidebar() { + const projects = useStore(s => s.projects) + const tasks = useStore(s => s.tasks) + const me = useStore(s => s.me) + const currentConvId = useStore(s => s.currentConvId) + const currentProjectId = useStore(s => s.currentProjectId) + const org = useStore(s => s.ui.taskOrg) + const showArchived = useStore(s => s.ui.showArchived) + const theme = useStore(s => s.ui.theme) + const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length) + + const [q, setQ] = useState('') + const [collapsed, setCollapsed] = useState>(loadCollapsed) + const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null) + const [creatingProject, setCreatingProject] = useState(false) + const [newProjectName, setNewProjectName] = useState('') + + // ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。 + useEffect(() => { + const h = (e: KeyboardEvent) => { + const k = e.key.toLowerCase() + if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() } + if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() } + } + window.addEventListener('keydown', h, true) + return () => window.removeEventListener('keydown', h, true) + }, []) + + const tglGroup = (key: string) => { + setCollapsed(c => { + const next = { ...c, [key]: !c[key] } + try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ } + return next + }) + } + const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o }) + + const visible = filterTasks(tasks, q) + const pinned = visible.filter(t => t.pinned) + const rest = visible.filter(t => !t.pinned) + const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest) + const currentProject = projects.find(p => p.id === currentProjectId) + const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null + const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null + + const rowActions = (t: Conversation) => ( + + + + + ) + + const taskRow = (t: Conversation, badge?: string) => ( +
store.openTask(t.id)} + onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}> + {t.title || '未命名任务'} + {badge && {badge}} + {relativeTime(t.updatedAt)} + {rowActions(t)} +
+ ) + + return ( + <> +
+ A + Agent-System + + +
+ + {/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */} +
+
+ + +
+ setQ(e.target.value)} /> +
+ + {/* 组织视图切换(P8)+ 过滤 */} +
+
+ + +
+ +
+ +
+ {creatingProject && ( +
+ setNewProjectName(e.target.value)} + onKeyDown={async e => { + if (e.key === 'Enter' && newProjectName.trim()) { + await store.createProject(newProjectName.trim()) + setCreatingProject(false); setNewProjectName('') + } + if (e.key === 'Escape') setCreatingProject(false) + }} + placeholder="项目名称,回车创建" /> +
+ )} + + {pinned.length > 0 && ( + <> +
置顶
+ {pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))} + + )} + + {groups.length === 0 && pinned.length === 0 && ( +
尚无任务——点击「新建任务」开始
(项目内素材上传、检索与生成全程可在对话中完成)
+ )} + + {groups.map(g => ( +
+ {org === 'project' ? ( +
tglGroup(g.key)}> + ▶ + {g.label} + {g.tasks.length} + + +
+ ) : ( +
{g.label}
+ )} + {!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))} +
+ ))} +
+ + {/* 工作区快捷图标条(D25:与 ⌘1–9 同一清单) */} +
+ {workspaceEntries().map(e => ( + + ))} + + +
+ + {/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */} +
+ + + {pendingBadge > 0 && } +
+ + {/* 弹出菜单们 */} + setMenu(null)}> + { store.createTask(); setMenu(null) }}>新建任务 · 当前项目⌘N +
+ { setCreatingProject(true); setMenu(null) }}>新建项目… + {projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => ( + { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务 + ))} +
+ + setMenu(null)}> + {rowTask && <> + { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'} + { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'} +
+ { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看 + } +
+ + setMenu(null)}> +
+ {(me?.name || me?.owner || '?').slice(0, 1).toUpperCase()} + +
{me?.name || '未连接'}
+
{me?.owner ? `租户:${me.owner}` : '—'}
+
+
+
+ { store.openSettings(true); setMenu(null) }}>偏好设置 + { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''} + {me?.auth && <> +
+ { auth.logout() }}>退出登录 + } +
+ + ) +} diff --git a/src/contracts.ts b/src/contracts.ts index fe3a488..7c45ebf 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -14,6 +14,7 @@ export interface Project { export interface Conversation { id: ID; projectId: ID; owner?: ID; title: string; profileId?: ID; + pinned?: boolean; archived?: boolean; createdAt: string; updatedAt: string; } diff --git a/src/store.ts b/src/store.ts index 48cda25..4aa31d1 100644 --- a/src/store.ts +++ b/src/store.ts @@ -35,13 +35,19 @@ export interface UIState { trackerRunId: string | null viewingArtifact: Artifact | null activeWorkspace: string | null + taskOrg: 'project' | 'timeline' + showArchived: boolean + lastProjectId: string | null } +export interface Me { owner: string; name: string; auth: boolean } + interface AppState { contextObs: Record projects: Project[] currentProjectId: string | null - conversations: Conversation[] + tasks: Conversation[] + me: Me | null currentConvId: string | null messages: Message[] runs: Run[] @@ -73,6 +79,9 @@ const DEFAULT_UI: UIState = { settingsOpen: false, trackerRunId: null, viewingArtifact: null, + taskOrg: 'project', + showArchived: false, + lastProjectId: null, } function loadUI(): UIState { @@ -85,7 +94,7 @@ function loadUI(): UIState { let state: AppState = { contextObs: {}, - projects: [], currentProjectId: null, conversations: [], currentConvId: null, + projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, artifacts: [], cards: [], profiles: [], pluginPanels: [], serverWorkspaces: [], @@ -126,12 +135,23 @@ export const store = { async refreshProjects() { try { const projects = await api.listProjects() - const current = projects.find(p => p.id === state.currentProjectId) ?? projects[0] + const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] set({ projects, currentProjectId: current?.id ?? null, error: null }) if (current) await store.openProject(current.id) } catch (e) { set({ error: String(e) }) } + store.refreshTasks() + store.refreshMe() + }, + + // 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。 + async refreshTasks(includeArchived = state.ui.showArchived) { + try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ } + }, + + async refreshMe() { + try { set({ me: await api.me() }) } catch { /* 身份不可用时侧栏只显示通用入口 */ } }, async createProject(name: string) { @@ -142,16 +162,47 @@ export const store = { }, async openProject(projectId: string) { - const conversations = await api.listConversations(projectId) - set({ currentProjectId: projectId, conversations }) + set({ currentProjectId: projectId }) + store.setUI({ lastProjectId: projectId }) await store.refreshArtifacts(projectId) }, + /** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 */ + async createTask(projectId?: string) { + const pid = projectId ?? state.currentProjectId + if (!pid) { store.toast('请先创建项目'); return } + const c = await api.createConversation(pid) + await store.refreshTasks() + await store.openTask(c.id) + }, + + /** 兼容旧入口(⌘/命令总线) */ async createConversation(title?: string) { - if (!state.currentProjectId) return - const c = await api.createConversation(state.currentProjectId, title) - await store.openProject(state.currentProjectId) - await store.openConversation(c.id) + if (title) store.toast('已在当前项目创建任务') + await store.createTask() + }, + + /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */ + async openTask(convId: string) { + const t = state.tasks.find(x => x.id === convId) + if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId) + await store.openConversation(convId) + }, + + async pinTask(convId: string, pinned: boolean) { + const tasks = state.tasks.map(t => t.id === convId ? { ...t, pinned } : t) + set({ tasks }) + try { await api.setConversationFlags(convId, { pinned }) } catch (e) { store.toast(`置顶失败:${e}`); store.refreshTasks() } + }, + + async archiveTask(convId: string, archived: boolean) { + const tasks = state.tasks.map(t => t.id === convId ? { ...t, archived } : t) + set({ tasks }) + try { + await api.setConversationFlags(convId, { archived }) + if (state.currentConvId === convId && archived) store.toast('任务已归档') + } catch (e) { store.toast(`归档失败:${e}`) } + store.refreshTasks() }, async openConversation(convId: string) { @@ -178,6 +229,7 @@ export const store = { const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)]) set({ plan, todos }) store.refreshCards(d.conversation.id) + store.refreshTasks() } catch (e) { set({ error: String(e) }) } diff --git a/src/styles.css b/src/styles.css index 00571b9..9b91458 100644 --- a/src/styles.css +++ b/src/styles.css @@ -17,27 +17,7 @@ button { font-family: inherit; } .right-panel { background: var(--bg-panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; } .right-panel.collapsed { width: 44px !important; } -/* ---- 左栏 ---- */ -.nav-header { padding: 14px 14px 8px; font-weight: 600; display: flex; align-items: center; justify-content: space-between; } -.nav-header button { background: none; border: none; color: var(--text-dim); cursor: pointer; font-size: 16px; padding: 4px; border-radius: var(--radius-sm); } -.nav-header button:hover { background: var(--bg-hover); color: var(--text); } -.nav-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; } -.nav-project { margin-bottom: 4px; } -.nav-project-name { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; cursor: pointer; user-select: none; } -.nav-project-name:hover { background: var(--bg-hover); } -.nav-project-name .chev { font-size: 10px; transition: transform .12s; } -.nav-project-name.open .chev { transform: rotate(90deg); } -.nav-conv { padding: 5px 8px 5px 26px; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.nav-conv:hover { background: var(--bg-hover); color: var(--text); } -.nav-conv.active { background: var(--accent-soft); color: var(--accent); } -.nav-footer { border-top: 1px solid var(--border); padding: 8px; display: flex; gap: 6px; } -.nav-footer button { flex: 1; background: none; border: none; color: var(--text-dim); padding: 7px; border-radius: var(--radius-sm); cursor: pointer; font-size: 12px; } -.nav-footer button:hover { background: var(--bg-hover); color: var(--text); } -.left-nav.collapsed .nav-label { display: none; } -.icon-only { display: flex; flex-direction: column; align-items: center; gap: 10px; padding-top: 10px; } -.icon-only button { background: none; border: none; color: var(--text-dim); font-size: 16px; cursor: pointer; padding: 8px; border-radius: var(--radius-sm); } -.icon-only button:hover { background: var(--bg-hover); color: var(--text); } - +/* ---- 左栏:任务中心(方案 A)见文末 tsb-* ---- */ /* ---- 中栏对话 ---- */ /* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小 高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */ @@ -205,11 +185,6 @@ button { font-family: inherit; } .login-err { margin-top: 10px; font-size: 12px; color: #f87171; } /* 工作区快捷区(T4/D26 左栏)与当前任务常驻卡(D25 R4) */ -.ws-quick{padding:6px 8px 8px;border-bottom:1px solid var(--border)} -.ws-quick-list{display:flex;flex-direction:column;gap:2px;margin-top:4px} -.ws-quick-item{display:flex;align-items:center;gap:8px;padding:4px 8px;border:none;background:transparent;color:inherit;cursor:pointer;border-radius:6px;text-align:left;font-size:12px} -.ws-quick-item:hover{background:rgba(120,160,255,.12)} -.ws-quick-item .sc{margin-left:auto;font-size:10px;color:var(--text-faint);border:1px solid var(--border);border-radius:3px;padding:0 4px} .current-task{border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:8px;background:rgba(120,160,255,.05)} .ct-head{display:flex;align-items:center;gap:8px;font-weight:600;margin-bottom:6px} .ct-title{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} @@ -222,3 +197,81 @@ button { font-family: inherit; } .rail-badge{position:absolute;top:-4px;right:-6px;font-size:10px;background:var(--warn,#d29922);color:#fff;border-radius:8px;padding:0 5px;min-width:14px} .right-panel.collapsed .icon-only button{position:relative} .tab-action{font-size:11px;color:var(--text-faint)} + +/* ---- 左栏:任务中心(方案 A,提案 2026-09-30) ---- */ +.tsb-brand { display: flex; align-items: center; gap: 8px; padding: 12px 12px 6px; } +.tsb-logo { width: 22px; height: 22px; border-radius: 6px; background: var(--accent); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; } +.tsb-name { font-weight: 600; font-size: 13.5px; } +.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 13px; display: inline-flex; align-items: center; justify-content: center; position: relative; } +.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); } +.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); } + +.tsb-actions { padding: 4px 10px 8px; display: flex; flex-direction: column; gap: 6px; } +.tsb-newtask { display: flex; height: 34px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-soft); overflow: hidden; } +.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 6px; padding: 0 10px; background: none; border: none; color: var(--accent); font-weight: 600; font-size: 13px; cursor: pointer; min-width: 0; } +.tsb-newtask-main:hover { filter: brightness(1.08); } +.tsb-newtask-proj { font-weight: 400; font-size: 11.5px; opacity: .8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tsb-newtask-caret { width: 28px; border: none; border-left: 1px solid var(--accent); background: none; color: var(--accent); cursor: pointer; font-size: 10px; } +.tsb-newtask-caret:hover { background: var(--accent-soft); } +.tsb-search { height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 10px; font-size: 12.5px; width: 100%; } +.tsb-search:focus { border-color: var(--accent); outline: none; } +.tsb-search::placeholder { color: var(--text-faint); } + +.tsb-orgrow { display: flex; align-items: center; gap: 6px; padding: 0 10px 8px; } +.tsb-pills { flex: 1; display: flex; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px; gap: 2px; } +.tsb-pill { flex: 1; height: 22px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; } +.tsb-pill:hover { color: var(--text-dim); } +.tsb-pill.on { background: var(--bg-active); color: var(--text); font-weight: 500; } + +.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; min-height: 0; } +.tsb-seclabel { font-size: 11px; color: var(--text-faint); padding: 10px 8px 4px; letter-spacing: .3px; } +.tsb-empty { padding: 18px 12px; color: var(--text-faint); font-size: 12.5px; line-height: 1.8; text-align: center; } +.tsb-newproject { padding: 0 2px 8px; } + +.tsb-group { margin-bottom: 2px; } +.tsb-group-head { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 6px 0 8px; border-radius: var(--radius-sm); color: var(--text-dim); font-weight: 500; font-size: 13px; cursor: pointer; user-select: none; } +.tsb-group-head:hover { background: var(--bg-hover); } +.tsb-group.closed .tsb-chev { transform: rotate(-90deg); } +.tsb-chev { font-size: 9px; color: var(--text-faint); transition: transform .12s; } +.tsb-group-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tsb-group-cnt { font-size: 11px; color: var(--text-faint); font-weight: 400; } +.tsb-group-add { visibility: hidden; width: 20px; height: 20px; border: none; border-radius: 4px; background: none; color: var(--text-faint); font-size: 12px; cursor: pointer; } +.tsb-group-head:hover .tsb-group-add { visibility: visible; } +.tsb-group-add:hover { background: var(--bg-active); color: var(--text); } + +.tsb-task { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; } +.tsb-task:hover { background: var(--bg-hover); color: var(--text); } +.tsb-task.active { background: var(--bg-active); color: var(--text); } +.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; } +.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; } +.tsb-task-tm { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; } +.tsb-pbadge { font-size: 10.5px; color: var(--text-faint); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; line-height: 1.5; white-space: nowrap; } +.tsb-row-acts { display: none; gap: 2px; } +.tsb-task:hover .tsb-row-acts { display: inline-flex; } +.tsb-task:hover .tsb-task-tm { display: none; } +.tsb-row-act { border: none; background: none; color: var(--text-faint); font-size: 11px; cursor: pointer; padding: 2px 3px; border-radius: 4px; } +.tsb-row-act:hover { background: var(--bg-active); color: var(--text); } + +.tsb-wsstrip { display: flex; align-items: center; gap: 2px; padding: 6px 10px; border-top: 1px solid var(--border); } +.tsb-wsstrip button { border: none; background: none; color: var(--text-faint); font-size: 13px; cursor: pointer; padding: 4px 5px; border-radius: var(--radius-sm); } +.tsb-wsstrip button:hover { background: var(--bg-hover); color: var(--text); } + +.tsb-footer { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); } +.tsb-user { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; height: 38px; padding: 0 7px; border: none; border-radius: var(--radius-sm); background: none; cursor: pointer; text-align: left; } +.tsb-user:hover { background: var(--bg-hover); } +.tsb-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: #fff; font-size: 12px; font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; } +.tsb-user-info { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.tsb-user-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tsb-user-sub { font-size: 11px; color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tsb-user-chev { color: var(--text-faint); font-size: 10px; } + +.tsb-menu { position: fixed; z-index: 300; min-width: 210px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0,0,0,.35); padding: 5px; display: flex; flex-direction: column; } +.tsb-mi { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: 13px; cursor: pointer; text-align: left; } +.tsb-mi:hover { background: var(--bg-hover); color: var(--text); } +.tsb-mi.danger { color: var(--err); } +.tsb-mi.danger:hover { background: var(--err); color: #fff; } +.tsb-mi kbd { margin-left: auto; font-size: 10.5px; color: var(--text-faint); } +.tsb-menu-div { height: 1px; background: var(--border); margin: 5px 4px; } +.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; } +.tsb-acc-name { font-size: 13.5px; font-weight: 600; } +.tsb-acc-mail { font-size: 11.5px; color: var(--text-faint); } diff --git a/src/tasklist.ts b/src/tasklist.ts new file mode 100644 index 0000000..e5228bf --- /dev/null +++ b/src/tasklist.ts @@ -0,0 +1,82 @@ +// 任务列表组织(方案 A,提案 2026-09-30):纯函数,供 TaskSidebar 与测试消费。 +// 组织是本端视图偏好(P8),不改数据模型——数据源恒为跨项目 tasks(updated_at 倒序)。 +import type { Conversation, Project } from './contracts' + +export type TaskOrg = 'project' | 'timeline' + +export interface TaskGroup { + key: string + label: string + /** 项目分组时携带(组头「在此项目新建」入口与计数)。 */ + projectId?: string + tasks: Conversation[] +} + +/** 按项目分组:项目按服务端序(更新倒序),组内任务按列表序(置顶已在服务端排首)。 */ +export function groupByProject(tasks: Conversation[], projects: Project[]): TaskGroup[] { + const byProject = new Map() + for (const t of tasks) { + const list = byProject.get(t.projectId) ?? [] + list.push(t) + byProject.set(t.projectId, list) + } + const groups: TaskGroup[] = [] + for (const p of projects) { + const list = byProject.get(p.id) + if (!list || list.length === 0) continue + groups.push({ key: `prj:${p.id}`, label: p.name, projectId: p.id, tasks: list }) + byProject.delete(p.id) + } + // 项目已删但会话仍在(竞态残留):收进"未分组",不静默丢弃。 + for (const [pid, list] of byProject) { + groups.push({ key: `prj:${pid}`, label: '未分组', projectId: pid, tasks: list }) + } + return groups +} + +/** 按时间线分组:今天 / 昨天 / 本周更早 / 更早(按 updatedAt 本地时区)。 */ +export function groupByTimeline(tasks: Conversation[], now = Date.now()): TaskGroup[] { + const startOfDay = (ts: number) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime() } + const today0 = startOfDay(now) + const dayMs = 86_400_000 + const buckets: TaskGroup[] = [ + { key: 'today', label: '今天', tasks: [] }, + { key: 'yesterday', label: '昨天', tasks: [] }, + { key: 'week', label: '本周更早', tasks: [] }, + { key: 'older', label: '更早', tasks: [] }, + ] + const weekday = (new Date(now).getDay() + 6) % 7 // 周一=0 + const weekStart = today0 - weekday * dayMs + for (const t of tasks) { + const ts = Date.parse(t.updatedAt) + if (Number.isNaN(ts)) { buckets[3].tasks.push(t); continue } + if (ts >= today0) buckets[0].tasks.push(t) + else if (ts >= today0 - dayMs) buckets[1].tasks.push(t) + else if (ts >= weekStart) buckets[2].tasks.push(t) + else buckets[3].tasks.push(t) + } + return buckets.filter(g => g.tasks.length > 0) +} + +/** 相对时间(侧栏行尾):刚刚 / N 分钟 / N 小时 / 昨天 / N 天 / 月-日。 */ +export function relativeTime(iso: string, now = Date.now()): string { + const ts = Date.parse(iso) + if (Number.isNaN(ts)) return '' + const diff = now - ts + if (diff < 60_000) return '刚刚' + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)} 分钟` + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)} 小时` + if (diff < 172_800_000) return '昨天' + if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)} 天` + const d = new Date(ts) + const nowD = new Date(now) + const md = `${d.getMonth() + 1}/${d.getDate()}` + return d.getFullYear() === nowD.getFullYear() ? md : `${d.getFullYear()}/${md}` +} + +/** 标题过滤(不区分大小写;空串返回原列表)。 */ +export function filterTasks(tasks: Conversation[], q: string): Conversation[] { + const needle = q.trim().toLowerCase() + if (!needle) return tasks + return tasks.filter(t => t.title.toLowerCase().includes(needle)) +} diff --git a/test/tasklist.test.ts b/test/tasklist.test.ts new file mode 100644 index 0000000..96f14a8 --- /dev/null +++ b/test/tasklist.test.ts @@ -0,0 +1,71 @@ +// 任务列表组织纯函数测试(方案 A R1)。 +import { describe, it, expect } from 'vitest' +import { groupByProject, groupByTimeline, relativeTime, filterTasks } from '../src/tasklist' +import type { Conversation, Project } from '../src/contracts' + +const NOW = new Date('2026-09-30T15:00:00').getTime() +const H = 3_600_000 + +function conv(id: string, projectId: string, updatedAt: string, extra: Partial = {}): Conversation { + return { id, projectId, title: id, createdAt: updatedAt, updatedAt, ...extra } +} + +describe('groupByProject', () => { + it('groups tasks under projects in project order and never drops orphans', () => { + const projects: Project[] = [ + { id: 'p2', name: '乙', createdAt: '', updatedAt: '' }, + { id: 'p1', name: '甲', createdAt: '', updatedAt: '' }, + ] + const tasks = [conv('a', 'p1', '2026-09-30T10:00:00Z'), conv('b', 'p2', '2026-09-29T10:00:00Z'), conv('c', 'pX', '2026-09-28T10:00:00Z')] + const groups = groupByProject(tasks, projects) + expect(groups.map(g => g.label)).toEqual(['乙', '甲', '未分组']) + expect(groups[0].projectId).toBe('p2') + expect(groups[2].tasks.map(t => t.id)).toEqual(['c']) + }) + it('skips projects with zero visible tasks', () => { + const projects: Project[] = [{ id: 'p1', name: '甲', createdAt: '', updatedAt: '' }] + expect(groupByProject([], projects)).toEqual([]) + }) +}) + +describe('groupByTimeline', () => { + it('buckets by local day and week boundaries', () => { + // NOW=周三 15:00(本地);构造今天/昨天/本周更早(周一)/更早 各一条。 + const tasks = [ + conv('today', 'p', new Date(NOW - H).toISOString()), + conv('yesterday', 'p', new Date(NOW - 26 * H).toISOString()), + conv('week', 'p', new Date(NOW - 50 * H).toISOString()), + conv('older', 'p', new Date(NOW - 10 * 24 * H).toISOString()), + ] + const groups = groupByTimeline(tasks, NOW) + expect(groups.map(g => g.label)).toEqual(['今天', '昨天', '本周更早', '更早']) + expect(groups[0].tasks[0].id).toBe('today') + }) + it('hides empty buckets and tolerates bad timestamps', () => { + const groups = groupByTimeline([conv('x', 'p', 'not-a-date')], NOW) + expect(groups).toHaveLength(1) + expect(groups[0].label).toBe('更早') + }) +}) + +describe('relativeTime', () => { + it('formats gradients and year rollover', () => { + expect(relativeTime(new Date(NOW - 30_000).toISOString(), NOW)).toBe('刚刚') + expect(relativeTime(new Date(NOW - 5 * 60_000).toISOString(), NOW)).toBe('5 分钟') + expect(relativeTime(new Date(NOW - 3 * H).toISOString(), NOW)).toBe('3 小时') + expect(relativeTime(new Date(NOW - 30 * H).toISOString(), NOW)).toBe('昨天') + expect(relativeTime(new Date(NOW - 3 * 24 * H).toISOString(), NOW)).toBe('3 天') + expect(relativeTime('2026-05-01T00:00:00', NOW)).toBe('5/1') + expect(relativeTime('2025-12-31T00:00:00', NOW)).toBe('2025/12/31') + expect(relativeTime('bad')).toBe('') + }) +}) + +describe('filterTasks', () => { + it('matches title case-insensitively and passes through on empty query', () => { + const tasks = [conv('a', 'p', '', { title: '周报 Draft' }), conv('b', 'p', '', { title: '接口评审' })] + expect(filterTasks(tasks, '周报')).toEqual([tasks[0]]) + expect(filterTasks(tasks, ' ')).toEqual(tasks) + expect(filterTasks(tasks, 'DRAFT')).toEqual([tasks[0]]) + }) +}) diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 00c22a1..1bda813 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/ProjectNav.tsx","./src/components/SettingsModal.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/main.tsx","./src/store.ts","./src/tasklist.ts","./src/components/AgentTracker.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/tasklist.test.ts"],"version":"5.9.3"} \ No newline at end of file