feat(v0.6): kill a session directly from the Projects grid cards

The detail view could kill sessions, but the grid card's session rows were
enter-only. Add a ✕ kill button to each card session row (stopPropagation so it
doesn't also enter the session); makeSessionRow gains an optional onKill,
makeProjectCard an optional onKillSession, and mountProjects wires it to
DELETE /live-sessions/:id followed by a refresh.

Frontend-only. projects-panel tests +1 (51): ✕ present only with the callback,
click kills the right id without entering. Verified in-browser: a card with 2
sessions dropped to 1 after clicking ✕.
This commit is contained in:
Yaojia Wang
2026-06-30 13:35:53 +02:00
parent 99bc6fd9f6
commit 46698b8b5e
4 changed files with 69 additions and 1 deletions

View File

@@ -146,6 +146,15 @@
- **commit**: <hash 或 N/A> - **commit**: <hash 或 N/A>
======================================================= --> ======================================================= -->
### 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 ### 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 + 真浏览器)验证通过。 - **状态**: `[x]` DONE。**452 测试全绿**(+21)· 双 typecheck 干净 · `build:web` OK · 覆盖率 90.16/81.32(`worktrees.ts` 100% / `projects.ts` 93.8%)· 真端到端(curl + 真浏览器)验证通过。

View File

@@ -174,6 +174,7 @@ function sessionDotClass(status: ClaudeStatus): string {
function makeSessionRow( function makeSessionRow(
sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number }, sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number },
onEnterSession: (id: string) => void, onEnterSession: (id: string) => void,
onKill?: (id: string) => void,
): HTMLElement { ): HTMLElement {
const row = el('div', 'proj-session-row') const row = el('div', 'proj-session-row')
row.setAttribute('role', 'button') row.setAttribute('role', 'button')
@@ -185,6 +186,19 @@ function makeSessionRow(
row.append(dot, title, badge) 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('click', () => onEnterSession(sess.id))
row.addEventListener('keydown', (e) => { row.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
@@ -283,6 +297,7 @@ export function makeProjectCard(
hooks: ProjectsHooks, hooks: ProjectsHooks,
onToggleFav: (path: string) => void, onToggleFav: (path: string) => void,
onOpenDetail?: (path: string) => void, onOpenDetail?: (path: string) => void,
onKillSession?: (id: string) => void,
): HTMLElement { ): HTMLElement {
const card = el('div', 'proj-card') const card = el('div', 'proj-card')
@@ -339,7 +354,7 @@ export function makeProjectCard(
if (runningSessions.length > 0) { if (runningSessions.length > 0) {
const sessionsList = el('div', 'proj-sessions-list') const sessionsList = el('div', 'proj-sessions-list')
for (const sess of runningSessions) { for (const sess of runningSessions) {
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession)) sessionsList.append(makeSessionRow(sess, hooks.onEnterSession, onKillSession))
} }
card.append(sessionsList) card.append(sessionsList)
} }
@@ -507,6 +522,10 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
void refresh() void refresh()
} }
function killAndRefresh(id: string): void {
void killSession(id).then(() => void refresh())
}
function renderGrid(): void { function renderGrid(): void {
const filtered = filterProjects(allProjects, searchInput.value) const filtered = filterProjects(allProjects, searchInput.value)
const sorted = sortProjects(filtered, favs) const sorted = sortProjects(filtered, favs)
@@ -534,6 +553,7 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
renderGrid() renderGrid()
}, },
openDetail, openDetail,
killAndRefresh,
), ),
) )
} }

View File

@@ -1066,6 +1066,26 @@ body {
white-space: nowrap; 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) */ /* Client-count badge (👁 N) */
.proj-session-badge { .proj-session-badge {
font-size: 11px; font-size: 11px;

View File

@@ -324,6 +324,25 @@ describe('makeProjectCard — launcher row', () => {
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3) 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', () => { it('highlights the Claude launcher when a Claude session (known status) is live', () => {
const p = makeProject({ const p = makeProject({
sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }], sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],