From 88b960bac5c62cdf20368b025ee34aa492e8149e Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 30 Jun 2026 14:51:42 +0200 Subject: [PATCH] feat(v0.6): show CLAUDE.md on the project detail page + /init generate/update button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page now reads /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. --- docs/PROGRESS_LOG.md | 12 +++++++++++ public/projects.ts | 27 +++++++++++++++++++++++ public/style.css | 43 +++++++++++++++++++++++++++++++++++++ src/http/projects.ts | 19 +++++++++++++++- src/types.ts | 2 ++ test/projects-panel.test.ts | 24 +++++++++++++++++++++ test/projects.test.ts | 17 +++++++++++++++ 7 files changed, 143 insertions(+), 1 deletion(-) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index cf6382d..63a92a4 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -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`(读 `/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 + 真浏览器)验证通过。 diff --git a/public/projects.ts b/public/projects.ts index 358c3bf..eae6e3f 100644 --- a/public/projects.ts +++ b/public/projects.ts @@ -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)) diff --git a/public/style.css b/public/style.css index 661993d..9c2fd65 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/src/http/projects.ts b/src/http/projects.ts index 53ab01d..497bd0c 100644 --- a/src/http/projects.ts +++ b/src/http/projects.ts @@ -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 /CLAUDE.md (truncated); undefined when absent/unreadable. */ +async function readClaudeMd(projectPath: string): Promise { + 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, } } diff --git a/src/types.ts b/src/types.ts index 0e4cd86..5baa540 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index a79791e..7827290 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -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') + }) }) diff --git a/test/projects.test.ts b/test/projects.test.ts index fcf1113..427736b 100644 --- a/test/projects.test.ts +++ b/test/projects.test.ts @@ -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'))