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:
@@ -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 + 真浏览器)验证通过。
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
Reference in New Issue
Block a user