feat(v0.6): show CLAUDE.md on the project detail page + /init generate/update button
The detail page now reads <repo>/CLAUDE.md and shows it in a scrollable
monospace box, with one smart button: "↻ Update" when it exists / "✨ Generate"
when it doesn't. The button opens an interactive Claude session in the repo
running /init (claude "/init") so you review what it writes — reuses the project
launcher, no new backend orchestration, stays a byte-shuttle.
- ProjectDetail += hasClaudeMd / claudeMd; buildProjectDetail reads CLAUDE.md
(truncated 64KB, best-effort, in the existing Promise.all)
- renderProjectDetail: CLAUDE.md section (content box or empty state) + button
- style.css: .proj-section-row / .proj-claudemd-btn / .proj-claudemd
Tests +5 (458): backend read present/absent; frontend content+Update,
empty+Generate, button opens `claude "/init"`. Verified: tsc clean, full suite
green, build OK, curl returns the content, in-browser shows the box + button.
This commit is contained in:
@@ -186,6 +186,18 @@
|
||||
- **验证**: `npx tsc -p tsconfig.web.json` 干净;`npx vitest run projects-panel` 51/51;真浏览器:web-terminal 卡 2 个 session 行 → 点 ✕ → 杀掉并刷新为 1 行。
|
||||
- **commit**: (本次提交)
|
||||
|
||||
### 2026-06-30 · v0.6 项目详情页 — 展示 CLAUDE.md + /init 生成/更新按钮
|
||||
|
||||
- **状态**: `[x]` DONE。**458 测试全绿**(+5)· 双 typecheck 干净 · `build:web` OK · 真端到端(curl + 真浏览器)验证通过。
|
||||
- **需求**(用户): 详情页展示 CLAUDE.md,加一个按钮自动更新 / 生成 CLAUDE.md。决策(AskUserQuestion): **展示 + 一个交互式 /init 按钮**(非静默后台覆盖,便于 review)。
|
||||
- **改动**:
|
||||
- 后端: `ProjectDetail` 加 `hasClaudeMd`/`claudeMd?`;`projects.ts` `readClaudeMd`(读 `<repo>/CLAUDE.md`,截断 64KB,best-effort)并入 `buildProjectDetail` 的 Promise.all。
|
||||
- 前端: `renderProjectDetail` 加 **CLAUDE.md 段**——标题行带按钮(有则 `↻ Update`、无则 `✨ Generate`),内容滚动框(`.proj-claudemd`,monospace,max-h 320);无则空态提示。按钮 → `onOpenProject(path, name, 'claude "/init"\\r')` 开交互式 Claude 跑 `/init`(复用 launcher,零新后端,byte-shuttle 不变)。style 加 `.proj-section-row`/`.proj-claudemd-btn`/`.proj-claudemd`。
|
||||
- 测试(+5): buildProjectDetail 读/无 CLAUDE.md;renderProjectDetail 有内容+Update、无内容+Generate+空态、按钮调 `onOpenProject(...,'claude "/init"\\r')`。
|
||||
- **验证**: `npx tsc`(双)干净;`npx vitest run` 458/458;`build:web` OK;curl `/projects/detail` 返回 `hasClaudeMd:true` + 13KB 内容;真浏览器:web-terminal 详情显示 CLAUDE.md 滚动框 + ↻ Update 按钮(段序 Branch/Active sessions/CLAUDE.md/Start)。
|
||||
- **注**: `/init` 经 `claude "/init"` 作为初始 prompt;若该形式不直接触发斜杠命令,会话是交互式的、用户可手动输 `/init`(一行可调)。
|
||||
- **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 + 真浏览器)验证通过。
|
||||
|
||||
@@ -464,6 +464,33 @@ export function renderProjectDetail(
|
||||
root.append(list)
|
||||
}
|
||||
|
||||
// CLAUDE.md — view + a smart Generate/Update button that runs /init interactively.
|
||||
const cmHead = el('div', 'proj-section-row')
|
||||
cmHead.append(el('div', 'proj-section-title', 'CLAUDE.md'))
|
||||
const cmBtn = el('button', 'proj-claudemd-btn', detail.hasClaudeMd ? '↻ Update' : '✨ Generate')
|
||||
cmBtn.title = detail.hasClaudeMd
|
||||
? 'Open a Claude session running /init to refresh CLAUDE.md'
|
||||
: 'Open a Claude session running /init to create CLAUDE.md'
|
||||
// Launch claude with /init as the initial prompt, in this repo (interactive,
|
||||
// so you review what it writes). Reuses the project launcher.
|
||||
cmBtn.addEventListener('click', () => hooks.onOpenProject(detail.path, detail.name, 'claude "/init"\r'))
|
||||
cmHead.append(cmBtn)
|
||||
root.append(cmHead)
|
||||
|
||||
if (detail.hasClaudeMd && detail.claudeMd !== undefined) {
|
||||
const pre = el('pre', 'proj-claudemd')
|
||||
pre.textContent = detail.claudeMd
|
||||
root.append(pre)
|
||||
} else {
|
||||
root.append(
|
||||
el(
|
||||
'div',
|
||||
'proj-empty',
|
||||
'No CLAUDE.md yet — generate one to give Claude project-specific instructions.',
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Launchers
|
||||
root.append(el('div', 'proj-section-title', 'Start'))
|
||||
root.append(makeLauncherRow(detail, hooks))
|
||||
|
||||
@@ -1278,6 +1278,49 @@ body {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Section header with an inline action (CLAUDE.md generate/update). */
|
||||
.proj-section-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 22px 0 10px;
|
||||
}
|
||||
.proj-section-row .proj-section-title {
|
||||
margin: 0;
|
||||
}
|
||||
.proj-claudemd-btn {
|
||||
flex: none;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--accent);
|
||||
border-radius: 7px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.proj-claudemd-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.proj-claudemd {
|
||||
margin: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* Worktrees */
|
||||
.proj-wt-list {
|
||||
display: flex;
|
||||
|
||||
@@ -361,6 +361,20 @@ export async function buildProjects(
|
||||
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
|
||||
}
|
||||
|
||||
const CLAUDE_MD_MAX_BYTES = 64 * 1024 // cap CLAUDE.md content sent for display
|
||||
|
||||
/** Read <repo>/CLAUDE.md (truncated); undefined when absent/unreadable. */
|
||||
async function readClaudeMd(projectPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const text = await fs.readFile(path.join(projectPath, 'CLAUDE.md'), 'utf8')
|
||||
return text.length > CLAUDE_MD_MAX_BYTES
|
||||
? text.slice(0, CLAUDE_MD_MAX_BYTES) + '\n\n…(truncated)'
|
||||
: text
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail for one project: branch + dirty + worktrees + its running sessions.
|
||||
* `projectPath` must be an absolute, existing directory (else null — 404). Not
|
||||
@@ -383,10 +397,11 @@ export async function buildProjectDetail(
|
||||
if (!stat.isDirectory()) return null
|
||||
|
||||
const isGit = await hasGitEntry(projectPath)
|
||||
const [branch, dirty, worktrees] = await Promise.all([
|
||||
const [branch, dirty, worktrees, claudeMd] = await Promise.all([
|
||||
isGit ? readBranch(projectPath) : Promise.resolve(undefined),
|
||||
isGit && cfg.projectDirtyCheck ? readDirty(projectPath) : Promise.resolve(undefined),
|
||||
isGit ? listWorktrees(projectPath) : Promise.resolve([]),
|
||||
readClaudeMd(projectPath),
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -397,5 +412,7 @@ export async function buildProjectDetail(
|
||||
dirty,
|
||||
worktrees,
|
||||
sessions: matchSessions(projectPath, liveSessions),
|
||||
hasClaudeMd: claudeMd !== undefined,
|
||||
claudeMd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,8 @@ export interface ProjectDetail {
|
||||
dirty?: boolean;
|
||||
worktrees: WorktreeInfo[]; // empty for a non-git dir
|
||||
sessions: ProjectSessionRef[]; // running sessions under this path (fresh)
|
||||
hasClaudeMd: boolean; // a CLAUDE.md exists at the project root
|
||||
claudeMd?: string; // its content (truncated for display) when present
|
||||
}
|
||||
|
||||
export interface SessionManager {
|
||||
|
||||
@@ -484,4 +484,28 @@ describe('renderProjectDetail', () => {
|
||||
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
|
||||
expect(root.textContent).toContain('Not a git repository')
|
||||
})
|
||||
|
||||
it('shows CLAUDE.md content + an Update button when present', () => {
|
||||
const root = renderProjectDetail(detail({ hasClaudeMd: true, claudeMd: '# Rules\nbe nice' }), hooks(), cbs())
|
||||
expect(root.querySelector('.proj-claudemd')?.textContent).toContain('be nice')
|
||||
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Update')
|
||||
})
|
||||
|
||||
it('shows a Generate button + empty state when CLAUDE.md is absent', () => {
|
||||
const root = renderProjectDetail(detail({ hasClaudeMd: false }), hooks(), cbs())
|
||||
expect(root.querySelector('.proj-claudemd')).toBeNull()
|
||||
expect(root.textContent).toContain('No CLAUDE.md yet')
|
||||
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Generate')
|
||||
})
|
||||
|
||||
it('the CLAUDE.md button opens a Claude /init session in the repo', () => {
|
||||
const h = hooks()
|
||||
const root = renderProjectDetail(
|
||||
detail({ name: 'r', path: '/p/r', hasClaudeMd: true, claudeMd: 'x' }),
|
||||
h,
|
||||
cbs(),
|
||||
)
|
||||
;(root.querySelector('.proj-claudemd-btn') as HTMLButtonElement).click()
|
||||
expect(h.onOpenProject).toHaveBeenCalledWith('/p/r', 'r', 'claude "/init"\r')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -398,6 +398,23 @@ describe('buildProjectDetail', () => {
|
||||
expect(d!.sessions.map((s) => s.id).sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('includes CLAUDE.md content when present', async () => {
|
||||
const dir = path.join(tmp, 'withmd')
|
||||
await fs.mkdir(dir)
|
||||
await fs.writeFile(path.join(dir, 'CLAUDE.md'), '# Project\nbe excellent')
|
||||
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
||||
expect(d!.hasClaudeMd).toBe(true)
|
||||
expect(d!.claudeMd).toContain('be excellent')
|
||||
})
|
||||
|
||||
it('reports no CLAUDE.md when absent', async () => {
|
||||
const dir = path.join(tmp, 'nomd')
|
||||
await fs.mkdir(dir)
|
||||
const d = await buildProjectDetail(makeCfg({}), dir, [])
|
||||
expect(d!.hasClaudeMd).toBe(false)
|
||||
expect(d!.claudeMd).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lists worktrees for a real git repo (gated on git)', async () => {
|
||||
if (!(await gitAvailable())) return
|
||||
await fs.mkdir(path.join(tmp, 'realrepo'))
|
||||
|
||||
Reference in New Issue
Block a user