diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 5bb151f..74867c6 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -146,6 +146,15 @@ - **commit**: ======================================================= --> +### 2026-06-30 · v0.6 项目网格卡片 — 直接关掉 session + +- **状态**: `[x]` DONE。51 projects-panel 测试全绿(+1)· web typecheck 干净 · `build:web` OK · 真浏览器验证通过。 +- **需求**(用户): Projects 页面(网格卡片)下应该能直接关掉 session(此前只有详情页有 Kill,卡片 session 行只能点进入)。 +- **改动**(纯前端 `public/projects.ts` + style): `makeSessionRow` 加可选 `onKill` → 行尾 ✕ 按钮(`.proj-session-kill`,stopPropagation 不触发进入);`makeProjectCard` 加可选 `onKillSession` 透传;`mountProjects` 加 `killAndRefresh`(`DELETE /live-sessions/:id` 后 refresh)传入网格卡片。CSS `.proj-session-kill`(hover 变红)。 +- **测试**(+1): 卡片 session 行——无 onKillSession 时无 ✕;有时点 ✕ 调 onKill(id) 且不触发 onEnterSession。 +- **验证**: `npx tsc -p tsconfig.web.json` 干净;`npx vitest run projects-panel` 51/51;真浏览器:web-terminal 卡 2 个 session 行 → 点 ✕ → 杀掉并刷新为 1 行。 +- **commit**: (本次提交) + ### 2026-06-30 · v0.6 项目详情页 — branch/worktree + 详细 active session - **状态**: `[x]` DONE。**452 测试全绿**(+21)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.16/81.32(`worktrees.ts` 100% / `projects.ts` 93.8%)· 真端到端(curl + 真浏览器)验证通过。 diff --git a/public/projects.ts b/public/projects.ts index 4a2226c..358c3bf 100644 --- a/public/projects.ts +++ b/public/projects.ts @@ -174,6 +174,7 @@ function sessionDotClass(status: ClaudeStatus): string { function makeSessionRow( sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number }, onEnterSession: (id: string) => void, + onKill?: (id: string) => void, ): HTMLElement { const row = el('div', 'proj-session-row') row.setAttribute('role', 'button') @@ -185,6 +186,19 @@ function makeSessionRow( row.append(dot, title, badge) + if (onKill) { + const kill = el('button', 'proj-session-kill', '✕') + kill.title = 'Kill this session' + kill.setAttribute('aria-label', 'Kill session') + // Don't let the kill button trigger the row's enter handler. + kill.addEventListener('pointerdown', (e) => e.stopPropagation()) + kill.addEventListener('click', (e) => { + e.stopPropagation() + onKill(sess.id) + }) + row.append(kill) + } + row.addEventListener('click', () => onEnterSession(sess.id)) row.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -283,6 +297,7 @@ export function makeProjectCard( hooks: ProjectsHooks, onToggleFav: (path: string) => void, onOpenDetail?: (path: string) => void, + onKillSession?: (id: string) => void, ): HTMLElement { const card = el('div', 'proj-card') @@ -339,7 +354,7 @@ export function makeProjectCard( if (runningSessions.length > 0) { const sessionsList = el('div', 'proj-sessions-list') for (const sess of runningSessions) { - sessionsList.append(makeSessionRow(sess, hooks.onEnterSession)) + sessionsList.append(makeSessionRow(sess, hooks.onEnterSession, onKillSession)) } card.append(sessionsList) } @@ -507,6 +522,10 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects void refresh() } + function killAndRefresh(id: string): void { + void killSession(id).then(() => void refresh()) + } + function renderGrid(): void { const filtered = filterProjects(allProjects, searchInput.value) const sorted = sortProjects(filtered, favs) @@ -534,6 +553,7 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects renderGrid() }, openDetail, + killAndRefresh, ), ) } diff --git a/public/style.css b/public/style.css index ced0b78..fa06f60 100644 --- a/public/style.css +++ b/public/style.css @@ -1066,6 +1066,26 @@ body { white-space: nowrap; } +/* Kill (✕) on a session row — close the session straight from the card. */ +.proj-session-kill { + flex: none; + width: 20px; + height: 20px; + line-height: 1; + padding: 0; + border: none; + border-radius: 5px; + background: none; + color: var(--text-faint); + cursor: pointer; + font-size: 11px; + transition: background 0.1s ease, color 0.1s ease; +} +.proj-session-kill:hover { + background: var(--red, #e5484d); + color: #fff; +} + /* Client-count badge (👁 N) */ .proj-session-badge { font-size: 11px; diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index cdc1285..a79791e 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -324,6 +324,25 @@ describe('makeProjectCard — launcher row', () => { expect(card.querySelectorAll('.proj-launch')).toHaveLength(3) }) + it('shows a Kill (✕) on each session row only when onKillSession is given', () => { + const p = makeProject({ + sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }], + }) + // Without the callback: no kill button (enter-only). + const plain = makeProjectCard(p, new Set(), noopHooks(), () => {}) + expect(plain.querySelector('.proj-session-kill')).toBeNull() + + // With the callback: clicking ✕ kills that session and does not enter it. + const hooks = noopHooks() + const onKill = vi.fn() + const card = makeProjectCard(p, new Set(), hooks, () => {}, undefined, onKill) + const kill = card.querySelector('.proj-session-kill') as HTMLButtonElement + expect(kill).not.toBeNull() + kill.click() + expect(onKill).toHaveBeenCalledWith('s1') + expect(hooks.onEnterSession).not.toHaveBeenCalled() + }) + it('highlights the Claude launcher when a Claude session (known status) is live', () => { const p = makeProject({ sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],