diff --git a/.agent/skills/.version b/.agent/skills/.version index 8efa1a3..6cf23ee 100644 --- a/.agent/skills/.version +++ b/.agent/skills/.version @@ -1,4 +1,4 @@ { "cliVersion": "dev", - "syncedAt": "2026-03-19T09:17:14Z" + "syncedAt": "2026-03-24T09:06:19Z" } diff --git a/.air.toml b/.air.toml index ffb5690..dd7264a 100644 --- a/.air.toml +++ b/.air.toml @@ -2,8 +2,8 @@ root = "." tmp_dir = "tmp" [build] -cmd = "go build -o ./tmp/knowns ./cmd/knowns" -entrypoint = ["./tmp/knowns", "browser", "--dev", "--port", "6420", "--no-open"] +cmd = "go build -o ./tmp/knowns ./cmd/knowns && lsof -ti:6420 | xargs kill -9 2>/dev/null || true" +entrypoint = ["./tmp/knowns", "browser", "--restart", "--dev", "--port", "6420", "--no-open"] include_ext = ["go"] exclude_dir = ["tmp", "bin", "ui", "node_modules", ".git", ".knowns"] delay = 200 diff --git a/.claude/skills/.version b/.claude/skills/.version index 8efa1a3..6cf23ee 100644 --- a/.claude/skills/.version +++ b/.claude/skills/.version @@ -1,4 +1,4 @@ { "cliVersion": "dev", - "syncedAt": "2026-03-19T09:17:14Z" + "syncedAt": "2026-03-24T09:06:19Z" } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7e57f90..137179c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,9 +1,11 @@ -# knowns - GitHub Copilot Instructions +# knowns-go - GitHub Copilot Instructions Compatibility entrypoint for runtimes that auto-detect `.github/copilot-instructions.md`. +**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.** + ## Canonical Guidance - Knowns is the repository memory layer for humans and the AI-friendly working layer for agents. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b883ef..a1338a8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -413,6 +413,28 @@ jobs: rm "$base" done + - name: Verify tarball contents + run: | + for archive in \ + knowns-darwin-arm64.tar.gz \ + knowns-darwin-x64.tar.gz \ + knowns-linux-arm64.tar.gz \ + knowns-linux-x64.tar.gz; do + tar -tzf "$archive" | grep -Fx "knowns" >/dev/null || { + echo "Expected $archive to contain 'knowns'" + exit 1 + } + done + + for archive in \ + knowns-win-arm64.tar.gz \ + knowns-win-x64.tar.gz; do + tar -tzf "$archive" | grep -Fx "knowns.exe" >/dev/null || { + echo "Expected $archive to contain 'knowns.exe'" + exit 1 + } + done + - name: Ensure GitHub release exists if: github.event_name == 'release' env: @@ -477,11 +499,11 @@ jobs: def install if OS.mac? && Hardware::CPU.arm? - bin.install "knowns-darwin-arm64" => "knowns" + bin.install "knowns" => "knowns" elsif OS.mac? && Hardware::CPU.intel? - bin.install "knowns-darwin-x64" => "knowns" + bin.install "knowns" => "knowns" elsif OS.linux? && Hardware::CPU.intel? - bin.install "knowns-linux-x64" => "knowns" + bin.install "knowns" => "knowns" end bin.install_symlink "knowns" => "kn" end diff --git a/.kiro/skills/.version b/.kiro/skills/.version new file mode 100644 index 0000000..6cf23ee --- /dev/null +++ b/.kiro/skills/.version @@ -0,0 +1,4 @@ +{ + "cliVersion": "dev", + "syncedAt": "2026-03-24T09:06:19Z" +} diff --git a/.kiro/skills/kn-commit/SKILL.md b/.kiro/skills/kn-commit/SKILL.md new file mode 100644 index 0000000..59421c5 --- /dev/null +++ b/.kiro/skills/kn-commit/SKILL.md @@ -0,0 +1,114 @@ +--- +name: kn-commit +description: Use when committing code changes with proper conventional commit format and verification +--- + +# Committing Changes + +**Announce:** "Using kn-commit to commit changes." + +**Core principle:** VERIFY BEFORE COMMITTING - check staged changes, ask for confirmation. + +## Inputs + +- Current staged changes +- Relevant task IDs, scope, and reason for the change + +## Preflight + +- Confirm the correct files are staged +- Check whether the commit should reference a task or feature area +- Refuse to commit if the staged diff looks unrelated or mixed across multiple concerns + +## Step 1: Review Staged Changes + +```bash +git status +git diff --staged +``` + +## Step 2: Generate Commit Message + +**Format:** +``` +(): + +- Bullet point summarizing change +``` + +**Types:** feat, fix, docs, style, refactor, perf, test, chore + +**Rules:** +- Title lowercase, no period, max 50 chars +- Body explains *why*, not just *what* + +## Step 3: Ask for Confirmation + +``` +Ready to commit: + +feat(auth): add JWT token refresh + +- Added refresh token endpoint + +Proceed? (yes/no/edit) +``` + +**Wait for user approval.** + +## Step 4: Commit + +```bash +git commit -m "feat(auth): add JWT token refresh + +- Added refresh token endpoint" +``` + +## Final Response Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state whether a commit was proposed, blocked, or created. +2. Key details - include the proposed or final commit message, relevant diff concerns, and approval status. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Skill-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-commit`, the key details should cover: + +- the proposed commit title +- 1 short body explaining why +- any concerns about the staged diff +- a clear approval prompt + +## Guidelines + +- Only commit staged files +- NO "Co-Authored-By" lines +- NO "Generated with Claude Code" ads +- Ask before committing + +## Next Step Suggestion + +When a follow-up is natural, recommend exactly one next command: + +- after proposing a commit: no command, wait for approval +- after a successful commit tied to active work: `/kn-verify` +- after a successful standalone commit: `/kn-extract` or the next task-specific workflow command if one is obvious + +## Checklist + +- [ ] Reviewed staged changes +- [ ] Message follows convention +- [ ] User approved +- [ ] Next action suggested when applicable + +## Abort Conditions + +- Nothing staged +- Staged diff includes unrelated work that should be split +- User has not explicitly approved the final message diff --git a/.kiro/skills/kn-doc/SKILL.md b/.kiro/skills/kn-doc/SKILL.md new file mode 100644 index 0000000..04f3bb8 --- /dev/null +++ b/.kiro/skills/kn-doc/SKILL.md @@ -0,0 +1,164 @@ +--- +name: kn-doc +description: Use when working with Knowns documentation - viewing, searching, creating, or updating docs +--- + +# Working with Documentation + +**Announce:** "Using kn-doc to work with documentation." + +**Core principle:** SEARCH BEFORE CREATING - avoid duplicates. + +## Inputs + +- Doc path, topic, folder, or task/spec reference +- Whether this is a create, update, or search request + +## Preflight + +- Search before creating +- Prefer section edits for targeted changes +- Preserve doc structure and metadata unless the user asked for a restructure +- Validate refs after doc changes + +## Quick Reference + +```json +// List docs +mcp__knowns__list_docs({}) + +// View doc (smart mode) +mcp__knowns__get_doc({ "path": "", "smart": true }) + +// Search docs +mcp__knowns__search({ "query": "", "type": "doc" }) + +// Create doc (MUST include description) +mcp__knowns__create_doc({ + "title": "", + "description": "<brief description of what this doc covers>", + "tags": ["tag1", "tag2"], + "folder": "folder" +}) + +// Update content +mcp__knowns__update_doc({ + "path": "<path>", + "content": "content" +}) + +// Update metadata (title, description, tags) +mcp__knowns__update_doc({ + "path": "<path>", + "title": "New Title", + "description": "Updated description", + "tags": ["new", "tags"] +}) + +// Update section only +mcp__knowns__update_doc({ + "path": "<path>", + "section": "2", + "content": "## 2. New Content\n\n..." +}) +``` + +## Creating Documents + +1. Search first (avoid duplicates) +2. Choose location: + +| Type | Folder | +|------|--------| +| Core | (root) | +| Guide | `guides` | +| Pattern | `patterns` | +| API | `api` | + +3. Create with **title + description + tags** +4. Add content +5. **Validate** after creating + +**CRITICAL:** Always include `description` - validate will fail without it! + +## Updating Documents + +**Section edit is most efficient:** +```json +mcp__knowns__update_doc({ + "path": "<path>", + "section": "3", + "content": "## 3. New Content\n\n..." +}) +``` + +## Validate After Changes + +**CRITICAL:** After creating/updating docs, validate: + +```json +// Validate specific doc (saves tokens) +mcp__knowns__validate({ "entity": "<doc-path>" }) + +// Or validate all docs +mcp__knowns__validate({ "scope": "docs" }) +``` + +If errors found, fix before continuing. + +## Shared Output Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what doc was created, updated, inspected, or ruled out. +2. Key details - include the most important supporting context, refs, path, warnings, or validation. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Documentation-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-doc`, the key details should cover: + +- whether the doc was created, updated, or only inspected +- the canonical doc path +- any important refs added or fixed +- validation result + +When doc work naturally leads to another action, include the best next command. If the request ends with inspection or a fully validated update, do not force a handoff. + +## Mermaid Diagrams + +WebUI supports mermaid rendering. Use for: +- Architecture diagrams +- Flowcharts +- Sequence diagrams +- Entity relationships + +````markdown +```mermaid +graph TD + A[Start] --> B{Decision} + B -->|Yes| C[Action] + B -->|No| D[End] +``` +```` + +Diagrams render automatically in WebUI preview. + +## Checklist + +- [ ] Searched for existing docs +- [ ] Created with **description** (required!) +- [ ] Used section editing for updates +- [ ] Used mermaid for complex flows (optional) +- [ ] Referenced with `@doc/<path>` +- [ ] **Validated after changes** + +## Red Flags + +- Creating near-duplicate docs instead of updating an existing one +- Replacing a full doc when only one section needed a change +- Leaving broken refs after an edit diff --git a/.kiro/skills/kn-extract/SKILL.md b/.kiro/skills/kn-extract/SKILL.md new file mode 100644 index 0000000..b6af717 --- /dev/null +++ b/.kiro/skills/kn-extract/SKILL.md @@ -0,0 +1,135 @@ +--- +name: kn-extract +description: Use when extracting reusable patterns, solutions, or knowledge into documentation +--- + +# Extracting Knowledge + +**Announce:** "Using kn-extract to extract knowledge." + +**Core principle:** ONLY EXTRACT GENERALIZABLE KNOWLEDGE. + +## Inputs + +- Usually a completed task ID +- Sometimes a code change, repeated pattern, or recurring support issue + +## Extraction Rules + +- Extract patterns, not one-off hacks +- Prefer updating an existing doc over creating a duplicate +- Link the extracted knowledge back to the source task or source doc +- Only create a template if the pattern is genuinely reusable for generation + +## Step 1: Identify Source + +```json +mcp__knowns__get_task({ "taskId": "$ARGUMENTS" }) +``` + +Look for: patterns, problems solved, decisions made, lessons learned. + +## Step 2: Search for Existing Docs + +```json +mcp__knowns__search({ "query": "<pattern/topic>", "type": "doc" }) +``` + +**Don't duplicate.** Update existing docs when possible. + +## Step 3: Create Documentation + +```json +mcp__knowns__create_doc({ + "title": "Pattern: <Name>", + "description": "Reusable pattern for <purpose>", + "tags": ["pattern", "<domain>"], + "folder": "patterns" +}) + +mcp__knowns__update_doc({ + "path": "patterns/<name>", + "content": "# Pattern: <Name>\n\n## Problem\n...\n\n## Solution\n...\n\n## Example\n```typescript\n// Code\n```\n\n## Source\n@task-<id>" +}) +``` + +## Step 4: Create Template (if code-generatable) + +```json +mcp__knowns__create_template({ + "name": "<pattern-name>", + "description": "Generate <what>", + "doc": "patterns/<pattern-name>" +}) +``` + +Link template in doc: +```json +mcp__knowns__update_doc({ + "path": "patterns/<name>", + "appendContent": "\n\n## Generate\n\nUse @template/<pattern-name>" +}) +``` + +## Step 5: Validate + +**CRITICAL:** After creating doc/template, validate to catch broken refs: + +```json +mcp__knowns__validate({ "entity": "patterns/<name>" }) +``` + +If errors found, fix before continuing. + +## Step 6: Link Back to Task + +```json +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "appendNotes": "๐Ÿ“š Extracted to @doc/patterns/<name>" +}) +``` + +## Shared Output Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what knowledge was extracted, updated, or intentionally not extracted. +2. Key details - include the most important supporting context, refs, canonical location, warnings, or validation. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Extraction-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-extract`, the key details should cover: + +- what knowledge was extracted +- whether a doc was created or updated +- whether a template was created +- where the canonical knowledge now lives + +When the extraction leads to a clear follow-up, include the best next command. If the correct outcome is a no-op or a completed doc update with no obvious continuation, stop after the result and key details. + +## No-Op Case + +If the work is too specific to generalize, say so explicitly and do not force a new doc. + +## What to Extract + +| Source | Extract As | Template? | +|--------|------------|-----------| +| Code pattern | Pattern doc | โœ… Yes | +| API pattern | Integration guide | โœ… Yes | +| Error solution | Troubleshooting | โŒ No | +| Security approach | Guidelines | โŒ No | + +## Checklist + +- [ ] Knowledge is generalizable +- [ ] Includes working example +- [ ] Links back to source +- [ ] Template created (if applicable) +- [ ] **Validated (no broken refs)** diff --git a/.kiro/skills/kn-implement/SKILL.md b/.kiro/skills/kn-implement/SKILL.md new file mode 100644 index 0000000..d635569 --- /dev/null +++ b/.kiro/skills/kn-implement/SKILL.md @@ -0,0 +1,248 @@ +--- +name: kn-implement +description: Use when implementing a task - follow the plan, check ACs, track progress +--- + +# Implementing a Task + +Execute the implementation plan, track progress, and complete the task. + +**Announce:** "Using kn-implement for task [ID]." + +**Core principle:** CHECK AC ONLY AFTER WORK IS DONE. + +## Inputs + +- Task ID +- Existing implementation plan +- Linked spec, docs, templates, and referenced tasks + +## Preflight + +- Confirm a plan exists; if not, redirect to `/kn-plan <id>` first unless user explicitly overrides +- Read task notes and pending ACs before changing code +- Identify whether the task is standalone or linked to a spec +- Decide what verification is required: tests, lint, build, validation, manual checks + +## Step 1: Review Task + +```json +mcp__knowns__get_task({ "taskId": "$ARGUMENTS" }) +``` + +**If task status is "done"** (reopening): +```json +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "status": "in-progress", + "appendNotes": "Reopened: <reason>" +}) +mcp__knowns__start_time({ "taskId": "$ARGUMENTS" }) +``` + +Verify: plan exists, timer running, which ACs pending. + +## Step 2: Check Templates + +```json +mcp__knowns__list_templates({}) +``` + +If template exists โ†’ use it to generate boilerplate. + +## Step 3: Work Through Plan + +For each step: +1. Do the work +2. Check AC (only after done!) +3. Append note + +```json +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "checkAc": [1], + "appendNotes": "Done: brief description" +}) +``` + +Working rules: + +- Append compact progress notes at meaningful checkpoints, not after every tiny edit +- If a step reveals missing context, pause implementation and gather it before continuing +- If the task needs docs or template changes, do them as part of completion, not as an afterthought + +## Step 4: Handle Scope Changes + +**Small:** Add AC + note +```json +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "addAc": ["New requirement"], + "appendNotes": "Scope: added per user" +}) +``` + +**Large:** Stop and ask user. + +## Step 5: Validate & Complete + +1. Run tests/lint/build +2. **Validate task** to catch broken refs (uses entity filter to save tokens): + +```json +mcp__knowns__validate({ "entity": "$ARGUMENTS" }) +``` + +3. Add implementation notes (use `appendNotes`, NOT `notes`!) +4. Stop timer + mark done + +```json +mcp__knowns__stop_time({ "taskId": "$ARGUMENTS" }) +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "status": "done" +}) +``` + +**Note:** When task is marked done (or AC is checked), matching ACs in the linked spec document are automatically checked. No manual spec update needed. + +## Step 5.5: SDD Workflow (if task has spec) + +**Check if task has `spec` field.** If yes, run SDD workflow: + +### 1. Get Sibling Tasks + +```json +mcp__knowns__list_tasks({ "spec": "<spec-path-from-task>" }) +``` + +### 2. Analyze Status + +Count tasks by status: +- `done`: completed tasks +- `todo` / `in-progress`: pending tasks + +### 3. Branch Based on Results + +**If pending tasks exist:** +``` +โœ“ Task done! This task is part of spec: specs/xxx + +Remaining tasks (Y of Z): +- task-YY: Title (todo) +- task-ZZ: Title (in-progress) + +Next: /kn-plan <first-todo-id> +``` + +**If this is the LAST task (all others done):** +``` +โœ“ Task done! All tasks for specs/xxx complete! + +Running SDD verification... +``` + +Then auto-run: +```json +mcp__knowns__validate({ "scope": "sdd" }) +``` + +Display SDD Coverage Report: +``` +SDD Coverage Report +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +Spec: specs/xxx +Tasks: X/X complete (100%) +ACs: Y/Z verified + +โœ… Spec fully implemented! +``` + +## Step 6: Extract Knowledge (optional) + +If patterns discovered: `/kn-extract` + +## Final Response Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what was implemented, confirmed, or what remains blocked. +2. Key details - include the most important supporting context, verification, refs, or spec status. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Skill-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-implement`, the key details should cover: + +- whether the task is done or what remains +- tests, validation, lint, or build status +- any spec-related follow-up or remaining sibling-task context + +--- + +## CRITICAL: Next Step Suggestion + +**You MUST suggest the next action when a natural follow-up exists. User won't know what to do next.** + +After task completion, check for: + +1. **More tasks from same spec?** + ```json + mcp__knowns__list_tasks({ "spec": "<spec-path>", "status": "todo" }) + ``` + +2. **Suggest based on context:** + +| Situation | Suggest | +|-----------|---------| +| More tasks in spec | "Next: `/kn-plan <next-task-id>` for [task title]" | +| All spec tasks done | "All tasks complete! Run `/kn-verify` to verify against spec" | +| Standalone task | "Task done. Run `/kn-extract` to extract patterns, or `/kn-commit` to commit" | +| Patterns discovered | "Consider `/kn-extract` to document this pattern" | + +**Example output:** +``` +โœ“ Task #43 complete! + +Next task from @doc/specs/user-auth: +โ†’ Task #44: Add refresh token rotation + +Run: /kn-plan 44 +``` + +--- + +## Related Skills + +- `/kn-plan <id>` - Create plan before implementing +- `/kn-verify` - Verify all tasks against spec +- `/kn-extract` - Extract patterns to docs +- `/kn-commit` - Commit with verification + +## Checklist + +- [ ] All ACs checked +- [ ] Tests pass +- [ ] **Validated (no broken refs)** +- [ ] Notes added +- [ ] Timer stopped +- [ ] Status = done +- [ ] **SDD workflow handled (if spec linked)** +- [ ] **Next step suggested** + +## Red Flags + +- Checking AC before work done +- Skipping tests +- Skipping validation +- Using `notes` instead of `appendNotes` +- Marking done without verification +- **Not checking sibling tasks when spec linked** +- **Not running SDD verify when spec complete** +- **Not suggesting next step** +- Implementing from a vague task without clarifying plan/context +- Silently expanding scope instead of asking diff --git a/.kiro/skills/kn-init/SKILL.md b/.kiro/skills/kn-init/SKILL.md new file mode 100644 index 0000000..29ea601 --- /dev/null +++ b/.kiro/skills/kn-init/SKILL.md @@ -0,0 +1,88 @@ +--- +name: kn-init +description: Use at the start of a new session to read project docs, understand context, and see current state +--- + +# Session Initialization + +**Announce:** "Using kn-init to initialize session." + +**Core principle:** READ DOCS BEFORE DOING ANYTHING ELSE. + +## Inputs + +- Optional user focus such as a task ID, feature area, bug, or question +- Current project root already opened in the agent session + +## Preflight + +- Confirm this is a Knowns project +- Prefer project docs over guessing from code structure +- If `README`, `ARCHITECTURE`, or `CONVENTIONS` do not exist, choose the closest equivalents from the docs list +- If a doc is large, read its TOC first and only open the relevant sections + +## Step 1: List Docs + +```json +mcp__knowns__list_docs({}) +``` + +## Step 2: Read Core Docs + +```json +mcp__knowns__get_doc({ "path": "README", "smart": true }) +mcp__knowns__get_doc({ "path": "ARCHITECTURE", "smart": true }) +mcp__knowns__get_doc({ "path": "CONVENTIONS", "smart": true }) +``` + +## Step 3: Check Current State + +```json +mcp__knowns__list_tasks({ "status": "in-progress" }) +mcp__knowns__get_board({}) +``` + +## Step 4: Summarize + +```markdown +## Session Context +- **Project**: [name] +- **Key Docs**: README, ARCHITECTURE, CONVENTIONS +- **In-progress tasks**: [count] +- **Current risks / gaps**: [missing docs, unclear conventions, broken search, etc.] +- **Ready for**: tasks, docs, questions +``` + +## Final Response Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what session context was established or what was confirmed. +2. Key details - include only the most important supporting context, refs, risks, or current-state notes. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Skill-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-init`, the key details should cover: + +- 1 short paragraph or bullet list summarizing project purpose and architecture +- 1 short list of the most relevant docs opened +- current in-progress work, if any +- current risks or missing context, if any + +## Fallbacks + +- If task search/list is unavailable, state that clearly and continue with docs + codebase context +- If core docs are missing, say which docs were not found and which substitutes were used +- Do not invent project conventions that were not found in docs or code + +When a follow-up is natural, recommend exactly one next command such as: + +``` +/kn-plan <task-id> +/kn-research <query> +``` diff --git a/.kiro/skills/kn-plan/SKILL.md b/.kiro/skills/kn-plan/SKILL.md new file mode 100644 index 0000000..0ec87b5 --- /dev/null +++ b/.kiro/skills/kn-plan/SKILL.md @@ -0,0 +1,301 @@ +--- +name: kn-plan +description: Use when creating an implementation plan for a task +--- + +# Planning a Task + +**Announce:** "Using kn-plan for task [ID]." + +**Core principle:** GATHER CONTEXT โ†’ PLAN โ†’ VALIDATE โ†’ WAIT FOR APPROVAL. + +## Inputs + +- Task ID, or `--from @doc/specs/<name>` for SDD task generation +- Existing task refs, spec refs, template refs, and user constraints + +## Preflight + +- Read the task or spec first +- Follow every explicit `@task-`, `@doc/`, and `@template/` ref before finalizing the plan +- Search for adjacent docs/tasks only after reading the primary source +- Do not write a plan that assumes undocumented architecture decisions + +## Mode Detection + +Check if `$ARGUMENTS` contains `--from`: +- **Yes** โ†’ Go to "Generate Tasks from Spec" section +- **No** โ†’ Continue with normal planning flow + +--- + +# Normal Planning Flow + +## Step 1: Take Ownership + +```json +mcp__knowns__get_task({ "taskId": "$ARGUMENTS" }) +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "status": "in-progress", + "assignee": "@me" +}) +mcp__knowns__start_time({ "taskId": "$ARGUMENTS" }) +``` + +## Step 2: Gather Context + +Follow refs in task: +```json +mcp__knowns__get_doc({ "path": "<path>", "smart": true }) +mcp__knowns__get_task({ "taskId": "<id>" }) +``` + +Search related: +```json +mcp__knowns__search({ "query": "<keywords>", "type": "doc" }) +mcp__knowns__list_templates({}) +``` + +## Step 3: Draft Plan + +```markdown +## Implementation Plan +1. [Step] (see @doc/relevant-doc) +2. [Step] (use @template/xxx) +3. Add tests +4. Update docs +``` + +**Tip:** Use mermaid for complex flows: +````markdown +```mermaid +graph LR + A[Input] --> B[Process] --> C[Output] +``` +```` + +Plan quality rules: + +- Steps should be outcome-oriented, not a dump of implementation details +- Mention concrete files, docs, or templates when known +- Include testing and validation explicitly +- Keep the plan short enough for approval, but specific enough to execute without re-discovery +- If supporting knowledge is too large, move it into a doc and reference it rather than bloating the plan + +## Step 4: Save Plan + +```json +mcp__knowns__update_task({ + "taskId": "$ARGUMENTS", + "plan": "1. Step one\n2. Step two\n3. Tests" +}) +``` + +## Step 5: Validate + +**CRITICAL:** After saving plan with refs, validate to catch broken refs: + +```json +mcp__knowns__validate({ "entity": "$ARGUMENTS" }) +``` + +If errors found (broken `@doc/...` or `@task-...`), fix before asking approval. + +## Step 6: Ask Approval + +Present plan and **WAIT for explicit approval**. + +## Final Response Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what plan or task preview was produced and whether approval is pending. +2. Key details - include the most important supporting context, refs, assumptions, or validation. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Skill-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-plan`, the key details should cover: + +- the concise implementation plan +- key assumptions or unresolved questions +- references used to derive the plan +- an explicit approval gate or validation result + +--- + +## CRITICAL: Next Step Suggestion + +**You MUST suggest the next action when a natural follow-up exists. User won't know what to do next.** + +After user approves the plan: + +``` +Plan approved! Ready to implement. + +Run: /kn-implement $ARGUMENTS +``` + +**If user wants to review first:** +``` +Take your time to review. When ready: + +Run: /kn-implement $ARGUMENTS +``` + +--- + +## Related Skills + +- `/kn-research` - Research before planning +- `/kn-implement <id>` - Implement after plan approved +- `/kn-spec` - Create spec for complex features + +## Checklist + +- [ ] Ownership taken +- [ ] Timer started +- [ ] Refs followed +- [ ] Templates checked +- [ ] **Validated (no broken refs)** +- [ ] User approved +- [ ] **Next step suggested** + +## Failure Modes + +- Missing task/spec -> stop and report the missing ID/path +- Broken refs -> fix or replace them before asking approval +- Scope too large for one task -> recommend splitting instead of hiding complexity inside one plan + +--- + +# Generate Tasks from Spec + +When `$ARGUMENTS` contains `--from @doc/specs/<name>`: + +**Announce:** "Using kn-plan to generate tasks from spec [name]." + +## Step 1: Read Spec Document + +Extract spec path from arguments (e.g., `--from @doc/specs/user-auth` โ†’ `specs/user-auth`). + +```json +mcp__knowns__get_doc({ "path": "specs/<name>", "smart": true }) +``` + +## Step 2: Parse Requirements + +Scan spec for: +- **Functional Requirements** (FR-1, FR-2, etc.) +- **Acceptance Criteria** (AC-1, AC-2, etc.) +- **Scenarios** (for edge cases) + +Group related items into logical tasks. + +## Step 3: Generate Task Preview + +For each requirement/group, create task structure: + +```markdown +## Generated Tasks from specs/<name> + +### Task 1: [Requirement Title] +- **Description:** [From spec] +- **ACs:** + - [ ] AC from spec + - [ ] AC from spec +- **Spec:** specs/<name> +- **Fulfills:** AC-1, AC-2 (maps to Spec ACs this task completes) +- **Priority:** medium + +### Task 2: [Requirement Title] +- **Description:** [From spec] +- **ACs:** + - [ ] AC from spec +- **Spec:** specs/<name> +- **Fulfills:** AC-3 +- **Priority:** medium + +--- +Total: X tasks to create +``` + +> **CRITICAL:** The `fulfills` field maps Task โ†’ Spec ACs. When the task is marked done, +> the matching Spec ACs will be auto-checked in the spec document. + +## Step 4: Ask for Approval + +> I've generated **X tasks** from the spec. Please review: +> - **Approve** to create all tasks +> - **Edit** to modify before creating +> - **Cancel** to abort + +**WAIT for explicit approval.** + +## Step 5: Create Tasks + +When approved, create tasks with `fulfills` to link Task โ†’ Spec ACs: + +```json +mcp__knowns__create_task({ + "title": "<requirement title>", + "description": "<from spec>", + "spec": "specs/<name>", + "fulfills": ["AC-1", "AC-2"], + "priority": "medium", + "labels": ["from-spec"] +}) +``` + +Then add implementation ACs (task-level criteria, different from spec ACs): +```json +mcp__knowns__update_task({ + "taskId": "<new-id>", + "addAc": ["Implementation step 1", "Implementation step 2", "Tests added"] +}) +``` + +> **Key Concept:** +> - `fulfills`: Which **Spec ACs** (AC-1, AC-2, etc.) this task satisfies +> - `addAc`: **Implementation ACs** - specific steps to complete the task +> +> When task status โ†’ "done", the `fulfills` ACs are auto-checked in the spec document. + +Repeat for each task. + +Creation rules: + +- Group requirements into tasks that can be reviewed and completed independently +- Keep task ACs implementation-oriented, while `fulfills` stays mapped to spec AC IDs +- Reuse existing tasks if the spec overlaps current in-progress work; do not silently duplicate scope +- If the spec depends on broad domain knowledge, create/update a supporting doc and reference it from the spec or generated tasks +- If the spec reveals general platform work, create a dedicated task and reference it instead of hiding it inside an unrelated feature task + +## Step 6: Summary + +```markdown +Goal/result: created X tasks linked to `specs/<name>`. + +Key details: +- task-xxx: Requirement 1 (3 ACs) +- task-yyy: Requirement 2 (2 ACs) +- validation/approval status, if relevant + +Next action: +- `/kn-plan <first-task-id>` +``` + +## Checklist (--from mode) + +- [ ] Spec document read +- [ ] Requirements parsed +- [ ] **Tasks include `fulfills` mapping to Spec ACs** +- [ ] Tasks previewed +- [ ] User approved +- [ ] Tasks created with spec link and fulfills +- [ ] Summary shown diff --git a/.kiro/skills/kn-research/SKILL.md b/.kiro/skills/kn-research/SKILL.md new file mode 100644 index 0000000..a09ffe4 --- /dev/null +++ b/.kiro/skills/kn-research/SKILL.md @@ -0,0 +1,117 @@ +--- +name: kn-research +description: Use when you need to understand existing code, find patterns, or explore the codebase before implementation +--- + +# Researching the Codebase + +**Announce:** "Using kn-research for [topic]." + +**Core principle:** UNDERSTAND WHAT EXISTS BEFORE ADDING NEW CODE. + +## Inputs + +- Topic, feature, API, error, file pattern, or task ID +- Any suspected file paths, package names, or existing references + +## Search Order + +1. Project docs +2. Completed or related tasks +3. Existing code paths and implementations +4. Adjacent tests, templates, and validation logic + +## Step 1: Search Documentation + +```json +mcp__knowns__search({ "query": "<topic>", "type": "doc" }) +mcp__knowns__get_doc({ "path": "<path>", "smart": true }) +``` + +## Step 2: Search Completed Tasks + +```json +mcp__knowns__search({ "query": "<keywords>", "type": "task" }) +mcp__knowns__get_task({ "taskId": "<id>" }) +``` + +## Step 3: Search Codebase + +```bash +find . -name "*<pattern>*" -type f | grep -v node_modules | head -20 +grep -r "<pattern>" --include="*.ts" -l | head -20 +``` + +## Step 4: Document Findings + +```markdown +## Research: [Topic] + +### Existing Implementations +- `src/path/file.ts`: Does X + +### Patterns Found +- Pattern 1: Used for... + +### Related Docs +- @doc/path1 - Covers X + +### Recommendations +1. Reuse X from Y +2. Follow pattern Z +``` + +## Shared Output Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what was researched, clarified, or ruled out. +2. Key details - include the most important supporting context, refs, constraints, gaps, or warnings. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Research-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-research`, the key details should cover: + +- concrete files or docs found +- what is reusable vs what is missing +- architecture or convention constraints discovered + +## Knowledge Spillover Rule + +If the research surface becomes too large for one response or one task: + +- create or update a Knowns doc for the reusable/domain knowledge +- reference that doc from the current task or plan with `@doc/<path>` +- keep the research summary short and point to the canonical doc instead of repeating everything inline + +If the research uncovers a broad follow-up topic that should be tracked independently: + +- create a task for that general knowledge or follow-up work +- reference it with `@task-<id>` from the current context +- do not silently expand the original task with unrelated background work + +## Fallbacks + +- If search is noisy, narrow by file type, feature folder, or known reference IDs +- If no existing pattern is found, state that explicitly rather than implying one exists +- If docs and code disagree, call out the mismatch + +## Checklist + +- [ ] Searched documentation +- [ ] Reviewed similar completed tasks +- [ ] Found existing code patterns +- [ ] Identified reusable components + +## Next Step Suggestion + +Only suggest a next command when the findings clearly lead somewhere: + +- research for an active task -> `/kn-plan <task-id>` +- research uncovered reusable knowledge -> `/kn-extract <task-id>` if the source task is complete +- no clear handoff -> stop after the findings without forcing a next command diff --git a/.kiro/skills/kn-spec/SKILL.md b/.kiro/skills/kn-spec/SKILL.md new file mode 100644 index 0000000..ed208c7 --- /dev/null +++ b/.kiro/skills/kn-spec/SKILL.md @@ -0,0 +1,219 @@ +--- +name: kn-spec +description: Use when creating a specification document for a feature (SDD workflow) +--- + +# Creating a Spec Document + +Create a specification document for a feature using SDD (Spec-Driven Development). + +**Announce:** "Using kn-spec to create spec for [name]." + +**Core principle:** SPEC FIRST โ†’ REVIEW โ†’ APPROVE โ†’ THEN PLAN TASKS. + +## Inputs + +- Feature name +- User requirements, scenarios, constraints, and non-functional expectations +- Related docs/tasks, if any + +## Spec Quality Rules + +- Requirements must be testable +- ACs must be observable outcomes, not vague goals +- Scenarios should cover happy path plus at least important edge cases +- Open questions should stay explicit instead of being buried in prose +- If background knowledge is too broad for the spec body, move it into a supporting doc and reference it + +## Step 1: Get Feature Name + +If `$ARGUMENTS` provided, use it as spec name. + +If no arguments, ask user: +> What feature are you speccing? (e.g., "user-auth", "payment-flow") + +## Step 2: Gather Requirements + +Ask user to describe the feature: +> Please describe the feature requirements. What should it do? + +Listen for: +- Core functionality +- User stories / scenarios +- Edge cases +- Non-functional requirements + +If requirements depend on large domain or architecture context: + +- create/update a supporting doc first +- keep the spec focused on product/behavioral requirements +- reference the supporting doc with `@doc/<path>` instead of dumping background material inline + +## Step 3: Create Spec Document + +```json +mcp__knowns__create_doc({ + "title": "<Feature Name>", + "description": "Specification for <feature>", + "folder": "specs", + "tags": ["spec", "draft"], + "content": "<spec content>" +}) +``` + +**Spec Template:** + +```markdown +## Overview + +Brief description of the feature and its purpose. + +## Requirements + +### Functional Requirements +- FR-1: [Requirement description] +- FR-2: [Requirement description] + +### Non-Functional Requirements +- NFR-1: [Performance, security, etc.] + +## Acceptance Criteria + +- [ ] AC-1: [Testable criterion] +- [ ] AC-2: [Testable criterion] +- [ ] AC-3: [Testable criterion] + +## Scenarios + +### Scenario 1: [Happy Path] +**Given** [context] +**When** [action] +**Then** [expected result] + +### Scenario 2: [Edge Case] +**Given** [context] +**When** [action] +**Then** [expected result] + +## Technical Notes + +Optional implementation hints or constraints. + +## Open Questions + +- [ ] Question 1? +- [ ] Question 2? +``` + +## Step 3.5: Validate Spec + +**CRITICAL:** After creating spec, validate to catch issues: + +```json +mcp__knowns__validate({ "entity": "specs/<name>" }) +``` + +## Step 4: Ask for Review + +Present the spec and ask: +> Please review this spec: +> - **Approve** if requirements are complete +> - **Edit** if you want to modify something +> - **Add more** if requirements are missing + +## Step 5: Handle Response + +**If approved:** +```json +mcp__knowns__update_doc({ + "path": "specs/<name>", + "tags": ["spec", "approved"] +}) +``` + +**If edit requested:** +Update the spec based on feedback and return to Step 4. + +**If add more:** +Gather additional requirements and update spec. + +## Final Response Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what spec was drafted, revised, approved, or blocked. +2. Key details - include the most important supporting context, refs, open questions, or validation. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Skill-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-spec`, the key details should cover: + +- the concrete spec draft or revision +- clear open questions, if any +- approval status +- any validation result or unresolved gaps + +## Spillover Rule + +If the spec uncovers cross-cutting or general knowledge work: + +- create a separate task for that work +- reference it from the spec or generated task set +- keep the spec focused on the feature, not on every general improvement the discussion surfaced + +--- + +## CRITICAL: Next Step Suggestion + +**You MUST suggest the next action when a natural follow-up exists. User won't know what to do next.** + +After spec is approved: + +``` +โœ“ Spec approved: @doc/specs/<name> + +This spec will generate multiple tasks. Ready to create them? + +Run: /kn-plan --from @doc/specs/<name> +``` + +**Show what will happen:** +``` +This will: +1. Parse requirements from spec +2. Generate tasks with ACs +3. Link all tasks to this spec +4. You review and approve before creation +``` + +--- + +## Related Skills + +- `/kn-plan --from @doc/specs/<name>` - Generate tasks from this spec +- `/kn-plan <id>` - Plan individual task +- `/kn-verify` - Verify implementation against spec + +## Checklist + +- [ ] Feature name determined +- [ ] Requirements gathered +- [ ] Spec created in specs/ folder +- [ ] Includes: Overview, Requirements, ACs, Scenarios +- [ ] User reviewed +- [ ] Status updated (draft โ†’ approved) +- [ ] **Next step suggested** (/kn-plan --from) + +## Red Flags + +- Creating spec without user input +- Skipping review step +- Approving without explicit user confirmation +- **Not suggesting task creation after approval** +- Writing implementation notes instead of requirements +- Leaving ambiguous AC text that cannot be verified later diff --git a/.kiro/skills/kn-template/SKILL.md b/.kiro/skills/kn-template/SKILL.md new file mode 100644 index 0000000..9165ba3 --- /dev/null +++ b/.kiro/skills/kn-template/SKILL.md @@ -0,0 +1,141 @@ +--- +name: kn-template +description: Use when generating code from templates - list, run, or create templates +--- + +# Working with Templates + +**Announce:** "Using kn-template to work with templates." + +**Core principle:** USE TEMPLATES FOR CONSISTENT CODE GENERATION. + +## Inputs + +- Template name or generation goal +- Variables required by prompts +- Linked pattern doc, if one exists + +## Preflight + +- Read the linked doc before running a non-trivial template +- Use dry run before generating real files +- Check whether a template already exists before creating a new one + +## Step 1: List Templates + +```json +mcp__knowns__list_templates({}) +``` + +## Step 2: Get Template Details + +```json +mcp__knowns__get_template({ "name": "<template-name>" }) +``` + +Check: prompts, `doc:` link, files to generate. + +## Step 3: Read Linked Documentation + +```json +mcp__knowns__get_doc({ "path": "<doc-path>", "smart": true }) +``` + +## Step 4: Run Template + +```json +// Dry run first +mcp__knowns__run_template({ + "name": "<template-name>", + "variables": { "name": "MyComponent" }, + "dryRun": true +}) + +// Then run for real +mcp__knowns__run_template({ + "name": "<template-name>", + "variables": { "name": "MyComponent" }, + "dryRun": false +}) +``` + +## Step 5: Create New Template + +```json +mcp__knowns__create_template({ + "name": "<template-name>", + "description": "Description", + "doc": "patterns/<related-doc>" +}) +``` + +## Template Config + +```yaml +name: react-component +description: Create a React component +doc: patterns/react-component + +prompts: + - name: name + message: Component name? + validate: required + +files: + - template: ".tsx.hbs" + destination: "src/components//.tsx" +``` + +## CRITICAL: Syntax Pitfalls + +**NEVER write `$` + triple-brace:** +``` +// โŒ WRONG +$` + `{` + `{` + `{camelCase name}` + +// โœ… CORRECT - add space, use ~ +${ {{~camelCase name~}}} +``` + +## Step 6: Validate (after creating template) + +```json +mcp__knowns__validate({ "scope": "templates" }) +``` + +## Shared Output Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what template was inspected, created, or run. +2. Key details - include the most important supporting context, refs, files, warnings, or validation. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Template-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-template`, the key details should cover: + +- which template was inspected, created, or run +- dry-run vs real execution +- generated or modified files +- any missing prompt values, doc gaps, or syntax issues + +When template work naturally leads to implementation or review, include the best next command. If the user only inspected templates or finished with a dry run decision, do not force a handoff. + +## Failure Modes + +- Missing linked doc -> say so and inspect the template directly +- Dry run looks wrong -> stop and fix the template before real generation +- New template overlaps an existing one -> prefer update or consolidation + +## Checklist + +- [ ] Listed available templates +- [ ] Read linked documentation +- [ ] Ran dry run first +- [ ] Verified generated files +- [ ] **Validated (if created new template)** diff --git a/.kiro/skills/kn-verify/SKILL.md b/.kiro/skills/kn-verify/SKILL.md new file mode 100644 index 0000000..aeab7c1 --- /dev/null +++ b/.kiro/skills/kn-verify/SKILL.md @@ -0,0 +1,146 @@ +--- +name: kn-verify +description: Use when running SDD verification and coverage reporting +--- + +# SDD Verification + +Run validation with SDD-awareness to check spec coverage and task status. + +**Announce:** "Using kn-verify to check SDD status." + +**Core principle:** VERIFY SPEC COVERAGE โ†’ REPORT WARNINGS โ†’ SUGGEST FIXES. + +## Inputs + +- Entire project SDD state, or a narrower entity if the user asked for focused validation + +## Verification Rules + +- Report concrete warnings before general commentary +- Prefer actionable fixes over generic advice +- Separate coverage problems from broken refs or missing links + +## Step 1: Run SDD Validation + +### Via CLI +```bash +knowns validate --sdd --plain +``` + +### Via MCP (if available) +```json +mcp__knowns__validate({ "scope": "sdd" }) +``` + +## Step 2: Present SDD Status + +Return the verification result using the shared output contract: + +- Goal/result: whether SDD validation passed, failed, or surfaced warnings +- Key details: coverage summary, explicit warnings, passing checks, and the highest-priority fixes +- Next action: only when the warnings point to a clear follow-up command + +The key-details portion may include a compact status block such as: + +``` +Specs: X total | Y approved | Z draft +Tasks: X total | Y done | Z in-progress | W todo +Coverage: X/Y tasks linked to specs (Z%) +Warnings: +- task-XX has no spec reference +- specs/feature: X/Y ACs incomplete +Passed: +- All spec references resolve +- specs/auth: fully implemented +``` + +## Step 3: Analyze Results + +**Good coverage (>80%):** +> SDD coverage is healthy. All tasks are properly linked to specs. + +**Medium coverage (50-80%):** +> Some tasks are missing spec references. Consider: +> - Link existing tasks to specs: `knowns task edit <id> --spec specs/<name>` +> - Create specs for unlinked work: `/kn-spec <feature-name>` + +**Low coverage (<50%):** +> Many tasks lack spec references. For better traceability: +> 1. Create specs for major features: `/kn-spec <feature>` +> 2. Link tasks to specs: `knowns task edit <id> --spec specs/<name>` +> 3. Use `/kn-plan --from @doc/specs/<name>` for new tasks + +## Step 4: Suggest Actions + +Based on warnings, add the most relevant fixes inside the key-details section, then give one best next command only if a natural handoff exists: + +**For tasks without spec:** +> Link task to spec: +> ```json +> mcp__knowns__update_task({ +> "taskId": "<id>", +> "spec": "specs/<name>" +> }) +> ``` + +**For incomplete ACs:** +> Check task progress: +> ```bash +> knowns task <id> --plain +> ``` + +**For approved specs without tasks:** +> Create tasks from spec: +> ``` +> /kn-plan --from @doc/specs/<name> +> ``` + +## Entity-Specific Validation (Optional) + +To validate a single task or doc (saves tokens): + +```json +// Validate single task +mcp__knowns__validate({ "entity": "abc123" }) + +// Validate single doc +mcp__knowns__validate({ "entity": "specs/user-auth" }) +``` + +## Shared Output Contract + +All built-in skills in scope must end with the same user-facing information order: `kn-init`, `kn-spec`, `kn-plan`, `kn-research`, `kn-implement`, `kn-verify`, `kn-doc`, `kn-template`, `kn-extract`, and `kn-commit`. + +Required order for the final user-facing response: + +1. Goal/result - state what validation confirmed, failed, or blocked. +2. Key details - include the most important supporting context, refs, coverage, warnings, or fixes. +3. Next action - recommend a concrete follow-up command only when a natural handoff exists. + +Keep this concise for CLI use. Verification-specific content may extend the key-details section, but must not replace or reorder the shared structure. + +Out of scope: explaining, syncing, or generating `.claude/skills/*`. Runtime auto-sync already handles platform copies, so this skill source only defines the built-in output contract. + +For `kn-verify`, the key details should cover: + +- coverage summary +- explicit warnings +- concrete follow-up actions +- whether the project is healthy enough to continue or needs cleanup first + +When verification reveals a clear follow-up, include the best next command. If the project is already healthy and no immediate workflow continuation is obvious, stop after the result and key details. + +## Checklist + +- [ ] Ran validate --sdd +- [ ] Presented status report +- [ ] Analyzed coverage level +- [ ] Suggested specific fixes for warnings + +## Red Flags + +- Ignoring warnings +- Not suggesting actionable fixes +- Skipping coverage analysis +- Claiming coverage is healthy without showing evidence diff --git a/.kiro/steering/knowns.md b/.kiro/steering/knowns.md new file mode 100644 index 0000000..98c980d --- /dev/null +++ b/.kiro/steering/knowns.md @@ -0,0 +1,9 @@ +--- +description: Knowns project guidelines โ€” always included so the agent follows repo conventions. +--- + +# Knowns Guidelines + +This steering file ensures the agent reads the canonical project guidance on every interaction. + +#[[file:KNOWNS.md]] diff --git a/.knowns/docs/specs/knowns-hub-mode.md b/.knowns/docs/specs/knowns-hub-mode.md new file mode 100644 index 0000000..9d00d2e --- /dev/null +++ b/.knowns/docs/specs/knowns-hub-mode.md @@ -0,0 +1,156 @@ +--- +title: Knowns Hub Mode +description: 'Specification for Hub Mode: standalone app with shared OpenCode daemon, project registry, workspace switching, and port handling fixes' +createdAt: '2026-03-24T07:08:41.276Z' +updatedAt: '2026-03-24T07:13:13.856Z' +tags: + - spec + - approved +--- + +# Knowns Hub Mode + +## Overview + +Hub Mode transforms Knowns from a per-project CLI tool into a standalone application. Users can launch Knowns without being inside a repository, browse and switch between multiple workspaces, and share a single OpenCode daemon across all projects instead of spawning a new process each time. + +## Requirements + +### Functional Requirements + +- FR-1: Fix port file race condition โ€” the `.server-port` file must only be written after the TCP port is successfully bound. +- FR-2: Propagate server bind errors โ€” if `ListenAndServe` (or `Serve`) fails, the error must be returned to the caller instead of being silently swallowed in a goroutine. +- FR-3: Cleanup `.server-port` on shutdown โ€” when the server receives SIGINT/SIGTERM, the port file must be removed before the process exits. +- FR-4: Implement a proper `stopExistingServer()` โ€” the restart flow must send a shutdown signal to the running server and wait for the port to be released, rather than only checking if the port responds. +- FR-5: Shared OpenCode daemon โ€” a single OpenCode server process runs as a daemon (detached, with PID file at `~/.knowns/opencode.pid`). All Knowns instances reuse it instead of spawning their own. +- FR-6: Daemon lifecycle management โ€” `EnsureRunning()` checks PID file + process liveness + HTTP health. If the daemon is dead or unresponsive, it is restarted automatically. +- FR-7: Project registry โ€” a global registry at `~/.knowns/registry.json` stores known project paths, names, and last-used timestamps. +- FR-8: Filesystem scan โ€” `knowns browser --scan <dirs>` (and `POST /api/workspaces/scan`) discovers directories containing `.knowns/` folders and adds them to the registry. +- FR-9: Workspace switch API โ€” `POST /api/workspaces/switch` swaps the active project at runtime without restarting the server. The server reloads the store and notifies connected clients via SSE. +- FR-10: Standalone launch โ€” `knowns browser` without being inside a repo loads the registry and opens the last-active project, or shows a workspace picker if no project is registered. +- FR-11: Workspace picker UI โ€” a React component that lists registered projects, allows selecting one, and supports adding new projects via folder picker or manual path input. + +### Non-Functional Requirements + +- NFR-1: Backward compatibility โ€” all existing single-project workflows (`knowns browser` inside a repo) must continue to work unchanged. +- NFR-2: Resource efficiency โ€” only one OpenCode process and one Knowns HTTP server should run at any time, regardless of how many projects are registered. +- NFR-3: Graceful degradation โ€” if the OpenCode CLI is not installed, Hub Mode still works for task/doc management; only AI chat features are unavailable. +- NFR-4: Startup latency โ€” switching workspaces should complete in under 500ms (excluding network-dependent operations). + +## Acceptance Criteria + +- [ ] AC-1: `.server-port` is written only after `net.Listen` succeeds; if binding fails, no port file exists. +- [ ] AC-2: A bind failure on the configured port returns an error from `Start()` instead of hanging silently. +- [ ] AC-3: After SIGINT/SIGTERM, the `.server-port` file is removed and the port is released. +- [ ] AC-4: `knowns browser --restart` sends a shutdown request to the existing server and waits for the port to be freed before starting a new one. +- [ ] AC-5: Running `knowns browser` twice does not spawn two OpenCode processes; the second invocation reuses the daemon started by the first. +- [ ] AC-6: If the OpenCode daemon crashes, the next `knowns browser` or health-check cycle restarts it automatically. +- [ ] AC-7: `GET /api/workspaces` returns the list of registered projects with name, path, and last-used timestamp. +- [ ] AC-8: `POST /api/workspaces/scan` with a list of directories returns newly discovered projects and persists them to the registry. +- [ ] AC-9: `POST /api/workspaces/switch` with a project ID reloads the store; subsequent API calls (tasks, docs) reflect the new project's data. +- [ ] AC-10: SSE clients receive a `refresh` event after a workspace switch so the UI reloads all data. +- [ ] AC-11: `knowns browser` launched outside any repo opens the workspace picker UI if no project is registered, or loads the last-active project otherwise. +- [ ] AC-12: The workspace picker UI lists all registered projects and allows the user to select, add, or remove projects. + +## Scenarios + +### Scenario 1: Normal startup inside a repo (backward compat) +**Given** the user is inside a directory with `.knowns/` +**When** they run `knowns browser` +**Then** the server starts on the configured port, the port file is written after binding, and the browser opens โ€” identical to current behavior. + +### Scenario 2: Port already in use +**Given** another process occupies port 3001 +**When** the user runs `knowns browser --port 3001` +**Then** `Start()` returns an error like "failed to listen on :3001: address already in use" and no port file is written. + +### Scenario 3: Restart with existing server +**Given** a Knowns server is running on port 3001 +**When** the user runs `knowns browser --restart --port 3001` +**Then** the old server receives a shutdown request, the port is freed, and a new server starts on port 3001. + +### Scenario 4: Shared OpenCode daemon +**Given** no OpenCode daemon is running +**When** the user runs `knowns browser` for project A, then opens a second terminal and runs `knowns browser --port 3002` for project B +**Then** only one `opencode serve` process exists (verified by PID file), and both Knowns servers proxy to it. + +### Scenario 5: Daemon crash recovery +**Given** the OpenCode daemon is running (PID file exists) +**When** the daemon process is killed externally +**Then** the next health check (or next `knowns browser` launch) detects the dead process and restarts the daemon. + +### Scenario 6: Standalone launch without repo +**Given** the user is in `~/Desktop` (no `.knowns/` folder) and the registry contains two projects +**When** they run `knowns browser` +**Then** the server starts and loads the most recently used project from the registry. + +### Scenario 7: First launch with empty registry +**Given** the user has never used Knowns before (no `~/.knowns/registry.json`) +**When** they run `knowns browser` +**Then** the workspace picker UI is shown, prompting the user to add a project folder. + +### Scenario 8: Workspace switch at runtime +**Given** the server is running with project A active, and the UI is open +**When** the user selects project B in the workspace picker +**Then** `POST /api/workspaces/switch` is called, the store reloads, an SSE `refresh` event fires, and the UI shows project B's tasks and docs. + +### Scenario 9: Scan for projects +**Given** the user has multiple repos under `~/projects/` +**When** they click "Scan" in the workspace picker and provide `~/projects/` +**Then** all subdirectories containing `.knowns/` are added to the registry and appear in the picker. + +## Technical Notes + +### Architecture + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Browser UI โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ Workspace โ”‚ โ”‚ Board โ”‚ โ”‚ OpenCode โ”‚ โ”‚ +โ”‚ โ”‚ Picker โ”‚ โ”‚ /Tasks โ”‚ โ”‚ Chat UI โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ โ”‚ โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ” + โ”‚ Knowns HTTP Server โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Project โ”‚ โ”‚ OpenCode โ”‚ โ”‚ + โ”‚ โ”‚ Registry โ”‚ โ”‚ Proxy โ”‚ โ”‚ + โ”‚ โ”‚ + Switcher โ”‚ โ”‚ (shared) โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ”‚ โ”‚ โ”‚ โ”‚ + โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ + โ”‚ โ”‚ Multi-Store โ”‚ โ”‚ OpenCode โ”‚ โ”‚ + โ”‚ โ”‚ Manager โ”‚ โ”‚ Daemon โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### New packages +- `internal/registry/` โ€” project registry (CRUD, scan, persist) +- `internal/agents/opencode/daemon.go` โ€” daemon lifecycle (PID file, health check, start/stop) +- `internal/storage/manager.go` โ€” multi-store manager (lazy load, switch, cleanup) +- `internal/server/routes/workspace.go` โ€” workspace API endpoints + +### Key implementation details +- Port fix: use `net.Listen` first, then `srv.Serve(listener)` instead of `srv.ListenAndServe` +- Daemon PID file: `~/.knowns/opencode.pid` +- Registry file: `~/.knowns/registry.json` +- Daemon detach: `cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}` +- Store switch: lazy-load into `map[string]*Store`, cleanup idle stores after timeout + +### Implementation Phases +| Phase | Scope | Risk | +|-------|-------|------| +| 1. Fix Port Bugs | `server.go`, `browser.go` | Low โ€” isolated fixes | +| 2. OpenCode Daemon | `opencode/daemon.go` | Medium โ€” process management | +| 3. Project Registry | `registry/`, API routes | Low โ€” new code, no breaking changes | +| 4. UI Workspace Switcher | React components, API integration | Medium โ€” UX design needed | + +## Open Questions + +- [ ] Should the daemon port be configurable globally (`~/.knowns/config.json`) or always derived from a fixed default? +- [ ] Should workspace switch trigger a full page reload or a soft data refresh via SSE? +- [ ] Should the registry support "pinned" or "favorite" projects for quick access? +- [ ] How should MCP handlers (separate process) discover the active project after a workspace switch? diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 82ecfbb..223a466 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ Compatibility entrypoint for runtimes that auto-detect `AGENTS.md`. <!-- KNOWNS GUIDELINES START --> +**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.** + ## Canonical Guidance - Knowns is the repository memory layer for humans and the AI-friendly working layer for agents. diff --git a/CLAUDE.md b/CLAUDE.md index b93eaa2..ede9d5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,10 @@ Compatibility entrypoint for runtimes that auto-detect `CLAUDE.md`. <!-- KNOWNS GUIDELINES START --> +@KNOWNS.md + +**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.** + ## Canonical Guidance - Knowns is the repository memory layer for humans and the AI-friendly working layer for agents. diff --git a/GEMINI.md b/GEMINI.md index 167e104..704f49e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -4,6 +4,10 @@ Compatibility entrypoint for runtimes that auto-detect `GEMINI.md`. <!-- KNOWNS GUIDELINES START --> +@KNOWNS.md + +**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.** + ## Canonical Guidance - Knowns is the repository memory layer for humans and the AI-friendly working layer for agents. diff --git a/KNOWNS.md b/KNOWNS.md index c34b4f9..56306b0 100644 --- a/KNOWNS.md +++ b/KNOWNS.md @@ -110,6 +110,10 @@ Canonical repository guidance for agents working in this project. - Task references use `@task-<id>`. - Doc references use `@doc/<path>`. - Template references use `@template/<name>`. +- Doc references support line and range suffixes: + - `@doc/<path>:42` โ€” link to a specific line. + - `@doc/<path>:10-25` โ€” link to a line range. + - `@doc/<path>#heading-slug` โ€” link to a heading anchor. - Follow references recursively before planning, implementation, or validation work. ## Common Mistakes diff --git a/OPENCODE.md b/OPENCODE.md index f00d648..beeed60 100644 --- a/OPENCODE.md +++ b/OPENCODE.md @@ -4,6 +4,8 @@ Compatibility entrypoint for runtimes that auto-detect `OPENCODE.md`. <!-- KNOWNS GUIDELINES START --> +**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.** + ## Canonical Guidance - Knowns is the repository memory layer for humans and the AI-friendly working layer for agents. diff --git a/internal/agents/opencode/daemon.go b/internal/agents/opencode/daemon.go new file mode 100644 index 0000000..8580f82 --- /dev/null +++ b/internal/agents/opencode/daemon.go @@ -0,0 +1,186 @@ +package opencode + +import ( + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Daemon manages a shared OpenCode server process that persists across +// Knowns server restarts. Only one daemon runs at a time, identified by +// a PID file at ~/.knowns/opencode.pid. +type Daemon struct { + Host string + Port int + PIDFile string + // startedByUs tracks whether this Daemon instance spawned the process + // (as opposed to finding an already-running one). Used to decide + // whether cleanup should kill the process. + startedByUs bool +} + +// NewDaemon creates a Daemon targeting the given host:port. +// The PID file defaults to ~/.knowns/opencode.pid. +func NewDaemon(host string, port int) *Daemon { + home, _ := os.UserHomeDir() + return &Daemon{ + Host: host, + Port: port, + PIDFile: filepath.Join(home, ".knowns", "opencode.pid"), + } +} + +// NewDaemonWithPIDFile creates a Daemon with a custom PID file path (for testing). +func NewDaemonWithPIDFile(host string, port int, pidFile string) *Daemon { + return &Daemon{ + Host: host, + Port: port, + PIDFile: pidFile, + } +} + +// StartedByUs reports whether this Daemon instance spawned the process. +func (d *Daemon) StartedByUs() bool { + return d.startedByUs +} + +// EnsureRunning checks if the daemon is alive and healthy. If not, it +// cleans up any stale PID file and starts a fresh process. +func (d *Daemon) EnsureRunning() error { + // 1. Check if opencode CLI is available at all. + if _, err := exec.LookPath("opencode"); err != nil { + return fmt.Errorf("opencode CLI not found: %w", err) + } + + // 2. Try to reuse an existing daemon. + if d.isHealthy() { + log.Printf("[daemon] OpenCode daemon already running on %s:%d", d.Host, d.Port) + return nil + } + + // 3. Stale or dead โ€” clean up and start fresh. + d.cleanupStalePID() + return d.start() +} + +// Stop sends SIGTERM to the daemon (if we started it) and removes the PID file. +func (d *Daemon) Stop() error { + pid, err := d.ReadPID() + if err != nil { + return nil // no PID file โ€” nothing to stop + } + + process, err := os.FindProcess(pid) + if err != nil { + os.Remove(d.PIDFile) + return nil + } + + log.Printf("[daemon] Stopping OpenCode daemon (pid %d)", pid) + if err := signalTerm(process); err != nil { + // Process may already be dead โ€” that's fine. + log.Printf("[daemon] Signal failed (process may be dead): %v", err) + } + + // Wait briefly for process to exit. + done := make(chan struct{}) + go func() { + process.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(3 * time.Second): + // Force kill if SIGTERM didn't work. + process.Kill() + process.Wait() + } + + os.Remove(d.PIDFile) + log.Printf("[daemon] OpenCode daemon stopped") + return nil +} + +// isHealthy checks: PID file exists โ†’ process alive โ†’ HTTP reachable. +func (d *Daemon) isHealthy() bool { + pid, err := d.ReadPID() + if err != nil { + return false + } + + if !isProcessAlive(pid) { + return false + } + + client := NewClient(Config{Host: d.Host, Port: d.Port}) + return client.IsServerAvailable() +} + +// cleanupStalePID removes the PID file if the referenced process is dead. +func (d *Daemon) cleanupStalePID() { + pid, err := d.ReadPID() + if err != nil { + return + } + if !isProcessAlive(pid) { + log.Printf("[daemon] Removing stale PID file (pid %d is dead)", pid) + os.Remove(d.PIDFile) + } +} + +// start spawns a new opencode serve process, detached from the parent. +func (d *Daemon) start() error { + args := []string{"serve", + "--hostname", d.Host, + "--port", strconv.Itoa(d.Port), + "--cors", "*", + } + cmd := exec.Command("opencode", args...) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + cmd.Stdin = strings.NewReader("") + + // Detach: new session so the daemon survives parent exit. + setSysProcAttr(cmd) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start opencode daemon on port %d: %w", d.Port, err) + } + + if err := d.WritePID(cmd.Process.Pid); err != nil { + // Non-fatal: daemon is running but PID file failed. + log.Printf("[daemon] Warning: could not write PID file: %v", err) + } + + d.startedByUs = true + log.Printf("[daemon] Spawned OpenCode daemon (pid %d) on %s:%d", cmd.Process.Pid, d.Host, d.Port) + + // Give the daemon a moment to bind its port. + time.Sleep(2 * time.Second) + return nil +} + +// ReadPID reads the daemon PID from the PID file. +func (d *Daemon) ReadPID() (int, error) { + data, err := os.ReadFile(d.PIDFile) + if err != nil { + return 0, err + } + return strconv.Atoi(strings.TrimSpace(string(data))) +} + +// WritePID writes the given PID to the PID file, creating parent dirs if needed. +func (d *Daemon) WritePID(pid int) error { + if err := os.MkdirAll(filepath.Dir(d.PIDFile), 0755); err != nil { + return err + } + return os.WriteFile(d.PIDFile, []byte(strconv.Itoa(pid)), 0644) +} + + diff --git a/internal/agents/opencode/daemon_test.go b/internal/agents/opencode/daemon_test.go new file mode 100644 index 0000000..b0eb479 --- /dev/null +++ b/internal/agents/opencode/daemon_test.go @@ -0,0 +1,132 @@ +package opencode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDaemonPIDReadWrite(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "test.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + + // Write PID + if err := d.WritePID(12345); err != nil { + t.Fatalf("WritePID failed: %v", err) + } + + // Read PID back + pid, err := d.ReadPID() + if err != nil { + t.Fatalf("ReadPID failed: %v", err) + } + if pid != 12345 { + t.Fatalf("ReadPID = %d, want 12345", pid) + } +} + +func TestDaemonReadPIDMissingFile(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "nonexistent.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + + _, err := d.ReadPID() + if err == nil { + t.Fatal("expected error for missing PID file") + } +} + +func TestDaemonWritePIDCreatesParentDirs(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "nested", "deep", "test.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + + if err := d.WritePID(99999); err != nil { + t.Fatalf("WritePID with nested dirs failed: %v", err) + } + + // Verify file exists + if _, err := os.Stat(pidFile); os.IsNotExist(err) { + t.Fatal("PID file was not created") + } + + pid, err := d.ReadPID() + if err != nil { + t.Fatalf("ReadPID failed: %v", err) + } + if pid != 99999 { + t.Fatalf("ReadPID = %d, want 99999", pid) + } +} + +func TestDaemonCleanupStalePID(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "stale.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + + // Write a PID that definitely doesn't exist (very high number) + if err := d.WritePID(999999999); err != nil { + t.Fatalf("WritePID failed: %v", err) + } + + // Verify file exists before cleanup + if _, err := os.Stat(pidFile); os.IsNotExist(err) { + t.Fatal("PID file should exist before cleanup") + } + + // cleanupStalePID should remove it since process 999999999 is dead + d.cleanupStalePID() + + // Verify file is removed + if _, err := os.Stat(pidFile); !os.IsNotExist(err) { + t.Fatal("stale PID file should be removed after cleanup") + } +} + +func TestDaemonIsHealthyReturnsFalseWithNoPIDFile(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "nope.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + + if d.isHealthy() { + t.Fatal("isHealthy should return false when no PID file exists") + } +} + +func TestDaemonIsHealthyReturnsFalseWithDeadProcess(t *testing.T) { + tmpDir := t.TempDir() + pidFile := filepath.Join(tmpDir, "dead.pid") + + d := NewDaemonWithPIDFile("127.0.0.1", 4096, pidFile) + d.WritePID(999999999) // non-existent process + + if d.isHealthy() { + t.Fatal("isHealthy should return false when process is dead") + } +} + +func TestDaemonStartedByUsDefaultFalse(t *testing.T) { + d := NewDaemonWithPIDFile("127.0.0.1", 4096, "/tmp/test.pid") + if d.StartedByUs() { + t.Fatal("StartedByUs should be false by default") + } +} + +func TestIsProcessAliveWithCurrentProcess(t *testing.T) { + // Our own process should be alive + if !isProcessAlive(os.Getpid()) { + t.Fatal("isProcessAlive should return true for current process") + } +} + +func TestIsProcessAliveWithDeadProcess(t *testing.T) { + if isProcessAlive(999999999) { + t.Fatal("isProcessAlive should return false for non-existent PID") + } +} diff --git a/internal/agents/opencode/daemon_unix.go b/internal/agents/opencode/daemon_unix.go new file mode 100644 index 0000000..8696972 --- /dev/null +++ b/internal/agents/opencode/daemon_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +package opencode + +import ( + "os" + "os/exec" + "syscall" +) + +// setSysProcAttr detaches the child process into its own session so it +// survives the parent exiting. +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} +} + +// signalTerm sends SIGTERM to the process. +func signalTerm(process *os.Process) error { + return process.Signal(syscall.SIGTERM) +} + +// isProcessAlive checks if a process with the given PID exists and is running. +func isProcessAlive(pid int) bool { + process, err := os.FindProcess(pid) + if err != nil { + return false + } + return process.Signal(syscall.Signal(0)) == nil +} diff --git a/internal/agents/opencode/daemon_windows.go b/internal/agents/opencode/daemon_windows.go new file mode 100644 index 0000000..1f80e6e --- /dev/null +++ b/internal/agents/opencode/daemon_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package opencode + +import ( + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +// setSysProcAttr creates the child process in a new process group so it +// survives the parent exiting on Windows. +func setSysProcAttr(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, + } +} + +// signalTerm terminates the process on Windows (no SIGTERM equivalent). +func signalTerm(process *os.Process) error { + return process.Kill() +} + +// isProcessAlive checks if a process with the given PID exists and is running +// by querying the Windows tasklist. +func isProcessAlive(pid int) bool { + cmd := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH") + out, err := cmd.Output() + if err != nil { + return false + } + return strings.Contains(string(out), strconv.Itoa(pid)) +} diff --git a/internal/cli/browser.go b/internal/cli/browser.go index 0df6afc..2c54b35 100644 --- a/internal/cli/browser.go +++ b/internal/cli/browser.go @@ -2,25 +2,89 @@ package cli import ( "fmt" + "net" "net/http" "os/exec" "path/filepath" "runtime" + "strings" "time" "github.com/spf13/cobra" + "github.com/howznguyen/knowns/internal/registry" "github.com/howznguyen/knowns/internal/server" + "github.com/howznguyen/knowns/internal/storage" "github.com/howznguyen/knowns/internal/util" ) var browserCmd = &cobra.Command{ Use: "browser", Short: "Launch the Knowns web UI", - Long: "Start the Knowns HTTP server and optionally open it in a browser.", + Long: "Start the Knowns HTTP server and optionally open it in a browser.\nCan be launched outside a repo to use the workspace picker.", RunE: runBrowser, } +// resolveProject determines which project to open using a fallback chain: +// 1. --project <path> flag +// 2. --scan <dirs> flag (pre-populate registry) +// 3. cwd-based .knowns/ discovery +// 4. Registry last-active project +// 5. Picker mode (nil store) +func resolveProject(cmd *cobra.Command) (store *storage.Store, projectRoot string) { + projectFlag, _ := cmd.Flags().GetString("project") + scanFlag, _ := cmd.Flags().GetString("scan") + + // 1. Explicit --project flag + if projectFlag != "" { + absPath, err := filepath.Abs(projectFlag) + if err == nil { + knDir := filepath.Join(absPath, ".knowns") + store = storage.NewStore(knDir) + projectRoot = absPath + return + } + } + + // 2. --scan flag: pre-populate registry before resolution + if scanFlag != "" { + dirs := strings.Split(scanFlag, ",") + for i := range dirs { + dirs[i] = strings.TrimSpace(dirs[i]) + } + reg := registry.NewRegistry() + if err := reg.Load(); err == nil { + added, _ := reg.Scan(dirs) + if len(added) > 0 { + fmt.Printf(" %s Discovered %d project(s)\n", StyleInfo.Render("โŠ•"), len(added)) + } + } + } + + // 3. Try cwd-based discovery + s, err := getStoreErr() + if err == nil { + store = s + projectRoot = filepath.Dir(s.Root) + return + } + + // 4. Fallback to registry last-active + reg := registry.NewRegistry() + if err := reg.Load(); err == nil { + if active := reg.GetActive(); active != nil { + knDir := filepath.Join(active.Path, ".knowns") + store = storage.NewStore(knDir) + projectRoot = active.Path + fmt.Printf(" %s Using last-active project: %s\n", StyleInfo.Render("โ†ฉ"), StyleBold.Render(active.Name)) + return + } + } + + // 5. Picker mode โ€” no project found + return nil, "" +} + func runBrowser(cmd *cobra.Command, args []string) error { port, _ := cmd.Flags().GetInt("port") openFlag, _ := cmd.Flags().GetBool("open") @@ -28,9 +92,10 @@ func runBrowser(cmd *cobra.Command, args []string) error { restart, _ := cmd.Flags().GetBool("restart") dev, _ := cmd.Flags().GetBool("dev") - store := getStore() + store, projectRoot := resolveProject(cmd) - if port == 0 { + // Resolve port from config (only if we have a store). + if port == 0 && store != nil { cfg, cerr := store.Config.Load() if cerr == nil && cfg.Settings.ServerPort != 0 { port = cfg.Settings.ServerPort @@ -40,8 +105,15 @@ func runBrowser(cmd *cobra.Command, args []string) error { port = 3001 } - // store.Root is the .knowns/ directory; the project root is its parent. - projectRoot := filepath.Dir(store.Root) + // Auto-register this project in the global registry (only if we have a project). + if store != nil { + reg := registry.NewRegistry() + if err := reg.Load(); err == nil { + if p, err := reg.Add(projectRoot); err == nil { + _ = reg.SetActive(p.ID) + } + } + } // Handle restart: attempt to stop existing server first if restart { @@ -65,22 +137,47 @@ func runBrowser(cmd *cobra.Command, args []string) error { fmt.Printf(" %s %s %s\n", StyleSuccess.Render("โ—"), StyleBold.Render("Knowns"), StyleDim.Render("v"+util.Version)) fmt.Println() fmt.Printf(" %s %s\n", StyleInfo.Render("โ†’"), StyleBold.Render(url)) - fmt.Printf(" %s %s\n", StyleDim.Render("โŒ"), StyleDim.Render(projectRoot)) + if projectRoot != "" { + fmt.Printf(" %s %s\n", StyleDim.Render("โŒ"), StyleDim.Render(projectRoot)) + } else { + fmt.Printf(" %s %s\n", StyleWarning.Render("โ—‡"), StyleDim.Render("No project โ€” workspace picker mode")) + } fmt.Println() return srv.Start() } -// stopExistingServer attempts to stop any existing server on the given port. -func stopExistingServer(port int) { +// stopExistingServer sends a shutdown request to any existing server on the +// given port and waits for the port to be released. Returns true if the port +// was freed, false if no server was found or the stop timed out. +func stopExistingServer(port int) bool { client := &http.Client{Timeout: 2 * time.Second} - resp, err := client.Get(fmt.Sprintf("http://localhost:%d", port)) + resp, err := client.Post( + fmt.Sprintf("http://localhost:%d/api/shutdown", port), + "application/json", nil, + ) if err != nil { fmt.Println(StyleDim.Render("No existing server found.")) - return + return false } resp.Body.Close() - fmt.Println(StyleWarning.Render("Existing server detected.") + " It will be replaced when the new server starts.") + + fmt.Println(StyleWarning.Render("Existing server detected.") + " Waiting for shutdown...") + + // Poll until the port is released (max ~3s). + for i := 0; i < 10; i++ { + conn, dialErr := net.DialTimeout("tcp", + fmt.Sprintf("localhost:%d", port), 200*time.Millisecond) + if dialErr != nil { + fmt.Println(StyleSuccess.Render("Previous server stopped.")) + return true // Port released + } + conn.Close() + time.Sleep(300 * time.Millisecond) + } + + fmt.Println(StyleWarning.Render("Timed out waiting for previous server to stop.")) + return false } func openBrowser(url string) { @@ -104,6 +201,8 @@ func init() { browserCmd.Flags().Bool("no-open", false, "Don't automatically open browser") browserCmd.Flags().Bool("restart", false, "Restart server if already running") browserCmd.Flags().Bool("dev", false, "Enable development mode (verbose logging)") + browserCmd.Flags().String("project", "", "Project path to open directly") + browserCmd.Flags().String("scan", "", "Comma-separated directories to scan for projects") rootCmd.AddCommand(browserCmd) } diff --git a/internal/cli/browser_test.go b/internal/cli/browser_test.go new file mode 100644 index 0000000..74e980d --- /dev/null +++ b/internal/cli/browser_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/howznguyen/knowns/internal/registry" + "github.com/spf13/cobra" +) + +// newTestCmd creates a cobra command with browser flags for testing resolveProject. +func newTestCmd() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("project", "", "") + cmd.Flags().String("scan", "", "") + return cmd +} + +func TestResolveProjectFromFlag(t *testing.T) { + tmpDir := t.TempDir() + projDir := filepath.Join(tmpDir, "my-proj") + os.MkdirAll(filepath.Join(projDir, ".knowns"), 0755) + + cmd := newTestCmd() + cmd.Flags().Set("project", projDir) + + store, projectRoot := resolveProject(cmd) + if store == nil { + t.Fatal("expected store from --project flag, got nil") + } + if projectRoot != projDir { + t.Fatalf("projectRoot = %q, want %q", projectRoot, projDir) + } + if store.Root != filepath.Join(projDir, ".knowns") { + t.Fatalf("store.Root = %q, want %q", store.Root, filepath.Join(projDir, ".knowns")) + } +} + +func TestResolveProjectFromRegistry(t *testing.T) { + tmpDir := t.TempDir() + projDir := filepath.Join(tmpDir, "reg-proj") + os.MkdirAll(filepath.Join(projDir, ".knowns"), 0755) + + // Set up a registry with one project + regFile := filepath.Join(tmpDir, "registry.json") + reg := registry.NewRegistryWithPath(regFile) + reg.Load() + p, _ := reg.Add(projDir) + reg.SetActive(p.ID) + + // Override the default registry path for this test by using --project + // Since we can't easily override NewRegistry() path, test via --project flag + // which is the primary path. The registry fallback is tested implicitly + // through the integration flow. + cmd := newTestCmd() + cmd.Flags().Set("project", projDir) + + store, root := resolveProject(cmd) + if store == nil { + t.Fatal("expected store, got nil") + } + if root != projDir { + t.Fatalf("root = %q, want %q", root, projDir) + } +} + +func TestResolveProjectPickerMode(t *testing.T) { + // Run from a temp dir with no .knowns/ and no registry + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + cmd := newTestCmd() + store, root := resolveProject(cmd) + + // In picker mode, store may be nil (if registry is empty) or from registry + // Since we can't control the global registry in unit tests, just verify + // that the function doesn't panic and returns something reasonable. + if store != nil && root == "" { + t.Fatal("if store is non-nil, root should also be non-empty") + } +} + +func TestResolveProjectFromCwd(t *testing.T) { + tmpDir := t.TempDir() + // Resolve symlinks (macOS /private/var vs /var) + tmpDir, _ = filepath.EvalSymlinks(tmpDir) + os.MkdirAll(filepath.Join(tmpDir, ".knowns"), 0755) + + origDir, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(origDir) + + cmd := newTestCmd() + store, root := resolveProject(cmd) + + if store == nil { + t.Fatal("expected store from cwd, got nil") + } + if root != tmpDir { + t.Fatalf("root = %q, want %q", root, tmpDir) + } +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go index b4b189f..417d38d 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -150,6 +150,7 @@ func runDocView(cmd *cobra.Command, path string) error { tocOnly, _ := cmd.Flags().GetBool("toc") infoOnly, _ := cmd.Flags().GetBool("info") section, _ := cmd.Flags().GetString("section") + lineParam, _ := cmd.Flags().GetString("line") smart, _ := cmd.Flags().GetBool("smart") if jsonOut { @@ -197,6 +198,19 @@ func runDocView(cmd *cobra.Command, path string) error { return nil } + if lineParam != "" { + lineContent, lineLabel, err := extractDocLines(doc.Content, lineParam) + if err != nil { + return err + } + if plain { + fmt.Printf("PATH: %s\nLINES: %s\n\n%s\n", doc.Path, lineLabel, lineContent) + } else { + fmt.Printf("Lines %s of %s:\n\n%s\n", lineLabel, doc.Title, lineContent) + } + return nil + } + // Smart mode: return full content if small, stats+toc if large if smart { const tokenLimit = 2000 @@ -774,6 +788,38 @@ func extractDocSection(content, sectionRef string) string { return strings.TrimSpace(strings.Join(lines[startLine:endLine], "\n")) } +// extractDocLines returns specific lines from content. +// lineParam can be "42" (single line) or "10-20" (range). +func extractDocLines(content, lineParam string) (string, string, error) { + allLines := strings.Split(content, "\n") + total := len(allLines) + + // Try range: "10-20" + if parts := strings.SplitN(lineParam, "-", 2); len(parts) == 2 { + start, err1 := strconv.Atoi(parts[0]) + end, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil && start >= 1 && end >= start { + if start > total { + return "", "", fmt.Errorf("line %d exceeds document length (%d lines)", start, total) + } + if end > total { + end = total + } + return strings.Join(allLines[start-1:end], "\n"), fmt.Sprintf("%d-%d", start, end), nil + } + } + + // Single line: "42" + line, err := strconv.Atoi(lineParam) + if err != nil { + return "", "", fmt.Errorf("invalid line parameter: %q (use '42' or '10-20')", lineParam) + } + if line < 1 || line > total { + return "", "", fmt.Errorf("line %d out of range (document has %d lines)", line, total) + } + return allLines[line-1], fmt.Sprintf("%d", line), nil +} + func replaceDocSection(content, sectionRef, newContent string) string { lines := strings.Split(content, "\n") headingCount := 0 @@ -835,12 +881,14 @@ func init() { docViewCmd.Flags().Bool("toc", false, "Show table of contents only") docViewCmd.Flags().Bool("info", false, "Show document stats without content") docViewCmd.Flags().String("section", "", "Show specific section by number or title") + docViewCmd.Flags().String("line", "", "Show specific lines (e.g., '42' or '10-20')") docViewCmd.Flags().Bool("smart", false, "Auto-optimize reading for large documents") // doc shorthand (the docCmd itself) also needs view flags docCmd.Flags().Bool("toc", false, "Show table of contents only") docCmd.Flags().Bool("info", false, "Show document stats without content") docCmd.Flags().String("section", "", "Show specific section by number or title") + docCmd.Flags().String("line", "", "Show specific lines (e.g., '42' or '10-20')") docCmd.Flags().Bool("smart", false, "Auto-optimize reading for large documents") // doc create flags diff --git a/internal/cli/init.go b/internal/cli/init.go index c8e5165..4fcfea0 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -114,11 +114,11 @@ Creates a .knowns/ directory with the required structure and a default config.js // allPlatformIDs is the full ordered list of supported platform identifiers. // "opencode" is handled separately via EnableChatUI โ€” not shown in the multi-select. -var allPlatformIDs = []string{"claude-code", "opencode", "gemini", "copilot", "agents"} +var allPlatformIDs = []string{"claude-code", "opencode", "gemini", "copilot", "kiro", "agents"} // wizardPlatformIDs is the subset shown in the wizard multi-select. // OpenCode is asked as a dedicated ChatUI question instead. -var wizardPlatformIDs = []string{"claude-code", "gemini", "copilot", "agents"} +var wizardPlatformIDs = []string{"claude-code", "gemini", "copilot", "kiro", "agents"} // platformLabel returns the human-readable label for a platform ID. func platformLabel(id string) string { @@ -131,6 +131,8 @@ func platformLabel(id string) string { return "Google Gemini (GEMINI.md)" case "copilot": return "GitHub Copilot (.github/copilot-instructions.md)" + case "kiro": + return "Kiro IDE (.kiro/steering/, .kiro/skills/)" case "agents": return "Generic Agents (AGENTS.md, .agent/skills/)" default: @@ -342,6 +344,14 @@ func runInit(cmd *cobra.Command, args []string) error { }, }) } + if hasPlatform(cfg.Platforms, "kiro") { + steps = append(steps, initStep{ + label: "Creating Kiro steering", + run: func() error { + return createKiroSteeringQuiet(cwd, force) + }, + }) + } steps = append(steps, initStep{ label: "Creating instruction files", @@ -608,6 +618,33 @@ func createOpenCodeConfigQuiet(projectRoot string) error { return os.WriteFile(configPath, append(data, '\n'), 0644) } +// createKiroSteeringQuiet creates .kiro/steering/knowns.md that references +// KNOWNS.md via Kiro's #[[file:...]] directive so the agent always loads the +// canonical guidelines automatically. +func createKiroSteeringQuiet(projectRoot string, force bool) error { + steeringDir := filepath.Join(projectRoot, ".kiro", "steering") + if err := os.MkdirAll(steeringDir, 0755); err != nil { + return fmt.Errorf("create .kiro/steering: %w", err) + } + + steeringPath := filepath.Join(steeringDir, "knowns.md") + if _, err := os.Stat(steeringPath); err == nil && !force { + return nil + } + + content := `--- +description: Knowns project guidelines โ€” always included so the agent follows repo conventions. +--- + +# Knowns Guidelines + +This steering file ensures the agent reads the canonical project guidance on every interaction. + +#[[file:KNOWNS.md]] +` + return os.WriteFile(steeringPath, []byte(content), 0644) +} + // createInstructionFilesForPlatforms generates only instruction files for the // given platform IDs. If platforms is empty all files are generated. func createInstructionFilesForPlatforms(projectRoot string, force bool, platforms []string) error { @@ -817,6 +854,10 @@ func renderCanonicalInstructionContent() string { sb.WriteString("- Task references use `@task-<id>`.\n") sb.WriteString("- Doc references use `@doc/<path>`.\n") sb.WriteString("- Template references use `@template/<name>`.\n") + sb.WriteString("- Doc references support line and range suffixes:\n") + sb.WriteString(" - `@doc/<path>:42` โ€” link to a specific line.\n") + sb.WriteString(" - `@doc/<path>:10-25` โ€” link to a line range.\n") + sb.WriteString(" - `@doc/<path>#heading-slug` โ€” link to a heading anchor.\n") sb.WriteString("- Follow references recursively before planning, implementation, or validation work.\n\n") sb.WriteString("## Common Mistakes\n\n") sb.WriteString("### Notes vs Append Notes\n\n") @@ -855,6 +896,13 @@ func renderCompatibilityInstructionContent(relativePath, platform, projectRoot s sb.WriteString(fmt.Sprintf("# %s\n\n", compatibilityInstructionTitle(relativePath, platform, projectName))) sb.WriteString(fmt.Sprintf("Compatibility entrypoint for runtimes that auto-detect `%s`.\n\n", relativePath)) sb.WriteString("<!-- KNOWNS GUIDELINES START -->\n\n") + + // Platform-specific file import directive so the runtime actually loads KNOWNS.md. + if relativePath == "CLAUDE.md" || relativePath == "GEMINI.md" { + sb.WriteString("@KNOWNS.md\n\n") + } + + sb.WriteString("**CRITICAL: You MUST read and follow `KNOWNS.md` in the repository root before doing any work. It is the canonical source of truth for all agent behavior in this project.**\n\n") sb.WriteString("## Canonical Guidance\n\n") sb.WriteString("- Knowns is the repository memory layer for humans and the AI-friendly working layer for agents.\n") sb.WriteString("- The source of truth for repo-level agent guidance is `KNOWNS.md`.\n") diff --git a/internal/codegen/skill_sync.go b/internal/codegen/skill_sync.go index d5c03ea..2e567f1 100644 --- a/internal/codegen/skill_sync.go +++ b/internal/codegen/skill_sync.go @@ -25,6 +25,7 @@ func ReadSyncedSkillVersion(projectRoot string) string { candidates := []string{ filepath.Join(projectRoot, ".claude", "skills", ".version"), filepath.Join(projectRoot, ".agent", "skills", ".version"), + filepath.Join(projectRoot, ".kiro", "skills", ".version"), } for _, p := range candidates { data, err := os.ReadFile(p) @@ -61,12 +62,18 @@ func platformNeedsAgentSkills(p string) bool { return p == "opencode" || p == "agents" } +// platformNeedsKiroSkills returns true if the platform writes to .kiro/skills/. +func platformNeedsKiroSkills(p string) bool { + return p == "kiro" +} + // SyncSkillsForPlatforms copies embedded built-in skills only to the directories // required by the given platforms. If platforms is empty, all directories are synced // (backwards-compatible behaviour matching SyncSkills). func SyncSkillsForPlatforms(projectRoot string, platforms []string) error { wantClaude := len(platforms) == 0 wantAgent := len(platforms) == 0 + wantKiro := len(platforms) == 0 for _, p := range platforms { if platformNeedsClaudeSkills(p) { wantClaude = true @@ -74,6 +81,9 @@ func SyncSkillsForPlatforms(projectRoot string, platforms []string) error { if platformNeedsAgentSkills(p) { wantAgent = true } + if platformNeedsKiroSkills(p) { + wantKiro = true + } } var targets []string @@ -83,6 +93,9 @@ func SyncSkillsForPlatforms(projectRoot string, platforms []string) error { if wantAgent { targets = append(targets, filepath.Join(projectRoot, ".agent", "skills")) } + if wantKiro { + targets = append(targets, filepath.Join(projectRoot, ".kiro", "skills")) + } if len(targets) == 0 { return nil } @@ -122,6 +135,7 @@ func SyncSkills(projectRoot string) error { targets := []string{ filepath.Join(projectRoot, ".claude", "skills"), filepath.Join(projectRoot, ".agent", "skills"), + filepath.Join(projectRoot, ".kiro", "skills"), } for _, targetDir := range targets { diff --git a/internal/mcp/handlers/doc.go b/internal/mcp/handlers/doc.go index 0790135..ba16410 100644 --- a/internal/mcp/handlers/doc.go +++ b/internal/mcp/handlers/doc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "strings" "time" "unicode/utf8" @@ -77,6 +78,9 @@ func RegisterDocTools(s *server.MCPServer, getStore func() *storage.Store) { mcp.WithString("section", mcp.Description("Return specific section by heading title or number (e.g., '2. Overview' or '2')"), ), + mcp.WithString("line", + mcp.Description("Return specific lines. Single line (e.g., '42') or range (e.g., '10-20')"), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { store := getStore() @@ -99,6 +103,7 @@ func RegisterDocTools(s *server.MCPServer, getStore func() *storage.Store) { info := boolArg(args, "info") tocOnly := boolArg(args, "toc") section, hasSection := stringArg(args, "section") + lineParam, hasLine := stringArg(args, "line") contentLen := utf8.RuneCountInString(doc.Content) // Approximate token count: ~4 chars per token. @@ -144,6 +149,21 @@ func RegisterDocTools(s *server.MCPServer, getStore func() *storage.Store) { return mcp.NewToolResultText(string(out)), nil } + if hasLine && lineParam != "" { + lineContent, lineLabel, err := extractLines(doc.Content, lineParam) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + result := map[string]any{ + "path": doc.Path, + "title": doc.Title, + "lines": lineLabel, + "content": lineContent, + } + out, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(out)), nil + } + if smart { const smartThreshold = 2000 if approxTokens <= smartThreshold { @@ -479,6 +499,41 @@ func extractHeadings(content string) []string { return headings } +// extractLines returns specific lines from content. +// lineParam can be "42" (single line) or "10-20" (range). +// Returns the extracted content, a human-readable label, and any error. +func extractLines(content, lineParam string) (string, string, error) { + allLines := strings.Split(content, "\n") + total := len(allLines) + + // Try range first: "10-20" + if parts := strings.SplitN(lineParam, "-", 2); len(parts) == 2 { + start, err1 := strconv.Atoi(parts[0]) + end, err2 := strconv.Atoi(parts[1]) + if err1 == nil && err2 == nil && start >= 1 && end >= start { + if start > total { + return "", "", fmt.Errorf("line %d exceeds document length (%d lines)", start, total) + } + if end > total { + end = total + } + extracted := allLines[start-1 : end] + label := fmt.Sprintf("%d-%d", start, end) + return strings.Join(extracted, "\n"), label, nil + } + } + + // Single line: "42" + line, err := strconv.Atoi(lineParam) + if err != nil { + return "", "", fmt.Errorf("invalid line parameter: %q (use '42' or '10-20')", lineParam) + } + if line < 1 || line > total { + return "", "", fmt.Errorf("line %d out of range (document has %d lines)", line, total) + } + return allLines[line-1], fmt.Sprintf("%d", line), nil +} + // extractSection finds the content of a specific heading section. // The section parameter can be a heading title (with or without # prefix) or a number like "2". func extractSection(content, section string) string { diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..f1a201d --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,174 @@ +// Package registry manages a global project registry at ~/.knowns/registry.json. +// It tracks known Knowns projects with their paths, names, and last-used timestamps. +package registry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/howznguyen/knowns/internal/util" +) + +// Project represents a registered Knowns project. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + LastUsed time.Time `json:"lastUsed"` +} + +// Registry manages the list of known projects. +type Registry struct { + Projects []Project `json:"projects"` + filePath string +} + +// NewRegistry creates a Registry with the default path (~/.knowns/registry.json). +func NewRegistry() *Registry { + home, _ := os.UserHomeDir() + return &Registry{ + filePath: filepath.Join(home, ".knowns", "registry.json"), + } +} + +// NewRegistryWithPath creates a Registry with a custom file path (for testing). +func NewRegistryWithPath(path string) *Registry { + return &Registry{filePath: path} +} + +// Load reads the registry from disk. If the file doesn't exist, starts empty. +func (r *Registry) Load() error { + data, err := os.ReadFile(r.filePath) + if err != nil { + if os.IsNotExist(err) { + r.Projects = []Project{} + return nil + } + return fmt.Errorf("read registry: %w", err) + } + return json.Unmarshal(data, &r.Projects) +} + +// Save writes the registry to disk, creating parent directories if needed. +func (r *Registry) Save() error { + if err := os.MkdirAll(filepath.Dir(r.filePath), 0755); err != nil { + return fmt.Errorf("create registry dir: %w", err) + } + data, err := json.MarshalIndent(r.Projects, "", " ") + if err != nil { + return fmt.Errorf("marshal registry: %w", err) + } + return os.WriteFile(r.filePath, data, 0644) +} + +// Add registers a project. If the path is already registered, returns the +// existing entry without duplicating. The path must contain a .knowns/ directory. +func (r *Registry) Add(projectPath string) (*Project, error) { + absPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("resolve path: %w", err) + } + + // Check .knowns/ exists + knDir := filepath.Join(absPath, ".knowns") + if info, err := os.Stat(knDir); err != nil || !info.IsDir() { + return nil, fmt.Errorf("no .knowns/ directory found at %s", absPath) + } + + // Dedup by path + if existing := r.FindByPath(absPath); existing != nil { + return existing, nil + } + + p := Project{ + ID: util.GenerateID(), + Name: filepath.Base(absPath), + Path: absPath, + LastUsed: time.Now(), + } + r.Projects = append(r.Projects, p) + return &p, r.Save() +} + +// Remove deletes a project from the registry by ID. +func (r *Registry) Remove(id string) error { + for i, p := range r.Projects { + if p.ID == id { + r.Projects = append(r.Projects[:i], r.Projects[i+1:]...) + return r.Save() + } + } + return fmt.Errorf("project %s not found", id) +} + +// SetActive updates the LastUsed timestamp for the given project ID. +func (r *Registry) SetActive(id string) error { + for i, p := range r.Projects { + if p.ID == id { + r.Projects[i].LastUsed = time.Now() + return r.Save() + } + } + return fmt.Errorf("project %s not found", id) +} + +// GetActive returns the most recently used project, or nil if empty. +func (r *Registry) GetActive() *Project { + if len(r.Projects) == 0 { + return nil + } + best := &r.Projects[0] + for i := 1; i < len(r.Projects); i++ { + if r.Projects[i].LastUsed.After(best.LastUsed) { + best = &r.Projects[i] + } + } + return best +} + +// FindByPath returns the project with the given absolute path, or nil. +func (r *Registry) FindByPath(absPath string) *Project { + for i, p := range r.Projects { + if p.Path == absPath { + return &r.Projects[i] + } + } + return nil +} + +// Scan walks the given directories (depth 1) looking for subdirectories +// that contain a .knowns/ folder. Newly discovered projects are added to +// the registry. Returns the list of newly added projects. +func (r *Registry) Scan(dirs []string) ([]Project, error) { + var added []Project + for _, dir := range dirs { + absDir, err := filepath.Abs(dir) + if err != nil { + continue + } + entries, err := os.ReadDir(absDir) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + candidate := filepath.Join(absDir, entry.Name()) + knDir := filepath.Join(candidate, ".knowns") + if info, err := os.Stat(knDir); err == nil && info.IsDir() { + if r.FindByPath(candidate) != nil { + continue // already registered + } + p, err := r.Add(candidate) + if err == nil && p != nil { + added = append(added, *p) + } + } + } + } + return added, nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..ad9420e --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,173 @@ +package registry + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// helper creates a temp dir with a .knowns/ subfolder to simulate a project. +func createFakeProject(t *testing.T, parent, name string) string { + t.Helper() + dir := filepath.Join(parent, name) + os.MkdirAll(filepath.Join(dir, ".knowns"), 0755) + return dir +} + +func TestRegistryAddAndLoad(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + projDir := createFakeProject(t, tmpDir, "my-project") + + r := NewRegistryWithPath(regFile) + if err := r.Load(); err != nil { + t.Fatalf("Load failed: %v", err) + } + + p, err := r.Add(projDir) + if err != nil { + t.Fatalf("Add failed: %v", err) + } + if p.Name != "my-project" { + t.Fatalf("Name = %q, want %q", p.Name, "my-project") + } + if p.Path != projDir { + t.Fatalf("Path = %q, want %q", p.Path, projDir) + } + + // Reload and verify persistence + r2 := NewRegistryWithPath(regFile) + if err := r2.Load(); err != nil { + t.Fatalf("Reload failed: %v", err) + } + if len(r2.Projects) != 1 { + t.Fatalf("expected 1 project after reload, got %d", len(r2.Projects)) + } + if r2.Projects[0].ID != p.ID { + t.Fatalf("ID mismatch after reload") + } +} + +func TestRegistryRemove(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + projDir := createFakeProject(t, tmpDir, "to-remove") + + r := NewRegistryWithPath(regFile) + r.Load() + p, _ := r.Add(projDir) + + if err := r.Remove(p.ID); err != nil { + t.Fatalf("Remove failed: %v", err) + } + if len(r.Projects) != 0 { + t.Fatalf("expected 0 projects after remove, got %d", len(r.Projects)) + } +} + +func TestRegistrySetActiveAndGetActive(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + proj1 := createFakeProject(t, tmpDir, "proj-a") + proj2 := createFakeProject(t, tmpDir, "proj-b") + + r := NewRegistryWithPath(regFile) + r.Load() + p1, _ := r.Add(proj1) + time.Sleep(10 * time.Millisecond) + p2, _ := r.Add(proj2) + + // p2 was added last, so it should be active + active := r.GetActive() + if active.ID != p2.ID { + t.Fatalf("expected p2 to be active, got %s", active.ID) + } + + // Set p1 as active + r.SetActive(p1.ID) + active = r.GetActive() + if active.ID != p1.ID { + t.Fatalf("expected p1 to be active after SetActive, got %s", active.ID) + } +} + +func TestRegistryAddDeduplicate(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + projDir := createFakeProject(t, tmpDir, "dup-project") + + r := NewRegistryWithPath(regFile) + r.Load() + p1, _ := r.Add(projDir) + p2, _ := r.Add(projDir) // same path again + + if p1.ID != p2.ID { + t.Fatalf("expected same ID for duplicate add, got %s vs %s", p1.ID, p2.ID) + } + if len(r.Projects) != 1 { + t.Fatalf("expected 1 project after duplicate add, got %d", len(r.Projects)) + } +} + +func TestRegistryScan(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + + // Create a parent dir with 3 subdirs, 2 of which have .knowns/ + scanDir := filepath.Join(tmpDir, "projects") + createFakeProject(t, scanDir, "repo-a") + createFakeProject(t, scanDir, "repo-b") + os.MkdirAll(filepath.Join(scanDir, "not-a-repo"), 0755) // no .knowns/ + + r := NewRegistryWithPath(regFile) + r.Load() + + added, err := r.Scan([]string{scanDir}) + if err != nil { + t.Fatalf("Scan failed: %v", err) + } + if len(added) != 2 { + t.Fatalf("expected 2 discovered projects, got %d", len(added)) + } + if len(r.Projects) != 2 { + t.Fatalf("expected 2 total projects, got %d", len(r.Projects)) + } + + // Scan again โ€” should find 0 new + added2, _ := r.Scan([]string{scanDir}) + if len(added2) != 0 { + t.Fatalf("expected 0 new projects on rescan, got %d", len(added2)) + } +} + +func TestRegistryFindByPath(t *testing.T) { + tmpDir := t.TempDir() + regFile := filepath.Join(tmpDir, "registry.json") + projDir := createFakeProject(t, tmpDir, "findme") + + r := NewRegistryWithPath(regFile) + r.Load() + r.Add(projDir) + + found := r.FindByPath(projDir) + if found == nil { + t.Fatal("FindByPath returned nil for registered project") + } + if found.Name != "findme" { + t.Fatalf("FindByPath Name = %q, want %q", found.Name, "findme") + } + + notFound := r.FindByPath("/nonexistent/path") + if notFound != nil { + t.Fatal("FindByPath should return nil for unregistered path") + } +} + +func TestRegistryGetActiveEmpty(t *testing.T) { + r := NewRegistryWithPath("/tmp/empty-reg.json") + r.Load() + if r.GetActive() != nil { + t.Fatal("GetActive should return nil for empty registry") + } +} diff --git a/internal/server/port_test.go b/internal/server/port_test.go new file mode 100644 index 0000000..e057739 --- /dev/null +++ b/internal/server/port_test.go @@ -0,0 +1,121 @@ +package server + +import ( + "fmt" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/howznguyen/knowns/internal/storage" +) + +// getFreePort asks the OS for an available TCP port. +func getFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("getFreePort: %v", err) + } + port := l.Addr().(*net.TCPAddr).Port + l.Close() + return port +} + +func TestPortFileWrittenAfterBind(t *testing.T) { + tmpDir := t.TempDir() + knDir := filepath.Join(tmpDir, ".knowns") + os.MkdirAll(knDir, 0755) + + store := storage.NewStore(knDir) + port := getFreePort(t) + + s := &Server{ + store: store, + sse: NewSSEBroker(), + port: port, + shutdownCh: make(chan struct{}, 1), + } + s.router = s.buildRouter() + + // Start server in background + errCh := make(chan error, 1) + go func() { + errCh <- s.Start() + }() + + // Give server time to bind and write port file + time.Sleep(200 * time.Millisecond) + + // Verify port file exists with correct content + portFile := filepath.Join(knDir, ".server-port") + data, err := os.ReadFile(portFile) + if err != nil { + t.Fatalf("expected .server-port to exist after bind, got error: %v", err) + } + if got := string(data); got != strconv.Itoa(port) { + t.Fatalf("port file content = %q, want %q", got, strconv.Itoa(port)) + } + + // Trigger shutdown via /api/shutdown + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Post(fmt.Sprintf("http://localhost:%d/api/shutdown", port), "application/json", nil) + if err != nil { + t.Fatalf("POST /api/shutdown failed: %v", err) + } + resp.Body.Close() + + // Wait for server to exit + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Start() returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not shut down within 5s") + } + + // Verify port file is cleaned up + if _, err := os.Stat(portFile); !os.IsNotExist(err) { + t.Fatal("expected .server-port to be removed after shutdown") + } +} + +func TestPortFileNotWrittenOnBindFailure(t *testing.T) { + tmpDir := t.TempDir() + knDir := filepath.Join(tmpDir, ".knowns") + os.MkdirAll(knDir, 0755) + + store := storage.NewStore(knDir) + + // Occupy a port first + l, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatalf("failed to occupy port: %v", err) + } + defer l.Close() + occupiedPort := l.Addr().(*net.TCPAddr).Port + + s := &Server{ + store: store, + sse: NewSSEBroker(), + port: occupiedPort, + shutdownCh: make(chan struct{}, 1), + } + s.router = s.buildRouter() + + // Start should fail because port is occupied + err = s.Start() + if err == nil { + t.Fatal("expected Start() to return error for occupied port") + } + + // Verify no port file was written + portFile := filepath.Join(knDir, ".server-port") + if _, statErr := os.Stat(portFile); !os.IsNotExist(statErr) { + t.Fatal("expected no .server-port file when bind fails") + } +} diff --git a/internal/server/routes/config.go b/internal/server/routes/config.go index 21622b8..face9b5 100644 --- a/internal/server/routes/config.go +++ b/internal/server/routes/config.go @@ -64,8 +64,15 @@ func (cr *ConfigRoutes) get(w http.ResponseWriter, r *http.Request) { if s.OpenCodeServerConfig != nil { flat["opencodeServer"] = s.OpenCodeServerConfig } + // opencodeModels: project-level overrides user-level. + // If the project has no opencodeModels, fall back to user-level preferences. if s.OpenCodeModels != nil { flat["opencodeModels"] = s.OpenCodeModels + } else { + userPrefs := storage.NewUserPrefsStore() + if up, err := userPrefs.Load(); err == nil && up.OpenCodeModels != nil { + flat["opencodeModels"] = up.OpenCodeModels + } } if s.Platforms != nil { flat["platforms"] = s.Platforms diff --git a/internal/server/routes/router.go b/internal/server/routes/router.go index 3fa376f..30bf960 100644 --- a/internal/server/routes/router.go +++ b/internal/server/routes/router.go @@ -9,7 +9,8 @@ import ( // SetupRoutes registers all /api sub-routes onto r. // The caller is responsible for mounting r at the /api prefix. -func SetupRoutes(r chi.Router, store *storage.Store, sse Broadcaster, projectRoot string) { +// manager may be nil when workspace switching is not needed (e.g. tests). +func SetupRoutes(r chi.Router, store *storage.Store, sse Broadcaster, projectRoot string, manager *storage.Manager) { // Tasks tr := &TaskRoutes{store: store, sse: sse} tr.Register(r) @@ -61,4 +62,14 @@ func SetupRoutes(r chi.Router, store *storage.Store, sse Broadcaster, projectRoo // Skills skr := NewSkillRoutes(projectRoot) skr.Register(r) + + // User-level preferences (cross-project) + upr := &UserPrefsRoutes{store: storage.NewUserPrefsStore()} + upr.Register(r) + + // Workspaces (multi-project management) + if manager != nil { + wsr := &WorkspaceRoutes{manager: manager, sse: sse} + wsr.Register(r) + } } diff --git a/internal/server/routes/user_prefs.go b/internal/server/routes/user_prefs.go new file mode 100644 index 0000000..3eef094 --- /dev/null +++ b/internal/server/routes/user_prefs.go @@ -0,0 +1,69 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/howznguyen/knowns/internal/models" + "github.com/howznguyen/knowns/internal/storage" +) + +// UserPrefsRoutes handles /api/user-preferences endpoints. +type UserPrefsRoutes struct { + store *storage.UserPrefsStore +} + +// Register wires the user preferences routes onto r. +func (upr *UserPrefsRoutes) Register(r chi.Router) { + r.Get("/user-preferences", upr.get) + r.Post("/user-preferences", upr.save) +} + +// get returns the user-level preferences. +// +// GET /api/user-preferences +func (upr *UserPrefsRoutes) get(w http.ResponseWriter, r *http.Request) { + prefs, err := upr.store.Load() + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respondJSON(w, http.StatusOK, prefs) +} + +// save updates user-level preferences (partial merge). +// +// POST /api/user-preferences +func (upr *UserPrefsRoutes) save(w http.ResponseWriter, r *http.Request) { + prefs, err := upr.store.Load() + if err != nil { + respondError(w, http.StatusInternalServerError, "load user prefs: "+err.Error()) + return + } + + var payload map[string]json.RawMessage + if err := decodeJSON(r, &payload); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) + return + } + + if raw, ok := payload["opencodeModels"]; ok { + if string(raw) == "null" { + prefs.OpenCodeModels = nil + } else { + cfg := new(models.OpenCodeModelSettings) + if err := json.Unmarshal(raw, cfg); err != nil { + respondError(w, http.StatusBadRequest, "invalid opencodeModels: "+err.Error()) + return + } + prefs.OpenCodeModels = cfg + } + } + + if err := upr.store.Save(prefs); err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + respondJSON(w, http.StatusOK, prefs) +} diff --git a/internal/server/routes/workspace.go b/internal/server/routes/workspace.go new file mode 100644 index 0000000..8046091 --- /dev/null +++ b/internal/server/routes/workspace.go @@ -0,0 +1,207 @@ +package routes + +import ( + "net/http" + "os" + "path/filepath" + + "github.com/go-chi/chi/v5" + "github.com/howznguyen/knowns/internal/storage" +) + +// WorkspaceRoutes handles /api/workspaces endpoints for multi-project management. +type WorkspaceRoutes struct { + manager *storage.Manager + sse Broadcaster +} + +// Register wires workspace routes onto r. +func (wr *WorkspaceRoutes) Register(r chi.Router) { + r.Get("/workspaces", wr.list) + r.Post("/workspaces/switch", wr.switchWorkspace) + r.Post("/workspaces/scan", wr.scan) + r.Post("/workspaces/auto-scan", wr.autoScan) + r.Delete("/workspaces/{id}", wr.remove) +} + +// list returns all registered projects from the global registry. +// +// GET /api/workspaces +func (wr *WorkspaceRoutes) list(w http.ResponseWriter, r *http.Request) { + reg := wr.manager.GetRegistry() + if reg == nil { + respondJSON(w, http.StatusOK, []interface{}{}) + return + } + projects := reg.Projects + if projects == nil { + respondJSON(w, http.StatusOK, []struct{}{}) + return + } + respondJSON(w, http.StatusOK, projects) +} + +// switchWorkspace swaps the active store to a different project. +// +// POST /api/workspaces/switch +// Body: {"id": "project-id"} +func (wr *WorkspaceRoutes) switchWorkspace(w http.ResponseWriter, r *http.Request) { + var body struct { + ID string `json:"id"` + } + if err := decodeJSON(r, &body); err != nil || body.ID == "" { + respondError(w, http.StatusBadRequest, "id is required") + return + } + + reg := wr.manager.GetRegistry() + if reg == nil { + respondError(w, http.StatusInternalServerError, "registry not available") + return + } + + // Find project by ID. + var projectPath string + for _, p := range reg.Projects { + if p.ID == body.ID { + projectPath = p.Path + break + } + } + if projectPath == "" { + respondError(w, http.StatusNotFound, "project not found") + return + } + + // Switch the active store. + if _, err := wr.manager.Switch(projectPath); err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + + // Broadcast SSE refresh event so UI reloads all data. + wr.sse.Broadcast(SSEEvent{ + Type: "refresh", + Data: map[string]string{"reason": "workspace-switch"}, + }) + + // Return the updated active project info. + for _, p := range reg.Projects { + if p.ID == body.ID { + respondJSON(w, http.StatusOK, p) + return + } + } + respondJSON(w, http.StatusOK, map[string]string{"status": "switched"}) +} + +// scan discovers new projects in the given directories. +// +// POST /api/workspaces/scan +// Body: {"dirs": ["/path/to/parent1", "/path/to/parent2"]} +func (wr *WorkspaceRoutes) scan(w http.ResponseWriter, r *http.Request) { + var body struct { + Dirs []string `json:"dirs"` + } + if err := decodeJSON(r, &body); err != nil || len(body.Dirs) == 0 { + respondError(w, http.StatusBadRequest, "dirs array is required") + return + } + + reg := wr.manager.GetRegistry() + if reg == nil { + respondError(w, http.StatusInternalServerError, "registry not available") + return + } + + added, err := reg.Scan(body.Dirs) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + if added == nil { + respondJSON(w, http.StatusOK, []struct{}{}) + return + } + respondJSON(w, http.StatusOK, added) +} + +// autoScan discovers projects in common directories automatically. +// Scans home directory and well-known project directories at depth 1. +// +// POST /api/workspaces/auto-scan +func (wr *WorkspaceRoutes) autoScan(w http.ResponseWriter, r *http.Request) { + reg := wr.manager.GetRegistry() + if reg == nil { + respondError(w, http.StatusInternalServerError, "registry not available") + return + } + + home, err := os.UserHomeDir() + if err != nil { + respondError(w, http.StatusInternalServerError, "cannot determine home directory") + return + } + + // Common project directories to scan + candidates := []string{ + home, + filepath.Join(home, "Projects"), + filepath.Join(home, "projects"), + filepath.Join(home, "Developer"), + filepath.Join(home, "developer"), + filepath.Join(home, "Documents"), + filepath.Join(home, "Code"), + filepath.Join(home, "code"), + filepath.Join(home, "repos"), + filepath.Join(home, "Repos"), + filepath.Join(home, "workspace"), + filepath.Join(home, "Workspace"), + filepath.Join(home, "src"), + filepath.Join(home, "go", "src"), + filepath.Join(home, "dev"), + filepath.Join(home, "Dev"), + } + + // Filter to only existing directories + var dirs []string + for _, d := range candidates { + if info, err := os.Stat(d); err == nil && info.IsDir() { + dirs = append(dirs, d) + } + } + + added, err := reg.Scan(dirs) + if err != nil { + respondError(w, http.StatusInternalServerError, err.Error()) + return + } + if added == nil { + respondJSON(w, http.StatusOK, []struct{}{}) + return + } + respondJSON(w, http.StatusOK, added) +} + +// remove deletes a project from the registry. +// +// DELETE /api/workspaces/{id} +func (wr *WorkspaceRoutes) remove(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + respondError(w, http.StatusBadRequest, "id is required") + return + } + + reg := wr.manager.GetRegistry() + if reg == nil { + respondError(w, http.StatusInternalServerError, "registry not available") + return + } + + if err := reg.Remove(id); err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/server/routes/workspace_test.go b/internal/server/routes/workspace_test.go new file mode 100644 index 0000000..aa0e140 --- /dev/null +++ b/internal/server/routes/workspace_test.go @@ -0,0 +1,153 @@ +package routes + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/howznguyen/knowns/internal/registry" + "github.com/howznguyen/knowns/internal/storage" +) + +// fakeBroadcaster records broadcast calls for assertions. +type fakeBroadcaster struct { + events []SSEEvent +} + +func (fb *fakeBroadcaster) Broadcast(e SSEEvent) { + fb.events = append(fb.events, e) +} + +// setupWorkspaceTest creates a test environment with registry, manager, and router. +func setupWorkspaceTest(t *testing.T) (*chi.Mux, *fakeBroadcaster, *storage.Manager, string) { + t.Helper() + tmpDir := t.TempDir() + + // Create a fake project with .knowns/ + projDir := filepath.Join(tmpDir, "test-project") + os.MkdirAll(filepath.Join(projDir, ".knowns"), 0755) + + regFile := filepath.Join(tmpDir, "registry.json") + reg := registry.NewRegistryWithPath(regFile) + reg.Load() + reg.Add(projDir) + + store := storage.NewStore(filepath.Join(projDir, ".knowns")) + mgr := storage.NewManager(store, reg) + sse := &fakeBroadcaster{} + + r := chi.NewRouter() + wr := &WorkspaceRoutes{manager: mgr, sse: sse} + wr.Register(r) + + return r, sse, mgr, tmpDir +} + +func TestWorkspaceList(t *testing.T) { + r, _, _, _ := setupWorkspaceTest(t) + + req := httptest.NewRequest("GET", "/workspaces", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /workspaces status = %d, want 200", w.Code) + } + + var projects []registry.Project + if err := json.Unmarshal(w.Body.Bytes(), &projects); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(projects) != 1 { + t.Fatalf("expected 1 project, got %d", len(projects)) + } + if projects[0].Name != "test-project" { + t.Fatalf("project name = %q, want %q", projects[0].Name, "test-project") + } +} + +func TestWorkspaceSwitch(t *testing.T) { + r, sse, mgr, tmpDir := setupWorkspaceTest(t) + + // Create a second project + proj2 := filepath.Join(tmpDir, "proj2") + os.MkdirAll(filepath.Join(proj2, ".knowns"), 0755) + reg := mgr.GetRegistry() + p2, _ := reg.Add(proj2) + + body, _ := json.Marshal(map[string]string{"id": p2.ID}) + req := httptest.NewRequest("POST", "/workspaces/switch", bytes.NewReader(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("POST /workspaces/switch status = %d, want 200", w.Code) + } + + // Verify store was switched + if mgr.GetStore().Root != filepath.Join(proj2, ".knowns") { + t.Fatalf("store root = %q, want %q", mgr.GetStore().Root, filepath.Join(proj2, ".knowns")) + } + + // Verify SSE refresh event was broadcast + if len(sse.events) != 1 { + t.Fatalf("expected 1 SSE event, got %d", len(sse.events)) + } + if sse.events[0].Type != "refresh" { + t.Fatalf("SSE event type = %q, want %q", sse.events[0].Type, "refresh") + } +} + +func TestWorkspaceScan(t *testing.T) { + r, _, _, tmpDir := setupWorkspaceTest(t) + + // Create a scan directory with 2 projects + scanDir := filepath.Join(tmpDir, "scan-parent") + os.MkdirAll(filepath.Join(scanDir, "repo-a", ".knowns"), 0755) + os.MkdirAll(filepath.Join(scanDir, "repo-b", ".knowns"), 0755) + + body, _ := json.Marshal(map[string][]string{"dirs": {scanDir}}) + req := httptest.NewRequest("POST", "/workspaces/scan", bytes.NewReader(body)) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("POST /workspaces/scan status = %d, want 200", w.Code) + } + + var added []registry.Project + if err := json.Unmarshal(w.Body.Bytes(), &added); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(added) != 2 { + t.Fatalf("expected 2 discovered projects, got %d", len(added)) + } +} + +func TestWorkspaceDelete(t *testing.T) { + r, _, mgr, _ := setupWorkspaceTest(t) + + reg := mgr.GetRegistry() + if len(reg.Projects) == 0 { + t.Fatal("expected at least 1 project in registry") + } + id := reg.Projects[0].ID + + req := httptest.NewRequest("DELETE", "/workspaces/"+id, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("DELETE /workspaces/%s status = %d, want 204", id, w.Code) + } + + // Verify removed + if len(reg.Projects) != 0 { + t.Fatalf("expected 0 projects after delete, got %d", len(reg.Projects)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 8908e77..b8cf1b8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,6 +29,7 @@ import ( "github.com/howznguyen/knowns/internal/agents/opencode" "github.com/howznguyen/knowns/internal/models" + "github.com/howznguyen/knowns/internal/registry" "github.com/howznguyen/knowns/internal/server/routes" "github.com/howznguyen/knowns/internal/storage" ui "github.com/howznguyen/knowns/ui" @@ -48,15 +49,17 @@ type Options struct { // Server is the top-level HTTP server. type Server struct { store *storage.Store + manager *storage.Manager // Multi-project store manager (may be nil) router chi.Router sse *SSEBroker port int projectRoot string opts Options - opencodeProcess *os.Process // Track auto-started OpenCode server for cleanup + opencodeDaemon *opencode.Daemon // Shared OpenCode daemon (may be nil if not configured) runtimeOpenCode *opencode.Config opencodeProxy *httputil.ReverseProxy // Shared proxy singleton โ€” reused across requests opencodeProxyMu sync.RWMutex + shutdownCh chan struct{} // Signals graceful shutdown from /api/shutdown endpoint } type openCodeConfigResolution struct { @@ -122,45 +125,6 @@ func resolveOpenCodeConfig(browserPort int, stored *models.OpenCodeServerConfig) } } -// tryAutoStartOpenCodeServer attempts to start the OpenCode server if the CLI is available. -// Returns the process if started by us, nil if already running or failed. -func tryAutoStartOpenCodeServer(host string, ports []int) (*os.Process, int, error) { - // Check if opencode CLI is available - if _, err := exec.LookPath("opencode"); err != nil { - return nil, 0, fmt.Errorf("opencode CLI not found: %w", err) - } - - for _, port := range ports { - // Skip ports that are already occupied. In auto-port mode this avoids - // binding OpenCode to the Knowns UI port or another local service. - addr := net.JoinHostPort(host, strconv.Itoa(port)) - conn, err := net.DialTimeout("tcp", addr, 250*time.Millisecond) - if err == nil { - conn.Close() - continue - } - - // Start opencode serve in background - // Command must be first positional argument - args := []string{"serve", "--hostname", host, "--port", strconv.Itoa(port), "--cors", "*"} - cmd := exec.Command("opencode", args...) - cmd.Stdout = io.Discard - cmd.Stderr = io.Discard - - // Explicitly set empty stdin to prevent interactive mode - cmd.Stdin = strings.NewReader("") - - if err := cmd.Start(); err != nil { - return nil, 0, fmt.Errorf("failed to start opencode serve on port %d: %w", port, err) - } - - log.Printf("[server] Spawned OpenCode server (pid %d) on port %d", cmd.Process.Pid, port) - return cmd.Process, port, nil - } - - return nil, 0, fmt.Errorf("no available OpenCode port for host %s in candidates %v", host, ports) -} - // buildOpenCodeProxy creates a shared reverse proxy for the OpenCode server. // The proxy uses a pooled transport with keep-alive so connections are reused // across requests instead of creating a new TCP connection each time. @@ -205,47 +169,36 @@ func buildOpenCodeProxy(cfg opencode.Config) *httputil.ReverseProxy { // projectRoot is the directory that contains the .knowns/ folder. // port is the TCP port to listen on (e.g. 3737). func NewServer(store *storage.Store, projectRoot string, port int, opts Options) *Server { - var opencodeProcess *os.Process var runtimeOpenCode *opencode.Config - - // Initialize OpenCode server client if configured - if resolution := resolveOpenCodeConfig(port, store.Config.GetOpenCodeServerConfig()); resolution.configured { - cfg := resolution.cfg - if resolution.explicitPort { - client := opencode.NewClient(cfg) - if !client.IsServerAvailable() { - var err error - opencodeProcess, _, err = tryAutoStartOpenCodeServer(cfg.Host, []int{cfg.Port}) - if err != nil { - log.Printf("[server] OpenCode server not available at %s:%d, using CLI fallback", cfg.Host, cfg.Port) - } else { - time.Sleep(2 * time.Second) - } - } - } else { - for _, candidate := range deriveOpenCodePortCandidates(port, cfg.Port) { - candidateCfg := cfg - candidateCfg.Port = candidate - if opencode.NewClient(candidateCfg).IsServerAvailable() { - cfg = candidateCfg - break + var daemon *opencode.Daemon + + // Initialize OpenCode daemon if configured (only when a project is loaded). + if store != nil { + if resolution := resolveOpenCodeConfig(port, store.Config.GetOpenCodeServerConfig()); resolution.configured { + cfg := resolution.cfg + + // For non-explicit ports, scan candidates to find one already running. + if !resolution.explicitPort { + for _, candidate := range deriveOpenCodePortCandidates(port, cfg.Port) { + candidateCfg := cfg + candidateCfg.Port = candidate + if opencode.NewClient(candidateCfg).IsServerAvailable() { + cfg = candidateCfg + break + } } } - client := opencode.NewClient(cfg) - if !client.IsServerAvailable() { - var err error - opencodeProcess, cfg.Port, err = tryAutoStartOpenCodeServer(cfg.Host, deriveOpenCodePortCandidates(port, cfg.Port)) - if err != nil { - log.Printf("[server] OpenCode server not available on derived ports for %s, using CLI fallback", cfg.Host) - } else { - time.Sleep(2 * time.Second) - } + // Use the shared daemon to ensure exactly one OpenCode process. + daemon = opencode.NewDaemon(cfg.Host, cfg.Port) + if err := daemon.EnsureRunning(); err != nil { + log.Printf("[server] OpenCode daemon not available: %v, using CLI fallback", err) + daemon = nil } - } - cfgCopy := cfg - runtimeOpenCode = &cfgCopy + cfgCopy := cfg + runtimeOpenCode = &cfgCopy + } } // Silence standard log output unless dev mode is enabled. @@ -259,23 +212,35 @@ func NewServer(store *storage.Store, projectRoot string, port int, opts Options) port: port, projectRoot: projectRoot, opts: opts, - opencodeProcess: opencodeProcess, + opencodeDaemon: daemon, runtimeOpenCode: runtimeOpenCode, + shutdownCh: make(chan struct{}, 1), } + // Create multi-project manager wrapping the initial store. + reg := registry.NewRegistry() + if err := reg.Load(); err != nil { + log.Printf("warn: could not load project registry: %v", err) + } + s.manager = storage.NewManager(store, reg) + // Build shared proxy singleton once at startup. if runtimeOpenCode != nil { s.opencodeProxy = buildOpenCodeProxy(*runtimeOpenCode) } // Startup recovery: mark all previously running workspaces as stopped. - if err := store.Workspaces.MarkAllStopped(); err != nil { - log.Printf("warn: could not mark workspaces stopped: %v", err) + if store != nil { + if err := store.Workspaces.MarkAllStopped(); err != nil { + log.Printf("warn: could not mark workspaces stopped: %v", err) + } } // Startup recovery: mark all streaming chat sessions as idle. - if err := store.Chats.MarkAllIdle(); err != nil { - log.Printf("warn: could not mark chats idle: %v", err) + if store != nil { + if err := store.Chats.MarkAllIdle(); err != nil { + log.Printf("warn: could not mark chats idle: %v", err) + } } s.router = s.buildRouter() @@ -283,60 +248,68 @@ func NewServer(store *storage.Store, projectRoot string, port int, opts Options) } // Start listens on the configured port and serves HTTP. -// It writes the port number to .knowns/.server-port before accepting connections. +// It binds the port first, then writes the port file only after a successful bind. func (s *Server) Start() error { + addr := ":" + strconv.Itoa(s.port) + + // 1. Bind FIRST โ€” fail fast if port is unavailable. + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + + // 2. Port is bound โ€” now safe to write the port file. if err := s.writePortFile(); err != nil { - // Non-fatal: log and continue. fmt.Fprintf(os.Stderr, "warn: could not write .server-port: %v\n", err) } - - // Save the active port into config.json so the UI can discover it. if err := s.savePortToConfig(); err != nil { fmt.Fprintf(os.Stderr, "warn: could not save port to config: %v\n", err) } - addr := ":" + strconv.Itoa(s.port) - srv := &http.Server{ - Addr: addr, - Handler: s.router, - } + srv := &http.Server{Handler: s.router} - // Start server in goroutine + // 3. Serve on the already-bound listener. + serverErr := make(chan error, 1) go func() { - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Printf("[server] HTTP server error: %v", err) + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + serverErr <- err } }() - // Wait for shutdown signal + // 4. Wait for shutdown signal, server error, or remote shutdown request. quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - signal.Stop(quit) - // Shutdown HTTP server + select { + case <-quit: + signal.Stop(quit) + case err := <-serverErr: + s.cleanupPortFile() + return fmt.Errorf("server error: %w", err) + case <-s.shutdownCh: + log.Printf("[server] Remote shutdown requested") + } + + // 5. Graceful shutdown. ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { log.Printf("[server] HTTP server shutdown error: %v", err) } - // Cleanup: kill auto-started OpenCode server + // 6. Cleanup port file + OpenCode. + s.cleanupPortFile() s.cleanupOpenCodeServer() log.Printf("[server] Shutdown complete") return nil } -// cleanupOpenCodeServer kills the auto-started OpenCode server process. +// cleanupOpenCodeServer stops the OpenCode daemon if we started it. func (s *Server) cleanupOpenCodeServer() { - if s.opencodeProcess != nil { - log.Printf("[server] Stopping auto-started OpenCode server (pid %d)", s.opencodeProcess.Pid) - if err := s.opencodeProcess.Kill(); err != nil { - log.Printf("[server] Failed to kill OpenCode server: %v", err) - } else { - s.opencodeProcess.Wait() - log.Printf("[server] OpenCode server stopped") + if s.opencodeDaemon != nil && s.opencodeDaemon.StartedByUs() { + if err := s.opencodeDaemon.Stop(); err != nil { + log.Printf("[server] Failed to stop OpenCode daemon: %v", err) } } } @@ -348,6 +321,23 @@ func (s *Server) writePortFile() error { return os.WriteFile(portFile, []byte(strconv.Itoa(s.port)), 0644) } +// cleanupPortFile removes the .server-port file on shutdown so stale port +// references don't linger after the server exits. +func (s *Server) cleanupPortFile() { + portFile := filepath.Join(s.store.Root, ".server-port") + os.Remove(portFile) +} + +// handleShutdown handles POST /api/shutdown for graceful remote stop. +func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "shutting down"}) + // Signal the main loop to initiate graceful shutdown. + select { + case s.shutdownCh <- struct{}{}: + default: + } +} + // savePortToConfig persists the server port into config.json so the browser // and other tools can discover the running server. func (s *Server) savePortToConfig() error { @@ -385,9 +375,12 @@ func (s *Server) buildRouter() chi.Router { // --- SSE endpoint --- r.Get("/api/events", s.sse.Subscribe) + // --- Shutdown endpoint (for remote graceful stop) --- + r.Post("/api/shutdown", s.handleShutdown) + // --- API routes --- r.Route("/api", func(r chi.Router) { - routes.SetupRoutes(r, s.store, s.sse, s.projectRoot) + routes.SetupRoutes(r, s.store, s.sse, s.projectRoot, s.manager) }) // --- OpenCode API proxy (for frontend to call OpenCode directly) --- diff --git a/internal/storage/manager.go b/internal/storage/manager.go new file mode 100644 index 0000000..9645f8c --- /dev/null +++ b/internal/storage/manager.go @@ -0,0 +1,80 @@ +// Package storage โ€” Manager provides thread-safe runtime project switching. +// It wraps a Store and a Registry, allowing workspace API handlers to swap +// the active project without restarting the server. +package storage + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/howznguyen/knowns/internal/registry" +) + +// Manager coordinates runtime project switching. All route handlers call +// GetStore() to obtain the currently active Store. +type Manager struct { + active *Store + reg *registry.Registry + mu sync.RWMutex +} + +// NewManager creates a Manager with the given initial store and registry. +func NewManager(initialStore *Store, reg *registry.Registry) *Manager { + return &Manager{ + active: initialStore, + reg: reg, + } +} + +// GetStore returns the currently active Store (read-locked). +func (m *Manager) GetStore() *Store { + m.mu.RLock() + defer m.mu.RUnlock() + return m.active +} + +// GetRegistry returns the underlying project registry. +func (m *Manager) GetRegistry() *registry.Registry { + return m.reg +} + +// Switch changes the active project to the one at projectPath. +// It validates that a .knowns/ directory exists, creates a new Store, +// and updates the registry's active project. +func (m *Manager) Switch(projectPath string) (*Store, error) { + absPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("resolve path: %w", err) + } + + knDir := filepath.Join(absPath, ".knowns") + if info, err := os.Stat(knDir); err != nil || !info.IsDir() { + return nil, fmt.Errorf("no .knowns/ directory at %s", absPath) + } + + newStore := NewStore(knDir) + + // Update registry: add if new, then set active. + if m.reg != nil { + p, err := m.reg.Add(absPath) + if err == nil && p != nil { + _ = m.reg.SetActive(p.ID) + } + } + + m.mu.Lock() + m.active = newStore + m.mu.Unlock() + + return newStore, nil +} + +// ActiveProjectRoot returns the project root (parent of .knowns/) for the +// currently active store. +func (m *Manager) ActiveProjectRoot() string { + m.mu.RLock() + defer m.mu.RUnlock() + return filepath.Dir(m.active.Root) +} diff --git a/internal/storage/manager_test.go b/internal/storage/manager_test.go new file mode 100644 index 0000000..97a62d7 --- /dev/null +++ b/internal/storage/manager_test.go @@ -0,0 +1,123 @@ +package storage + +import ( + "os" + "path/filepath" + "sync" + "testing" + + "github.com/howznguyen/knowns/internal/registry" +) + +// createFakeProject creates a temp dir with a .knowns/ subfolder. +func createFakeProject(t *testing.T, parent, name string) string { + t.Helper() + dir := filepath.Join(parent, name) + if err := os.MkdirAll(filepath.Join(dir, ".knowns"), 0755); err != nil { + t.Fatal(err) + } + return dir +} + +func TestManagerGetStore(t *testing.T) { + tmpDir := t.TempDir() + projDir := createFakeProject(t, tmpDir, "proj") + store := NewStore(filepath.Join(projDir, ".knowns")) + + m := NewManager(store, nil) + if got := m.GetStore(); got != store { + t.Fatal("GetStore should return the initial store") + } +} + +func TestManagerSwitch(t *testing.T) { + tmpDir := t.TempDir() + proj1 := createFakeProject(t, tmpDir, "proj1") + proj2 := createFakeProject(t, tmpDir, "proj2") + + regFile := filepath.Join(tmpDir, "registry.json") + reg := registry.NewRegistryWithPath(regFile) + reg.Load() + + store1 := NewStore(filepath.Join(proj1, ".knowns")) + m := NewManager(store1, reg) + + // Verify initial store + if m.GetStore().Root != store1.Root { + t.Fatal("initial store mismatch") + } + + // Switch to proj2 + newStore, err := m.Switch(proj2) + if err != nil { + t.Fatalf("Switch failed: %v", err) + } + if newStore.Root != filepath.Join(proj2, ".knowns") { + t.Fatalf("new store root = %q, want %q", newStore.Root, filepath.Join(proj2, ".knowns")) + } + if m.GetStore().Root != newStore.Root { + t.Fatal("GetStore should return the switched store") + } +} + +func TestManagerSwitchInvalidPath(t *testing.T) { + tmpDir := t.TempDir() + proj := createFakeProject(t, tmpDir, "proj") + store := NewStore(filepath.Join(proj, ".knowns")) + + m := NewManager(store, nil) + _, err := m.Switch(filepath.Join(tmpDir, "nonexistent")) + if err == nil { + t.Fatal("Switch to nonexistent path should fail") + } +} + +func TestManagerConcurrentAccess(t *testing.T) { + tmpDir := t.TempDir() + proj1 := createFakeProject(t, tmpDir, "proj1") + proj2 := createFakeProject(t, tmpDir, "proj2") + + regFile := filepath.Join(tmpDir, "registry.json") + reg := registry.NewRegistryWithPath(regFile) + reg.Load() + + store1 := NewStore(filepath.Join(proj1, ".knowns")) + m := NewManager(store1, reg) + + var wg sync.WaitGroup + // 10 goroutines reading GetStore concurrently + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 100; j++ { + s := m.GetStore() + if s == nil { + t.Error("GetStore returned nil") + } + } + }() + } + // 1 goroutine switching + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < 50; j++ { + m.Switch(proj2) + m.Switch(proj1) + } + }() + wg.Wait() +} + +func TestManagerActiveProjectRoot(t *testing.T) { + tmpDir := t.TempDir() + proj := createFakeProject(t, tmpDir, "myproj") + store := NewStore(filepath.Join(proj, ".knowns")) + + m := NewManager(store, nil) + root := m.ActiveProjectRoot() + if root != proj { + t.Fatalf("ActiveProjectRoot = %q, want %q", root, proj) + } +} diff --git a/internal/storage/user_prefs_store.go b/internal/storage/user_prefs_store.go new file mode 100644 index 0000000..3e11bfb --- /dev/null +++ b/internal/storage/user_prefs_store.go @@ -0,0 +1,64 @@ +// Package storage โ€” UserPrefsStore manages user-level preferences at ~/.knowns/preferences.json. +// These preferences apply across all projects and serve as defaults when +// a project does not define its own value. +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/howznguyen/knowns/internal/models" +) + +// UserPrefs holds user-level preferences that apply across all projects. +type UserPrefs struct { + OpenCodeModels *models.OpenCodeModelSettings `json:"opencodeModels,omitempty"` +} + +// UserPrefsStore reads and writes ~/.knowns/preferences.json. +type UserPrefsStore struct { + filePath string +} + +// NewUserPrefsStore creates a store with the default path (~/.knowns/preferences.json). +func NewUserPrefsStore() *UserPrefsStore { + home, _ := os.UserHomeDir() + return &UserPrefsStore{ + filePath: filepath.Join(home, ".knowns", "preferences.json"), + } +} + +// NewUserPrefsStoreWithPath creates a store with a custom path (for testing). +func NewUserPrefsStoreWithPath(path string) *UserPrefsStore { + return &UserPrefsStore{filePath: path} +} + +// Load reads user preferences from disk. Returns empty prefs if file doesn't exist. +func (s *UserPrefsStore) Load() (*UserPrefs, error) { + data, err := os.ReadFile(s.filePath) + if err != nil { + if os.IsNotExist(err) { + return &UserPrefs{}, nil + } + return nil, fmt.Errorf("read user prefs: %w", err) + } + var prefs UserPrefs + if err := json.Unmarshal(data, &prefs); err != nil { + return nil, fmt.Errorf("parse user prefs: %w", err) + } + return &prefs, nil +} + +// Save writes user preferences to disk, creating parent directories if needed. +func (s *UserPrefsStore) Save(prefs *UserPrefs) error { + if err := os.MkdirAll(filepath.Dir(s.filePath), 0755); err != nil { + return fmt.Errorf("create prefs dir: %w", err) + } + data, err := json.MarshalIndent(prefs, "", " ") + if err != nil { + return fmt.Errorf("marshal user prefs: %w", err) + } + return os.WriteFile(s.filePath, data, 0644) +} diff --git a/internal/storage/user_prefs_store_test.go b/internal/storage/user_prefs_store_test.go new file mode 100644 index 0000000..f8b5d11 --- /dev/null +++ b/internal/storage/user_prefs_store_test.go @@ -0,0 +1,74 @@ +package storage + +import ( + "path/filepath" + "testing" + + "github.com/howznguyen/knowns/internal/models" +) + +func TestUserPrefsStoreLoadEmpty(t *testing.T) { + t.Parallel() + store := NewUserPrefsStoreWithPath(filepath.Join(t.TempDir(), "prefs.json")) + prefs, err := store.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if prefs.OpenCodeModels != nil { + t.Fatal("expected nil OpenCodeModels for missing file") + } +} + +func TestUserPrefsStoreSaveAndLoad(t *testing.T) { + t.Parallel() + store := NewUserPrefsStoreWithPath(filepath.Join(t.TempDir(), "prefs.json")) + + prefs := &UserPrefs{ + OpenCodeModels: &models.OpenCodeModelSettings{ + Version: 1, + DefaultModel: &models.OpenCodeModelRef{ + ProviderID: "anthropic", + ModelID: "claude-sonnet-4-5", + }, + ActiveModels: []string{"anthropic:claude-sonnet-4-5", "openai:gpt-5.4"}, + }, + } + if err := store.Save(prefs); err != nil { + t.Fatalf("Save failed: %v", err) + } + + loaded, err := store.Load() + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if loaded.OpenCodeModels == nil { + t.Fatal("expected non-nil OpenCodeModels") + } + if loaded.OpenCodeModels.DefaultModel.ProviderID != "anthropic" { + t.Fatalf("ProviderID = %q, want %q", loaded.OpenCodeModels.DefaultModel.ProviderID, "anthropic") + } + if len(loaded.OpenCodeModels.ActiveModels) != 2 { + t.Fatalf("ActiveModels len = %d, want 2", len(loaded.OpenCodeModels.ActiveModels)) + } +} + +func TestUserPrefsStoreOverwrite(t *testing.T) { + t.Parallel() + store := NewUserPrefsStoreWithPath(filepath.Join(t.TempDir(), "prefs.json")) + + // Save initial + store.Save(&UserPrefs{ + OpenCodeModels: &models.OpenCodeModelSettings{ + Version: 1, + ActiveModels: []string{"a:b"}, + }, + }) + + // Overwrite with nil + store.Save(&UserPrefs{OpenCodeModels: nil}) + + loaded, _ := store.Load() + if loaded.OpenCodeModels != nil { + t.Fatal("expected nil OpenCodeModels after overwrite") + } +} diff --git a/opencode.json b/opencode.json index 8b812f8..c0dfd42 100644 --- a/opencode.json +++ b/opencode.json @@ -1,8 +1,27 @@ { "$schema": "https://opencode.ai/config.json", "mcp": { + "brave-search": { + "command": [ + "npx", + "-y", + "@brave/brave-search-mcp-server", + "--transport", + "http" + ], + "environment": { + "BRAVE_API_KEY": "{env:BRAVE_API_KEY}" + }, + "type": "local" + }, "knowns": { - "command": ["npx", "-y", "knowns", "mcp", "--stdio"], + "command": [ + "npx", + "-y", + "knowns", + "mcp", + "--stdio" + ], "enabled": true, "type": "local" } diff --git a/ui/e2e/dashboard.spec.ts b/ui/e2e/dashboard.spec.ts index 918a9b3..3b96d9c 100644 --- a/ui/e2e/dashboard.spec.ts +++ b/ui/e2e/dashboard.spec.ts @@ -23,8 +23,8 @@ test.describe("Dashboard", () => { await test.step("Key metrics are displayed", async () => { await expect(page.getByText("Total Tasks")).toBeVisible(); - await expect(page.getByText("Documents")).toBeVisible(); - await expect(page.getByText("SDD Coverage").first()).toBeVisible(); + await expect(page.getByText("Completion", { exact: true })).toBeVisible(); + await expect(page.getByText("In Progress").first()).toBeVisible(); }); }); @@ -38,7 +38,8 @@ test.describe("Dashboard", () => { }); await test.step("Tasks section shows status counts", async () => { - await expect(page.locator("section").filter({ hasText: "Tasks" }).getByText("To Do")).toBeVisible(); + // Status Distribution donut chart shows status labels for non-zero segments + await expect(page.getByText("Status Distribution")).toBeVisible(); }); }); diff --git a/ui/e2e/doc-debug.spec.ts b/ui/e2e/doc-debug.spec.ts deleted file mode 100644 index cc4d182..0000000 --- a/ui/e2e/doc-debug.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { test, expect } from "@playwright/test"; -import { startServer, type TestServer } from "./helpers"; - -let server: TestServer; -test.beforeAll(async () => { server = await startServer(); }); -test.afterAll(() => { server?.cleanup(); }); - -test("debug doc creation", async ({ page }) => { - const logs: string[] = []; - const responses: string[] = []; - - page.on("console", (msg) => logs.push(`[${msg.type()}] ${msg.text()}`)); - page.on("response", (resp) => responses.push(`${resp.status()} ${resp.url()}`)); - page.on("pageerror", (err) => logs.push(`[PAGE_ERROR] ${err.message}`)); - - await page.goto(`${server.baseURL}/docs`); - await page.waitForTimeout(1000); - - // Click New Doc - const newBtn = page.locator("button").filter({ hasText: "New Doc" }).first(); - await newBtn.click(); - await page.waitForTimeout(500); - - // Fill title - await page.locator('input[placeholder="Untitled"]').fill("Debug Doc"); - await page.waitForTimeout(200); - - // Click Create - const createBtn = page.locator("button").filter({ hasText: "Create" }).first(); - console.log("Create btn visible:", await createBtn.isVisible()); - console.log("Create btn disabled:", await createBtn.isDisabled()); - - // Clear response log before clicking - responses.length = 0; - await createBtn.click(); - await page.waitForTimeout(3000); - - // Check what happened - console.log("=== RESPONSES AFTER CLICK ==="); - for (const r of responses) { - if (r.includes("/api/")) console.log(r); - } - console.log("=== CONSOLE LOGS ==="); - for (const l of logs) { - if (l.includes("error") || l.includes("Error") || l.includes("fail") || l.includes("PAGE_ERROR")) { - console.log(l); - } - } - - // Check current page state - const hasNewDoc = await page.locator("button").filter({ hasText: "New Doc" }).isVisible().catch(() => false); - const hasCreate = await page.locator("button").filter({ hasText: "Create" }).isVisible().catch(() => false); - console.log("New Doc btn visible (back to list?):", hasNewDoc); - console.log("Create btn still visible (still on form?):", hasCreate); - - // Take screenshot - await page.screenshot({ path: "test-results/doc-debug.png" }); -}); diff --git a/ui/e2e/helpers.ts b/ui/e2e/helpers.ts index e49b977..38bbe0a 100644 --- a/ui/e2e/helpers.ts +++ b/ui/e2e/helpers.ts @@ -10,9 +10,14 @@ import { fileURLToPath } from "node:url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +const isWindows = process.platform === "win32"; +const defaultBinary = resolve( + __dirname, + isWindows ? "../../bin/knowns.exe" : "../../bin/knowns", +); const BINARY = process.env.TEST_BINARY ? resolve(process.cwd(), process.env.TEST_BINARY) - : resolve(__dirname, "../../bin/knowns"); + : defaultBinary; /** Find an available port */ function findPort(): number { diff --git a/ui/e2e/tasks.spec.ts b/ui/e2e/tasks.spec.ts index 0430651..903f602 100644 --- a/ui/e2e/tasks.spec.ts +++ b/ui/e2e/tasks.spec.ts @@ -60,8 +60,8 @@ test.describe("Tasks Page", () => { if (await newBtn.isVisible({ timeout: 3000 }).catch(() => false)) { await newBtn.click(); } else { - // Try alternative: "+" or "Create" button - await page.getByRole("button", { name: /create|add|\+/i }).first().click(); + // Table view shows a compact "New" button + await page.getByRole("button", { name: /^new$/i }).first().click(); } }); @@ -74,7 +74,7 @@ test.describe("Tasks Page", () => { }); await test.step("New task appears in list", async () => { - await expect(page.locator("tbody").getByText("New Page Task")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("New Page Task").first()).toBeVisible({ timeout: 5000 }); }); }); diff --git a/ui/e2e/ui-interactions.spec.ts b/ui/e2e/ui-interactions.spec.ts index e93cdab..6f69046 100644 --- a/ui/e2e/ui-interactions.spec.ts +++ b/ui/e2e/ui-interactions.spec.ts @@ -43,7 +43,13 @@ test.describe("Task Creation via UI", () => { }); await test.step("Click new task button", async () => { - await page.getByRole("button", { name: /new task/i }).first().click(); + const newTaskBtn = page.getByRole("button", { name: /new task/i }).first(); + if (await newTaskBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await newTaskBtn.click(); + } else { + // Table view shows a compact "New" button + await page.getByRole("button", { name: /^new$/i }).first().click(); + } }); await test.step("Type task title", async () => { @@ -55,7 +61,7 @@ test.describe("Task Creation via UI", () => { }); await test.step("Task appears in the table", async () => { - await expect(page.locator("tbody").getByText("Design login page")).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("Design login page").first()).toBeVisible({ timeout: 5000 }); }); }); diff --git a/ui/src/AppShell.tsx b/ui/src/AppShell.tsx index bc32716..b3ebad7 100644 --- a/ui/src/AppShell.tsx +++ b/ui/src/AppShell.tsx @@ -4,6 +4,7 @@ import type { Task } from "../models/task"; import { api } from "./api/client"; import { useSSEEvent } from "./contexts/SSEContext"; import { AppSidebar, TaskCreateForm, SearchCommandDialog, NotificationBell, TaskDetailSheet } from "./components/organisms"; +import { WorkspacePicker } from "./components/organisms/WorkspacePicker"; import { ConnectionStatus, ThemeToggle, ErrorBoundary } from "./components/atoms"; import { HeaderTimeTracker } from "./components/molecules"; import { AppBreadcrumb } from "./components/molecules/AppBreadcrumb"; @@ -64,6 +65,7 @@ export default function AppShell() { const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); const [showCommandDialog, setShowCommandDialog] = useState(false); + const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true); const [isDark, setIsDark] = useState(() => { if (typeof window !== "undefined") { @@ -77,6 +79,22 @@ export default function AppShell() { const currentPage = getCurrentPage(location.pathname); const isChatPage = currentPage === "chat"; + // Update document title based on current page + useEffect(() => { + const titles: Record<string, string> = { + dashboard: "Dashboard", + kanban: "Kanban", + tasks: "Tasks", + docs: "Docs", + imports: "Imports", + chat: "Chat", + config: "Settings", + }; + const pageTitle = titles[currentPage] || "Dashboard"; + const projectName = config.name || "Knowns"; + document.title = `${pageTitle} ยท ${projectName}`; + }, [currentPage, config.name]); + const handleSidebarOpenChange = (open: boolean) => { setSidebarOpen(open); }; @@ -132,6 +150,16 @@ export default function AppShell() { api.getTasks().then(setTasks).catch(console.error); }); + // Handle workspace switch โ€” reload all data + useSSEEvent("refresh", (data) => { + if (data?.reason === "workspace-switch") { + api.getTasks().then((data) => { + setTasks(data); + setLoading(false); + }).catch(console.error); + } + }); + const handleTaskCreated = () => { api.getTasks().then(setTasks).catch(console.error); }; @@ -242,6 +270,7 @@ export default function AppShell() { <AppSidebar currentPage={currentPage} onSearchClick={() => setShowCommandDialog(true)} + onWorkspacePickerClick={() => setShowWorkspacePicker(true)} /> <main className={cn("flex min-w-0 flex-1 flex-col overflow-hidden", isChatPage ? "bg-background" : "bg-background")}> <header @@ -277,13 +306,18 @@ export default function AppShell() { <div className={cn( - "flex-1 w-full overflow-x-hidden", + "flex-1 w-full overflow-x-hidden flex flex-col", isChatPage ? "min-h-0 overflow-hidden bg-muted/10" : "overflow-y-auto", )} > <ErrorBoundary> <Suspense fallback={<PageLoading />}> - {renderPage()} + <div + key={currentPage} + className="animate-page-in flex-1 flex flex-col min-h-0" + > + {renderPage()} + </div> </Suspense> </ErrorBoundary> </div> @@ -303,6 +337,11 @@ export default function AppShell() { onDocSelect={handleSearchDocSelect} /> + <WorkspacePicker + open={showWorkspacePicker} + onOpenChange={setShowWorkspacePicker} + /> + <TaskDetailSheet task={currentTaskId ? tasks.find((t) => t.id === currentTaskId) || null : null} allTasks={tasks} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index ff68d59..bf0486b 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -258,6 +258,26 @@ export async function saveConfig(config: Record<string, unknown>): Promise<void> } } +// User Preferences API (user-level, cross-project) +export async function getUserPreferences(): Promise<Record<string, unknown>> { + const res = await fetch(`${API_BASE}/api/user-preferences`); + if (!res.ok) { + throw new Error("Failed to fetch user preferences"); + } + return res.json(); +} + +export async function saveUserPreferences(prefs: Record<string, unknown>): Promise<void> { + const res = await fetch(`${API_BASE}/api/user-preferences`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(prefs), + }); + if (!res.ok) { + throw new Error("Failed to save user preferences"); + } +} + // Docs API export interface Doc { path: string; @@ -1285,3 +1305,54 @@ export const opencodeApi = { if (!res.ok) throw new Error("Failed to revert message"); }, }; + +// Workspace API +export interface WorkspaceProject { + id: string; + name: string; + path: string; + lastUsed: string; +} + +export const workspaceApi = { + async list(): Promise<WorkspaceProject[]> { + const res = await fetch(`${API_BASE}/api/workspaces`); + if (!res.ok) throw new Error("Failed to fetch workspaces"); + return res.json(); + }, + + async switchProject(id: string): Promise<WorkspaceProject> { + const res = await fetch(`${API_BASE}/api/workspaces/switch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (!res.ok) throw new Error("Failed to switch workspace"); + return res.json(); + }, + + async scan(dirs: string[]): Promise<WorkspaceProject[]> { + const res = await fetch(`${API_BASE}/api/workspaces/scan`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dirs }), + }); + if (!res.ok) throw new Error("Failed to scan workspaces"); + return res.json(); + }, + + async remove(id: string): Promise<void> { + const res = await fetch(`${API_BASE}/api/workspaces/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to remove workspace"); + }, + + async autoScan(): Promise<WorkspaceProject[]> { + const res = await fetch(`${API_BASE}/api/workspaces/auto-scan`, { + method: "POST", + }); + if (!res.ok) throw new Error("Failed to auto-scan workspaces"); + return res.json(); + }, +}; diff --git a/ui/src/components/editor/DocMentionBadge.tsx b/ui/src/components/editor/DocMentionBadge.tsx new file mode 100644 index 0000000..9dffcd9 --- /dev/null +++ b/ui/src/components/editor/DocMentionBadge.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect, useMemo } from "react"; +import { FileText } from "lucide-react"; +import { getDoc } from "../../api/client"; +import { navigateTo } from "../../lib/navigation"; +import { docMentionClass, docMentionBrokenClass } from "./mentionUtils"; + +/** + * Parse doc mention suffix โ€” extracts line, range, or heading from docPath. + * Supports both mention-style (:10-20) and URL-style (?L=10-20) suffixes. + */ +export function parseDocFragment(raw: string): { + path: string; + line?: number; + startLine?: number; + endLine?: number; + heading?: string; +} { + // Mention-style suffixes (:line, :start-end) + const rangeMatch = raw.match(/^(.+?):(\d+)-(\d+)$/); + if (rangeMatch && rangeMatch[1] && rangeMatch[2] && rangeMatch[3]) + return { path: rangeMatch[1], startLine: +rangeMatch[2], endLine: +rangeMatch[3] }; + const lineMatch = raw.match(/^(.+?):(\d+)$/); + if (lineMatch && lineMatch[1] && lineMatch[2]) + return { path: lineMatch[1], line: +lineMatch[2] }; + const headingMatch = raw.match(/^(.+?)#([a-zA-Z0-9_-]+(?:[a-zA-Z0-9_. -]*)?)$/); + if (headingMatch && headingMatch[1] && headingMatch[2]) + return { path: headingMatch[1], heading: headingMatch[2] }; + + // URL-style suffixes (?L=line, ?L=start-end) โ€” defensive fallback + const urlRangeMatch = raw.match(/^(.+?)\?L=(\d+)-(\d+)$/); + if (urlRangeMatch && urlRangeMatch[1] && urlRangeMatch[2] && urlRangeMatch[3]) + return { path: urlRangeMatch[1], startLine: +urlRangeMatch[2], endLine: +urlRangeMatch[3] }; + const urlLineMatch = raw.match(/^(.+?)\?L=(\d+)$/); + if (urlLineMatch && urlLineMatch[1] && urlLineMatch[2]) + return { path: urlLineMatch[1], line: +urlLineMatch[2] }; + + return { path: raw }; +} + +export function docFragmentToQuery(frag: ReturnType<typeof parseDocFragment>): string { + if (frag.startLine != null && frag.endLine != null) return `?L=${frag.startLine}-${frag.endLine}`; + if (frag.line != null) return `?L=${frag.line}`; + if (frag.heading) return `#${frag.heading}`; + return ""; +} + +export function docFragmentDisplay(frag: ReturnType<typeof parseDocFragment>): string { + if (frag.startLine != null && frag.endLine != null) return `:${frag.startLine}-${frag.endLine}`; + if (frag.line != null) return `:${frag.line}`; + if (frag.heading) return `#${frag.heading}`; + return ""; +} + +/** + * Doc mention badge that fetches and displays the doc title. + * Supports line (:42), range (:10-20), and heading (#overview) suffixes. + */ +export function DocMentionBadge({ + docPath, + onDocLinkClick, +}: { + docPath: string; + onDocLinkClick?: (path: string) => void; +}) { + const frag = useMemo(() => parseDocFragment(docPath), [docPath]); + const [title, setTitle] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [actualPath, setActualPath] = useState<string | null>(null); + + useEffect(() => { + let cancelled = false; + + getDoc(frag.path) + .then((doc) => { + if (!cancelled && doc) { + setTitle(doc.title || null); + setActualPath(doc.path); + setNotFound(false); + setLoading(false); + } else if (!cancelled) { + setNotFound(true); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setTitle(null); + setNotFound(true); + setLoading(false); + } + }); + + return () => { cancelled = true; }; + }, [frag.path]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (notFound) return; + const targetPath = actualPath || frag.path; + const query = docFragmentToQuery(frag); + if (onDocLinkClick) { + onDocLinkClick(`${targetPath}${query}`); + } else { + navigateTo(`/docs/${targetPath}${query}`); + } + }; + + const shortPath = frag.path.replace(/\.md$/, "").split("/").pop() || frag.path; + const suffix = docFragmentDisplay(frag); + const mentionClass = notFound ? docMentionBrokenClass : docMentionClass; + + return ( + <span + role={notFound ? undefined : "link"} + className={mentionClass} + data-doc-path={frag.path} + onClick={handleClick} + title={notFound ? `Document not found: ${frag.path}` : title ? `${title}${suffix}` : undefined} + > + <FileText className="w-3 h-3 shrink-0 opacity-60" /> + {loading ? ( + <span className="opacity-60">{shortPath}{suffix}</span> + ) : title ? ( + <> + <span className="max-w-[250px] truncate">{title}</span> + {suffix && <span className="opacity-50 text-[0.85em]">{suffix}</span>} + </> + ) : ( + <span className="max-w-[250px] truncate">{shortPath}{suffix}</span> + )} + </span> + ); +} diff --git a/ui/src/components/editor/MDRender.tsx b/ui/src/components/editor/MDRender.tsx index 6ae8c07..dd94844 100644 --- a/ui/src/components/editor/MDRender.tsx +++ b/ui/src/components/editor/MDRender.tsx @@ -4,28 +4,26 @@ import { useRef, useMemo, useState, - useEffect, - useCallback, lazy, Suspense, type ReactNode, - Component, - type ErrorInfo, } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import hljs from "highlight.js"; -import { ClipboardCheck, FileText, AlertTriangle, RefreshCw, Check, Loader2 } from "lucide-react"; +import { ClipboardCheck, Check, Loader2 } from "lucide-react"; import { useTheme } from "../../App"; -import { getTask, getDoc } from "../../api/client"; -import { normalizeKnownsTaskReferences } from "../../lib/knownsReferences"; -import { navigateTo } from "../../lib/navigation"; import { cn } from "../../lib/utils"; +import { transformMentions, toDocPath, getInlineMention } from "./mentionUtils"; +import { TaskMentionBadge } from "./TaskMentionBadge"; +import { DocMentionBadge } from "./DocMentionBadge"; +import { MarkdownErrorBoundary } from "./MarkdownErrorBoundary"; +import { extractTextFromChildren, slugifyHeading, parseHeadingMeta } from "./headingUtils"; + // Lazy load MermaidBlock for better performance const MermaidBlock = lazy(() => import("./MermaidBlock")); -// Loading fallback for Mermaid function MermaidLoading() { return ( <div className="my-4 p-4 rounded-lg border bg-muted/30 animate-pulse"> @@ -37,68 +35,6 @@ function MermaidLoading() { ); } -/** - * Error boundary to catch render errors in markdown content - */ -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -interface ErrorBoundaryProps { - children: ReactNode; - fallback?: ReactNode; - onReset?: () => void; -} - -class MarkdownErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - console.error("Markdown render error:", error, errorInfo); - } - - handleReset = () => { - this.setState({ hasError: false, error: null }); - this.props.onReset?.(); - }; - - render() { - if (this.state.hasError) { - return ( - this.props.fallback || ( - <div className="p-4 rounded-lg border border-red-500/30 bg-red-500/10"> - <div className="flex items-center gap-2 text-red-600 dark:text-red-400 mb-2"> - <AlertTriangle className="w-4 h-4" /> - <span className="font-medium">Failed to render markdown</span> - </div> - <p className="text-sm text-muted-foreground mb-3"> - {this.state.error?.message || "An error occurred while rendering the content."} - </p> - <button - type="button" - onClick={this.handleReset} - className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-muted hover:bg-muted/80 transition-colors" - > - <RefreshCw className="w-3.5 h-3.5" /> - Try again - </button> - </div> - ) - ); - } - - return this.props.children; - } -} - export interface MDRenderRef { getElement: () => HTMLElement | null; } @@ -112,399 +48,6 @@ interface MDRenderProps { onHeadingAnchorClick?: (id: string) => void; } -// Regex patterns for mentions -// Task: supports both numeric IDs (task-42, task-42.1) and alphanumeric IDs (task-pdyd2e, task-4sv3rh) -const TASK_MENTION_REGEX = /@(task-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)?)/g; -// Doc: excludes trailing punctuation (comma, semicolon, etc.) but allows colons for import paths -const DOC_MENTION_REGEX = /@docs?\/([^\s,;!?"'()]+)/g; -const KNOWNS_DOC_PATH_REGEX = /(^|[\s(])(\.knowns\/docs\/[^\s,;!?"'()]+\.md)\b/g; - -/** - * Normalize doc path - ensure .md extension - */ -function normalizeDocPath(path: string): string { - return path.endsWith(".md") ? path : `${path}.md`; -} - -function toDocPath(path: string): string { - let normalized = path.trim(); - - if (normalized.startsWith("@doc/")) { - normalized = normalized.slice(5); - } else if (normalized.startsWith("@docs/")) { - normalized = normalized.slice(6); - } else if (normalized.startsWith(".knowns/docs/")) { - normalized = normalized.slice(".knowns/docs/".length); - } else if (normalized.startsWith("/.knowns/docs/")) { - normalized = normalized.slice("/.knowns/docs/".length); - } else if (normalized.startsWith("docs/")) { - normalized = normalized.slice("docs/".length); - } else if (normalized.startsWith("/docs/")) { - normalized = normalized.slice("/docs/".length); - } else if (normalized.startsWith("/")) { - normalized = normalized.slice(1); - } - - return normalizeDocPath(normalized); -} - -function getInlineMention(raw: string): { type: "task"; taskId: string } | { type: "doc"; docPath: string } | null { - const value = raw.trim(); - - if (/^@task-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)?$/.test(value)) { - return { type: "task", taskId: value.slice(1) }; - } - - if ( - value.startsWith("@doc/") || - value.startsWith("@docs/") || - value.startsWith(".knowns/docs/") || - value.startsWith("/.knowns/docs/") - ) { - return { type: "doc", docPath: toDocPath(value) }; - } - - return null; -} - -/** - * Transform mention patterns into markdown links - * These will then be styled via the custom link component - * IMPORTANT: Skip code blocks to avoid breaking mermaid/code syntax - */ -function transformMentions(content: string): string { - // Split by fenced code blocks (```...```) and inline code (`...`) - // We only transform text outside of code blocks - const parts: string[] = []; - let lastIndex = 0; - - // Match fenced code blocks (```...```) and inline code (`...`) - // Fenced blocks: ```language\n...\n``` - // Inline code: `...` - const codeBlockRegex = /(```[\s\S]*?```|`[^`\n]+`)/g; - let match: RegExpExecArray | null; - - while ((match = codeBlockRegex.exec(content)) !== null) { - // Add text before this code block (transform it) - if (match.index > lastIndex) { - parts.push(transformMentionsInText(content.slice(lastIndex, match.index))); - } - // Add code block as-is (don't transform) - parts.push(match[0]); - lastIndex = match.index + match[0].length; - } - - // Add remaining text after last code block - if (lastIndex < content.length) { - parts.push(transformMentionsInText(content.slice(lastIndex))); - } - - return parts.join(""); -} - -/** - * Transform mentions in regular text (not code) - */ -function transformMentionsInText(text: string): string { - let transformed = normalizeKnownsTaskReferences(text); - - // Transform @task-123 to [@@task-123](/tasks/task-123) - transformed = transformed.replace(TASK_MENTION_REGEX, "[@@$1](/tasks/$1)"); - - // Transform @doc/path or @docs/path to [@@doc/path.md](/docs/path.md) - transformed = transformed.replace(DOC_MENTION_REGEX, (_match, docPath) => { - // Strip trailing punctuation that's not part of the path - let cleanPath = docPath; - // Remove trailing colons, dots (unless part of extension like .md) - cleanPath = cleanPath.replace(/[:]+$/, ""); - if (cleanPath.endsWith(".") && !cleanPath.match(/\.\w+$/)) { - cleanPath = cleanPath.slice(0, -1); - } - const normalizedPath = toDocPath(cleanPath); - return `[@@doc/${normalizedPath}](/docs/${normalizedPath})`; - }); - - transformed = transformed.replace(KNOWNS_DOC_PATH_REGEX, (_match, prefix, docPath) => { - const normalizedPath = toDocPath(docPath); - return `${prefix}[@@doc/${normalizedPath}](/docs/${normalizedPath})`; - }); - - return transformed; -} - -// Status colors for task badges -const STATUS_STYLES: Record<string, string> = { - todo: "bg-muted-foreground/50", - "in-progress": "bg-yellow-500", - "in-review": "bg-purple-500", - blocked: "bg-red-500", - done: "bg-green-500", -}; - -// Notion-like mention styles: inline, subtle bg, underline on hover -const mentionBase = - "inline-flex items-center gap-1 px-1 py-px rounded text-[0.9em] font-medium cursor-pointer select-none transition-all no-underline"; - -const taskMentionClass = - `${mentionBase} bg-green-500/8 text-green-700 hover:bg-green-500/15 hover:underline decoration-green-500/40 dark:text-green-400`; - -const taskMentionBrokenClass = - `${mentionBase} bg-red-500/8 text-red-600 line-through opacity-70 cursor-not-allowed dark:text-red-400`; - -const docMentionClass = - `${mentionBase} bg-blue-500/8 text-blue-700 hover:bg-blue-500/15 hover:underline decoration-blue-500/40 dark:text-blue-400`; - -const docMentionBrokenClass = - `${mentionBase} bg-red-500/8 text-red-600 line-through opacity-70 cursor-not-allowed dark:text-red-400`; - -/** - * Task mention badge that fetches and displays the task title and status - * Shows red warning style when task is not found - */ -function TaskMentionBadge({ - taskId, - onTaskLinkClick, -}: { - taskId: string; - onTaskLinkClick?: (taskId: string) => void; -}) { - const [title, setTitle] = useState<string | null>(null); - const [status, setStatus] = useState<string | null>(null); - const [loading, setLoading] = useState(true); - const [notFound, setNotFound] = useState(false); - - const taskNumber = taskId.replace("task-", ""); - - useEffect(() => { - let cancelled = false; - - // API uses just the number, not "task-33" - getTask(taskNumber) - .then((task) => { - if (!cancelled) { - setTitle(task.title); - setStatus(task.status); - setNotFound(false); - setLoading(false); - } - }) - .catch(() => { - if (!cancelled) { - setTitle(null); - setStatus(null); - setNotFound(true); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [taskNumber]); - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Don't navigate if task not found - if (notFound) return; - if (onTaskLinkClick) { - onTaskLinkClick(taskNumber); - } else { - navigateTo(`/kanban/${taskNumber}`); - } - }; - - const statusStyle = status - ? STATUS_STYLES[status] || STATUS_STYLES.todo - : null; - - const mentionClass = notFound ? taskMentionBrokenClass : taskMentionClass; - - return ( - <span - role={notFound ? undefined : "link"} - className={mentionClass} - data-task-id={taskNumber} - onClick={handleClick} - title={notFound ? `Task not found: ${taskNumber}` : title || undefined} - > - {statusStyle && ( - <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusStyle}`} /> - )} - {loading ? ( - <span className="opacity-60">#{taskNumber}</span> - ) : title ? ( - <span className="max-w-[250px] truncate">{title}</span> - ) : ( - <span>#{taskNumber}</span> - )} - </span> - ); -} - -/** - * Doc mention badge that fetches and displays the doc title - * Shows red warning style when doc is not found - */ -function DocMentionBadge({ - docPath, - onDocLinkClick, -}: { - docPath: string; - onDocLinkClick?: (path: string) => void; -}) { - const [title, setTitle] = useState<string | null>(null); - const [loading, setLoading] = useState(true); - const [notFound, setNotFound] = useState(false); - const [actualPath, setActualPath] = useState<string | null>(null); - - useEffect(() => { - let cancelled = false; - - getDoc(docPath) - .then((doc) => { - if (!cancelled && doc) { - setTitle(doc.title || null); - setActualPath(doc.path); // Store actual path from API - setNotFound(false); - setLoading(false); - } else if (!cancelled) { - setNotFound(true); - setLoading(false); - } - }) - .catch(() => { - if (!cancelled) { - setTitle(null); - setNotFound(true); - setLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [docPath]); - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Don't navigate if doc not found - if (notFound) return; - // Use actualPath from API (includes import source prefix) instead of docPath parameter - const targetPath = actualPath || docPath; - if (onDocLinkClick) { - onDocLinkClick(targetPath); - } else { - navigateTo(`/docs/${targetPath}`); - } - }; - - // Display filename without extension for shorter display - const shortPath = docPath.replace(/\.md$/, "").split("/").pop() || docPath; - - const mentionClass = notFound ? docMentionBrokenClass : docMentionClass; - - return ( - <span - role={notFound ? undefined : "link"} - className={mentionClass} - data-doc-path={docPath} - onClick={handleClick} - title={notFound ? `Document not found: ${docPath}` : title || undefined} - > - <FileText className="w-3 h-3 shrink-0 opacity-60" /> - {loading ? ( - <span className="opacity-60">{shortPath}</span> - ) : title ? ( - <span className="max-w-[250px] truncate">{title}</span> - ) : ( - <span className="max-w-[250px] truncate">{shortPath}</span> - )} - </span> - ); -} - -function extractTextFromChildren(children: ReactNode): string { - if (typeof children === "string") return children; - if (typeof children === "number") return String(children); - if (!children) return ""; - if (Array.isArray(children)) return children.map(extractTextFromChildren).join(""); - if (typeof children === "object" && "props" in children) { - return extractTextFromChildren((children as { props: { children?: ReactNode } }).props.children); - } - return ""; -} - -function slugifyHeading(text: string): string { - return text - .toLowerCase() - .replace(/[^\w\s-]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); -} - -function getHeadingNumber(counts: number[], level: number): string { - const depth = Math.max(0, level - 2); - - for (let i = 0; i < depth; i += 1) { - if (counts[i] === 0) counts[i] = 1; - } - - counts[depth] = (counts[depth] || 0) + 1; - - for (let i = depth + 1; i < counts.length; i += 1) { - counts[i] = 0; - } - - return counts.slice(0, depth + 1).join("."); -} - -interface HeadingMeta { - level: number; - text: string; - number: string; - id: string; -} - -function parseHeadingMeta(markdown: string): HeadingMeta[] { - const items: HeadingMeta[] = []; - const lines = markdown.split("\n"); - let inCodeBlock = false; - const counts = [0, 0, 0]; - - for (const line of lines) { - if (line.trimStart().startsWith("```")) { - inCodeBlock = !inCodeBlock; - continue; - } - if (inCodeBlock) continue; - - const match = line.match(/^(#{2,4})\s+(.+)/); - if (!match?.[1] || !match[2]) continue; - - const level = match[1].length; - const text = match[2] - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/\*(.+?)\*/g, "$1") - .replace(/`(.+?)`/g, "$1") - .replace(/\[(.+?)\]\(.+?\)/g, "$1") - .trim(); - const number = getHeadingNumber(counts, level); - const slug = slugifyHeading(text); - - items.push({ - level, - text, - number, - id: slug ? `${number}-${slug}` : number, - }); - } - - return items; -} - /** * Read-only markdown renderer with mention badge support */ @@ -513,12 +56,8 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( const { isDark } = useTheme(); const containerRef = useRef<HTMLDivElement>(null); - // Transform mentions in the markdown content - const transformedMarkdown = useMemo(() => { - return transformMentions(markdown || ""); - }, [markdown]); + const transformedMarkdown = useMemo(() => transformMentions(markdown || ""), [markdown]); - // Expose ref methods useImperativeHandle(ref, () => ({ getElement: () => containerRef.current, })); @@ -526,15 +65,10 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( const headingMeta = useMemo(() => parseHeadingMeta(markdown || ""), [markdown]); let headingRenderIndex = 0; - // Heading with anchor link const Heading = ({ level, children, ...props }: { level: number; children?: ReactNode }) => { const Tag = `h${level}` as const; if (!showHeadingAnchors) { - return ( - <Tag {...props}> - {children} - </Tag> - ); + return <Tag {...props}>{children}</Tag>; } const text = extractTextFromChildren(children); @@ -545,11 +79,9 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( const id = meta?.id ?? slugifyHeading(text); const headingClassName = - level === 2 + level <= 3 ? "group relative scroll-mt-4 flex items-baseline gap-2" - : level === 3 - ? "group relative scroll-mt-4 flex items-baseline gap-2" - : "group relative scroll-mt-4 flex items-baseline gap-1.5"; + : "group relative scroll-mt-4 flex items-baseline gap-1.5"; return ( <Tag id={id} className={headingClassName} {...props}> @@ -567,7 +99,6 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( if (onHeadingAnchorClick) { e.preventDefault(); onHeadingAnchorClick(id); - return; } }} > @@ -577,10 +108,8 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( ); }; - // Custom components for react-markdown const components = useMemo( () => ({ - // Custom heading components that generate IDs for TOC navigation h2: ({ children, ...props }: { children?: ReactNode }) => ( <Heading level={2} {...props}>{children}</Heading> ), @@ -591,50 +120,24 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( <Heading level={4} {...props}>{children}</Heading> ), - // Custom link component that renders mention badges a: ({ href, children }: { href?: string; children?: ReactNode }) => { const text = String(children); - // Check if this is a task mention (starts with @@task-) if (text.startsWith("@@task-")) { - const taskId = text.slice(2); // Remove @@ - return ( - <TaskMentionBadge - taskId={taskId} - onTaskLinkClick={onTaskLinkClick} - /> - ); + return <TaskMentionBadge taskId={text.slice(2)} onTaskLinkClick={onTaskLinkClick} />; } - // Check if this is a doc mention (starts with @@doc/) if (text.startsWith("@@doc/")) { - const docPath = text.slice(6); // Remove @@doc/ - return ( - <DocMentionBadge - docPath={docPath} - onDocLinkClick={onDocLinkClick} - /> - ); + return <DocMentionBadge docPath={text.slice(6)} onDocLinkClick={onDocLinkClick} />; } if (href && (href.startsWith("@doc/") || href.startsWith("@docs/") || href.startsWith(".knowns/docs/") || href.startsWith("/.knowns/docs/"))) { - return ( - <DocMentionBadge - docPath={toDocPath(href)} - onDocLinkClick={onDocLinkClick} - /> - ); + return <DocMentionBadge docPath={toDocPath(href)} onDocLinkClick={onDocLinkClick} />; } - // Regular link - return ( - <a href={href} className="text-primary hover:underline"> - {children} - </a> - ); + return <a href={href} className="text-primary hover:underline">{children}</a>; }, - // Custom code component that handles mermaid blocks and syntax highlighting code: ({ className: codeClassName, children, @@ -648,44 +151,24 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( const match = /language-(\w+)/.exec(codeClassName || ""); const language = match?.[1]; const codeContent = String(children).replace(/\n$/, ""); - - // Check if this is inline code (no language and single line without newlines) const isInline = !language && !String(children).includes("\n"); - // Inline code if (isInline) { const inlineMention = getInlineMention(codeContent); if (inlineMention?.type === "task") { - return ( - <TaskMentionBadge - taskId={inlineMention.taskId} - onTaskLinkClick={onTaskLinkClick} - /> - ); + return <TaskMentionBadge taskId={inlineMention.taskId} onTaskLinkClick={onTaskLinkClick} />; } - if (inlineMention?.type === "doc") { - return ( - <DocMentionBadge - docPath={inlineMention.docPath} - onDocLinkClick={onDocLinkClick} - /> - ); + return <DocMentionBadge docPath={inlineMention.docPath} onDocLinkClick={onDocLinkClick} />; } - return ( - <code - className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-break-spaces break-words [overflow-wrap:anywhere]" - {...props} - > + <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm whitespace-break-spaces break-words [overflow-wrap:anywhere]" {...props}> {children} </code> ); } - // Handle mermaid code blocks (lazy loaded) if (language === "mermaid") { - // Use code hash as key to ensure stable identity const key = `mermaid-${codeContent.length}-${codeContent.slice(0, 50).replace(/\s/g, '')}`; return ( <Suspense key={key} fallback={<MermaidLoading />}> @@ -694,7 +177,6 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( ); } - // Block code with syntax highlighting using highlight.js let highlightedCode: string; try { if (language && hljs.getLanguage(language)) { @@ -714,7 +196,6 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( ); }, - // Custom pre to wrap code blocks with copy button pre: ({ children, ...props }: { children?: ReactNode }) => { const [copied, setCopied] = useState(false); const preRef = useRef<HTMLPreElement>(null); @@ -737,36 +218,20 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( > {copied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <ClipboardCheck className="w-3.5 h-3.5" />} </button> - <pre - ref={preRef} - className="p-4 rounded-lg overflow-x-auto text-sm hljs-pre" - {...props} - > + <pre ref={preRef} className="p-4 rounded-lg overflow-x-auto text-sm hljs-pre" {...props}> {children} </pre> </div> ); }, - // Custom input for checkboxes (task lists) - input: ({ - type, - checked, - disabled, - ...props - }: { - type?: string; - checked?: boolean; - disabled?: boolean; - }) => { + input: ({ type, checked, disabled, ...props }: { type?: string; checked?: boolean; disabled?: boolean }) => { if (type === "checkbox") { return ( <span className={cn( "inline-flex items-center justify-center h-4 w-4 shrink-0 rounded-sm border mr-2 align-text-bottom", - checked - ? "bg-primary border-primary text-primary-foreground" - : "border-muted-foreground/50" + checked ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50" )} aria-checked={checked} role="checkbox" @@ -778,77 +243,41 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( return <input type={type} checked={checked} disabled={disabled} {...props} />; }, - // Custom li for task list items (remove bullet) - li: ({ - children, - className, - ...props - }: { - children?: ReactNode; - className?: string; - }) => { + li: ({ children, className, ...props }: { children?: ReactNode; className?: string }) => { const isTaskListItem = className?.includes("task-list-item"); return ( - <li - className={cn( - className, - isTaskListItem && "list-none ml-0 flex items-start gap-0" - )} - {...props} - > + <li className={cn(className, isTaskListItem && "list-none ml-0 flex items-start gap-0")} {...props}> {children} </li> ); }, - // Custom ul for task lists (remove padding for task lists) - ul: ({ - children, - className, - ...props - }: { - children?: ReactNode; - className?: string; - }) => { + ul: ({ children, className, ...props }: { children?: ReactNode; className?: string }) => { const isTaskList = className?.includes("contains-task-list"); return ( - <ul - className={cn( - className, - isTaskList && "list-none pl-0" - )} - {...props} - > + <ul className={cn(className, isTaskList && "list-none pl-0")} {...props}> {children} </ul> ); }, - // Custom table components for better styling with copy button table: ({ children, ...props }: { children?: ReactNode }) => { const [copied, setCopied] = useState(false); const tableRef = useRef<HTMLTableElement>(null); const handleCopyTable = () => { if (!tableRef.current) return; - - // Convert table to markdown const rows = tableRef.current.querySelectorAll("tr"); const markdownRows: string[] = []; - rows.forEach((row, rowIndex) => { const cells = row.querySelectorAll("th, td"); const cellTexts = Array.from(cells).map((cell) => cell.textContent?.trim() || ""); markdownRows.push(`| ${cellTexts.join(" | ")} |`); - - // Add separator after header row if (rowIndex === 0) { markdownRows.push(`| ${cellTexts.map(() => "---").join(" | ")} |`); } }); - - const markdown = markdownRows.join("\n"); - navigator.clipboard.writeText(markdown).then(() => { + navigator.clipboard.writeText(markdownRows.join("\n")).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); @@ -872,39 +301,19 @@ const MDRender = forwardRef<MDRenderRef, MDRenderProps>( }, thead: ({ children, ...props }: { children?: ReactNode }) => ( - <thead className="bg-muted" {...props}> - {children} - </thead> + <thead className="bg-muted" {...props}>{children}</thead> ), - tbody: ({ children, ...props }: { children?: ReactNode }) => ( - <tbody className="divide-y divide-border/50" {...props}> - {children} - </tbody> + <tbody className="divide-y divide-border/50" {...props}>{children}</tbody> ), - tr: ({ children, ...props }: { children?: ReactNode }) => ( - <tr className="hover:bg-muted/50 transition-colors" {...props}> - {children} - </tr> + <tr className="hover:bg-muted/50 transition-colors" {...props}>{children}</tr> ), - th: ({ children, ...props }: { children?: ReactNode }) => ( - <th - className="px-4 py-3 text-left font-semibold border-b-2 border-border" - {...props} - > - {children} - </th> + <th className="px-4 py-3 text-left font-semibold border-b-2 border-border" {...props}>{children}</th> ), - td: ({ children, ...props }: { children?: ReactNode }) => ( - <td - className="px-4 py-3 border-b border-border/30" - {...props} - > - {children} - </td> + <td className="px-4 py-3 border-b border-border/30" {...props}>{children}</td> ), }), [Heading, onDocLinkClick, onTaskLinkClick, isDark] diff --git a/ui/src/components/editor/MDRenderWithHighlight.tsx b/ui/src/components/editor/MDRenderWithHighlight.tsx new file mode 100644 index 0000000..3a582cd --- /dev/null +++ b/ui/src/components/editor/MDRenderWithHighlight.tsx @@ -0,0 +1,166 @@ +import { useMemo, useRef, useEffect, forwardRef } from "react"; +import MDRender from "./MDRender"; + +interface MDRenderWithHighlightProps { + content: string; + lineHighlight?: { start: number; end: number } | null; + className?: string; + onDocLinkClick?: (path: string) => void; + onTaskLinkClick?: (taskId: string) => void; + showHeadingAnchors?: boolean; + onHeadingAnchorClick?: (id: string) => void; + /** Called when user dismisses the highlight (e.g. clicking X) */ + onDismissHighlight?: () => void; +} + +/** + * Expand [start, end] (1-based) to fully contain any fenced code block or + * table that overlaps the range, so we never split mid-block. + */ +function expandToBlockBoundaries(lines: string[], start: number, end: number): { start: number; end: number } { + let s = start - 1; // convert to 0-based + let e = end - 1; + + // Walk backwards from s to find any unclosed fenced code block + let fenceDepth = 0; + for (let i = s; i >= 0; i--) { + if (/^(`{3,}|~{3,})/.test(lines[i])) { + fenceDepth++; + if (fenceDepth % 2 === 1) { + // Odd count means we're inside a block that opened at i + s = i; + // Find the closing fence + const opener = lines[i].match(/^(`{3,}|~{3,})/)?.[1] ?? "```"; + for (let j = i + 1; j < lines.length; j++) { + if (lines[j].startsWith(opener) && lines[j].trim() === opener) { + e = Math.max(e, j); + break; + } + } + break; + } + } + } + + // Expand end to close any unclosed fenced block within [s, e] + let inFence = false; + let fenceMarker = ""; + for (let i = s; i <= e; i++) { + const m = lines[i].match(/^(`{3,}|~{3,})/); + if (m) { + if (!inFence) { + inFence = true; + fenceMarker = m[1]; + } else if (lines[i].startsWith(fenceMarker) && lines[i].trim() === fenceMarker) { + inFence = false; + fenceMarker = ""; + } + } + } + if (inFence) { + // Find closing fence + for (let i = e + 1; i < lines.length; i++) { + if (lines[i].startsWith(fenceMarker) && lines[i].trim() === fenceMarker) { + e = i; + break; + } + } + } + + // Expand to include full table: if any line in range is a table row, include + // contiguous table lines above and below + const isTableRow = (l: string) => /^\s*\|/.test(l) || /\|/.test(l); + if (lines.slice(s, e + 1).some(isTableRow)) { + while (s > 0 && isTableRow(lines[s - 1])) s--; + while (e < lines.length - 1 && isTableRow(lines[e + 1])) e++; + } + + return { start: s + 1, end: e + 1 }; // back to 1-based +} + +/** + * MDRender with optional line-range highlight. + * Splits markdown into before/highlighted/after sections and renders them + * with the highlighted section visually emphasized and the rest dimmed. + */ +export const MDRenderWithHighlight = forwardRef<HTMLDivElement, MDRenderWithHighlightProps>( + ({ content, lineHighlight, className = "", onDocLinkClick, onTaskLinkClick, showHeadingAnchors, onHeadingAnchorClick, onDismissHighlight }, ref) => { + const highlightRef = useRef<HTMLDivElement>(null); + + const parts = useMemo(() => { + if (!lineHighlight || !content) return null; + const lines = content.split("\n"); + const rawStart = Math.max(1, lineHighlight.start); + const rawEnd = Math.min(lines.length, lineHighlight.end); + if (rawStart > lines.length) return null; + + const { start, end } = expandToBlockBoundaries(lines, rawStart, rawEnd); + const origLabel = rawStart === rawEnd ? `Line ${rawStart}` : `Lines ${rawStart}โ€“${rawEnd}`; + + return { + before: lines.slice(0, start - 1).join("\n"), + highlighted: lines.slice(start - 1, end).join("\n"), + after: lines.slice(end).join("\n"), + label: origLabel, + }; + }, [content, lineHighlight]); + + // Scroll highlighted section into view + useEffect(() => { + if (parts && highlightRef.current) { + requestAnimationFrame(() => + highlightRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }) + ); + } + }, [parts]); + + // Expose the highlight element via forwarded ref + useEffect(() => { + if (ref && typeof ref === "object") { + (ref as React.MutableRefObject<HTMLDivElement | null>).current = highlightRef.current; + } + }, [ref]); + + const sharedProps = { onDocLinkClick, onTaskLinkClick, showHeadingAnchors, onHeadingAnchorClick }; + + if (!parts) { + return <MDRender markdown={content} className={className} {...sharedProps} />; + } + + return ( + <div className={className}> + {parts.before && ( + <div className="opacity-40 pointer-events-none select-none"> + <MDRender markdown={parts.before} {...sharedProps} /> + </div> + )} + + <div ref={highlightRef} className="relative rounded-lg border border-amber-300/60 bg-amber-50/50 dark:border-amber-500/30 dark:bg-amber-950/20 px-4 py-2 my-2"> + <div className="flex items-center justify-between mb-1"> + <span className="text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/60 px-1.5 py-0.5 rounded"> + {parts.label} + </span> + {onDismissHighlight && ( + <button + type="button" + onClick={onDismissHighlight} + className="text-[10px] text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-200 transition-colors" + > + Dismiss + </button> + )} + </div> + <MDRender markdown={parts.highlighted} {...sharedProps} /> + </div> + + {parts.after && ( + <div className="opacity-40 pointer-events-none select-none"> + <MDRender markdown={parts.after} {...sharedProps} /> + </div> + )} + </div> + ); + } +); + +MDRenderWithHighlight.displayName = "MDRenderWithHighlight"; diff --git a/ui/src/components/editor/MarkdownErrorBoundary.tsx b/ui/src/components/editor/MarkdownErrorBoundary.tsx new file mode 100644 index 0000000..1162ad5 --- /dev/null +++ b/ui/src/components/editor/MarkdownErrorBoundary.tsx @@ -0,0 +1,61 @@ +import { Component, type ReactNode, type ErrorInfo } from "react"; +import { AlertTriangle, RefreshCw } from "lucide-react"; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; + onReset?: () => void; +} + +export class MarkdownErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("Markdown render error:", error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + this.props.onReset?.(); + }; + + override render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( + <div className="p-4 rounded-lg border border-red-500/30 bg-red-500/10"> + <div className="flex items-center gap-2 text-red-600 dark:text-red-400 mb-2"> + <AlertTriangle className="w-4 h-4" /> + <span className="font-medium">Failed to render markdown</span> + </div> + <p className="text-sm text-muted-foreground mb-3"> + {this.state.error?.message || "An error occurred while rendering the content."} + </p> + <button + type="button" + onClick={this.handleReset} + className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md bg-muted hover:bg-muted/80 transition-colors" + > + <RefreshCw className="w-3.5 h-3.5" /> + Try again + </button> + </div> + ) + ); + } + + return this.props.children; + } +} diff --git a/ui/src/components/editor/TaskMentionBadge.tsx b/ui/src/components/editor/TaskMentionBadge.tsx new file mode 100644 index 0000000..dbc1477 --- /dev/null +++ b/ui/src/components/editor/TaskMentionBadge.tsx @@ -0,0 +1,83 @@ +import { useState, useEffect } from "react"; +import { getTask } from "../../api/client"; +import { navigateTo } from "../../lib/navigation"; +import { STATUS_STYLES, taskMentionClass, taskMentionBrokenClass } from "./mentionUtils"; + +export function TaskMentionBadge({ + taskId, + onTaskLinkClick, +}: { + taskId: string; + onTaskLinkClick?: (taskId: string) => void; +}) { + const [title, setTitle] = useState<string | null>(null); + const [status, setStatus] = useState<string | null>(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + const taskNumber = taskId.replace("task-", ""); + + useEffect(() => { + let cancelled = false; + + getTask(taskNumber) + .then((task) => { + if (!cancelled) { + setTitle(task.title); + setStatus(task.status); + setNotFound(false); + setLoading(false); + } + }) + .catch(() => { + if (!cancelled) { + setTitle(null); + setStatus(null); + setNotFound(true); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [taskNumber]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (notFound) return; + if (onTaskLinkClick) { + onTaskLinkClick(taskNumber); + } else { + navigateTo(`/kanban/${taskNumber}`); + } + }; + + const statusStyle = status + ? STATUS_STYLES[status] || STATUS_STYLES.todo + : null; + + const mentionClass = notFound ? taskMentionBrokenClass : taskMentionClass; + + return ( + <span + role={notFound ? undefined : "link"} + className={mentionClass} + data-task-id={taskNumber} + onClick={handleClick} + title={notFound ? `Task not found: ${taskNumber}` : title || undefined} + > + {statusStyle && ( + <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${statusStyle}`} /> + )} + {loading ? ( + <span className="opacity-60">#{taskNumber}</span> + ) : title ? ( + <span className="max-w-[250px] truncate">{title}</span> + ) : ( + <span>#{taskNumber}</span> + )} + </span> + ); +} diff --git a/ui/src/components/editor/headingUtils.ts b/ui/src/components/editor/headingUtils.ts new file mode 100644 index 0000000..dd66b65 --- /dev/null +++ b/ui/src/components/editor/headingUtils.ts @@ -0,0 +1,81 @@ +import type { ReactNode } from "react"; + +export function extractTextFromChildren(children: ReactNode): string { + if (typeof children === "string") return children; + if (typeof children === "number") return String(children); + if (!children) return ""; + if (Array.isArray(children)) return children.map(extractTextFromChildren).join(""); + if (typeof children === "object" && "props" in children) { + return extractTextFromChildren((children as { props: { children?: ReactNode } }).props.children); + } + return ""; +} + +export function slugifyHeading(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function getHeadingNumber(counts: number[], level: number): string { + const depth = Math.max(0, level - 2); + + for (let i = 0; i < depth; i += 1) { + if (counts[i] === 0) counts[i] = 1; + } + + counts[depth] = (counts[depth] || 0) + 1; + + for (let i = depth + 1; i < counts.length; i += 1) { + counts[i] = 0; + } + + return counts.slice(0, depth + 1).join("."); +} + +export interface HeadingMeta { + level: number; + text: string; + number: string; + id: string; +} + +export function parseHeadingMeta(markdown: string): HeadingMeta[] { + const items: HeadingMeta[] = []; + const lines = markdown.split("\n"); + let inCodeBlock = false; + const counts = [0, 0, 0]; + + for (const line of lines) { + if (line.trimStart().startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + if (inCodeBlock) continue; + + const match = line.match(/^(#{2,4})\s+(.+)/); + if (!match?.[1] || !match[2]) continue; + + const level = match[1].length; + const text = match[2] + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/\*(.+?)\*/g, "$1") + .replace(/`(.+?)`/g, "$1") + .replace(/\[(.+?)\]\(.+?\)/g, "$1") + .trim(); + const number = getHeadingNumber(counts, level); + const slug = slugifyHeading(text); + + items.push({ + level, + text, + number, + id: slug ? `${number}-${slug}` : number, + }); + } + + return items; +} diff --git a/ui/src/components/editor/mentionUtils.ts b/ui/src/components/editor/mentionUtils.ts new file mode 100644 index 0000000..3732274 --- /dev/null +++ b/ui/src/components/editor/mentionUtils.ts @@ -0,0 +1,195 @@ +import { normalizeKnownsTaskReferences } from "../../lib/knownsReferences"; + +// Regex patterns for mentions +// Task: supports both numeric IDs (task-42, task-42.1) and alphanumeric IDs (task-pdyd2e, task-4sv3rh) +const TASK_MENTION_REGEX = /@(task-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)?)/g; +// Doc: excludes trailing punctuation (comma, semicolon, etc.) but allows colons for line numbers +const DOC_MENTION_REGEX = /@docs?\/([^\s,;!?"'()]+)/g; +const KNOWNS_DOC_PATH_REGEX = /(^|[\s(])(\.knowns\/docs\/[^\s,;!?"'()]+\.md)\b/g; + +/** + * Normalize doc path - ensure .md extension + */ +export function normalizeDocPath(path: string): string { + return path.endsWith(".md") ? path : `${path}.md`; +} + +export function toDocPath(path: string): string { + let normalized = path.trim(); + + // Strip query params and hash before normalizing + const queryIndex = normalized.indexOf("?"); + const hashIndex = normalized.indexOf("#"); + const splitIndex = queryIndex >= 0 ? queryIndex : hashIndex >= 0 ? hashIndex : -1; + let suffix = ""; + if (splitIndex >= 0) { + suffix = normalized.slice(splitIndex); + normalized = normalized.slice(0, splitIndex); + } + + if (normalized.startsWith("@doc/")) { + normalized = normalized.slice(5); + } else if (normalized.startsWith("@docs/")) { + normalized = normalized.slice(6); + } else if (normalized.startsWith(".knowns/docs/")) { + normalized = normalized.slice(".knowns/docs/".length); + } else if (normalized.startsWith("/.knowns/docs/")) { + normalized = normalized.slice("/.knowns/docs/".length); + } else if (normalized.startsWith("docs/")) { + normalized = normalized.slice("docs/".length); + } else if (normalized.startsWith("/docs/")) { + normalized = normalized.slice("/docs/".length); + } else if (normalized.startsWith("/")) { + normalized = normalized.slice(1); + } + + // Convert URL-style ?L= suffix back to mention-style :line suffix + if (suffix) { + const rangeMatch = suffix.match(/^\?L=(\d+)-(\d+)/); + const lineMatch = !rangeMatch && suffix.match(/^\?L=(\d+)/); + const headingMatch = !rangeMatch && !lineMatch && suffix.match(/^#(.+)/); + if (rangeMatch && rangeMatch[1] && rangeMatch[2]) { + normalized = `${normalizeDocPath(normalized)}:${rangeMatch[1]}-${rangeMatch[2]}`; + return normalized; + } + if (lineMatch && lineMatch[1]) { + normalized = `${normalizeDocPath(normalized)}:${lineMatch[1]}`; + return normalized; + } + if (headingMatch && headingMatch[1]) { + normalized = `${normalizeDocPath(normalized)}#${headingMatch[1]}`; + return normalized; + } + } + + return normalizeDocPath(normalized); +} + +export function getInlineMention(raw: string): { type: "task"; taskId: string } | { type: "doc"; docPath: string } | null { + const value = raw.trim(); + + // Skip placeholder/example text containing angle brackets (e.g. @doc/<path>) + if (value.includes("<") || value.includes(">")) return null; + + if (/^@task-[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)?$/.test(value)) { + return { type: "task", taskId: value.slice(1) }; + } + + if ( + value.startsWith("@doc/") || + value.startsWith("@docs/") || + value.startsWith(".knowns/docs/") || + value.startsWith("/.knowns/docs/") + ) { + return { type: "doc", docPath: toDocPath(value) }; + } + + return null; +} + +/** + * Transform mention patterns into markdown links + * These will then be styled via the custom link component + * IMPORTANT: Skip code blocks to avoid breaking mermaid/code syntax + */ +export function transformMentions(content: string): string { + const parts: string[] = []; + let lastIndex = 0; + + const codeBlockRegex = /(```[\s\S]*?```|`[^`\n]+`)/g; + let match: RegExpExecArray | null; + + while ((match = codeBlockRegex.exec(content)) !== null) { + if (match.index > lastIndex) { + parts.push(transformMentionsInText(content.slice(lastIndex, match.index))); + } + parts.push(match[0]); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < content.length) { + parts.push(transformMentionsInText(content.slice(lastIndex))); + } + + return parts.join(""); +} + +/** + * Transform mentions in regular text (not code) + */ +function transformMentionsInText(text: string): string { + let transformed = normalizeKnownsTaskReferences(text); + + // Transform @task-123 to [@@task-123](/tasks/task-123) + // Skip placeholder/example IDs with angle brackets (e.g. @task-<id>) + transformed = transformed.replace(TASK_MENTION_REGEX, (_match, taskRef) => { + if (taskRef.includes("<") || taskRef.includes(">")) return _match; + return `[@@${taskRef}](/tasks/${taskRef})`; + }); + + // Transform @doc/path or @docs/path to [@@doc/path.md](/docs/path.md) + // Supports @doc/path:line, @doc/path:start-end, @doc/path#heading + transformed = transformed.replace(DOC_MENTION_REGEX, (_match, docPath) => { + // Skip placeholder/example paths with angle brackets (e.g. <path>) + if (docPath.includes("<") || docPath.includes(">")) return _match; + + let cleanPath = docPath; + cleanPath = cleanPath.replace(/[:]+$/, ""); + if (cleanPath.endsWith(".") && !cleanPath.match(/\.\w+$/)) { + cleanPath = cleanPath.slice(0, -1); + } + let fragment = ""; + let displaySuffix = ""; + const rangeMatch = cleanPath.match(/:(\d+)-(\d+)$/); + const lineMatch = !rangeMatch && cleanPath.match(/:(\d+)$/); + const headingMatch = !rangeMatch && !lineMatch && cleanPath.match(/#([a-zA-Z0-9_-]+(?:[a-zA-Z0-9_. -]*)?)$/); + + if (rangeMatch) { + fragment = `?L=${rangeMatch[1]}-${rangeMatch[2]}`; + displaySuffix = `:${rangeMatch[1]}-${rangeMatch[2]}`; + cleanPath = cleanPath.slice(0, -rangeMatch[0].length); + } else if (lineMatch) { + fragment = `?L=${lineMatch[1]}`; + displaySuffix = `:${lineMatch[1]}`; + cleanPath = cleanPath.slice(0, -lineMatch[0].length); + } else if (headingMatch) { + fragment = `#${headingMatch[1]}`; + displaySuffix = `#${headingMatch[1]}`; + cleanPath = cleanPath.slice(0, -headingMatch[0].length); + } + const normalizedPath = toDocPath(cleanPath); + return `[@@doc/${normalizedPath}${displaySuffix}](/docs/${normalizedPath}${fragment})`; + }); + + transformed = transformed.replace(KNOWNS_DOC_PATH_REGEX, (_match, prefix, docPath) => { + const normalizedPath = toDocPath(docPath); + return `${prefix}[@@doc/${normalizedPath}](/docs/${normalizedPath})`; + }); + + return transformed; +} + +// Notion-like mention styles +const mentionBase = + "inline-flex items-center gap-1 px-1 py-px rounded text-[0.9em] font-medium cursor-pointer select-none transition-all no-underline"; + +export const taskMentionClass = + `${mentionBase} bg-green-500/8 text-green-700 hover:bg-green-500/15 hover:underline decoration-green-500/40 dark:text-green-400`; + +export const taskMentionBrokenClass = + `${mentionBase} bg-red-500/8 text-red-600 line-through opacity-70 cursor-not-allowed dark:text-red-400`; + +export const docMentionClass = + `${mentionBase} bg-blue-500/8 text-blue-700 hover:bg-blue-500/15 hover:underline decoration-blue-500/40 dark:text-blue-400`; + +export const docMentionBrokenClass = + `${mentionBase} bg-red-500/8 text-red-600 line-through opacity-70 cursor-not-allowed dark:text-red-400`; + +// Status colors for task badges +export const STATUS_STYLES: Record<string, string> = { + todo: "bg-muted-foreground/50", + "in-progress": "bg-yellow-500", + "in-review": "bg-purple-500", + blocked: "bg-red-500", + done: "bg-green-500", +}; diff --git a/ui/src/components/molecules/AppBreadcrumb.tsx b/ui/src/components/molecules/AppBreadcrumb.tsx index c39af94..2118de0 100644 --- a/ui/src/components/molecules/AppBreadcrumb.tsx +++ b/ui/src/components/molecules/AppBreadcrumb.tsx @@ -1,6 +1,6 @@ import { Link } from "@tanstack/react-router"; -import { ChevronRight } from "lucide-react"; -import { useDocs } from "../../contexts/DocsContext"; +import { ChevronRight, Package } from "lucide-react"; +import { useDocsOptional } from "../../contexts/DocsContext"; interface AppBreadcrumbProps { currentPage: string; @@ -36,7 +36,7 @@ function BreadcrumbLink({ <Link to={to} onClick={onClick} - className="text-muted-foreground hover:text-foreground transition-colors truncate" + className="text-muted-foreground hover:text-foreground transition-colors truncate max-w-[160px]" > {children} </Link> @@ -45,17 +45,32 @@ function BreadcrumbLink({ function BreadcrumbCurrent({ children }: { children: React.ReactNode }) { return ( - <span className="text-foreground font-medium truncate">{children}</span> + <span className="text-foreground font-medium truncate max-w-[200px]">{children}</span> ); } function DocsBreadcrumbSegments() { - const { selectedDoc, currentFolder, navigateToFolder } = useDocs(); + const docsCtx = useDocsOptional(); + if (!docsCtx) return null; + const { selectedDoc, currentFolder, navigateToFolder } = docsCtx; if (selectedDoc) { // Viewing a doc: show folder path (clickable) + doc title const segments: React.ReactNode[] = []; + // Show imported badge before folder segments for imported docs + if (selectedDoc.isImported) { + segments.push( + <BreadcrumbSeparator key="sep-imported" />, + <span + key="imported-badge" + className="inline-flex items-center gap-1 text-muted-foreground shrink-0" + > + <Package className="w-3 h-3" /> + </span>, + ); + } + if (selectedDoc.folder) { const folderParts = selectedDoc.folder.split("/"); for (let i = 0; i < folderParts.length; i++) { @@ -127,7 +142,7 @@ export function AppBreadcrumb({ currentPage, projectName }: AppBreadcrumbProps) const pageLabel = pageLabels[currentPage] || "Dashboard"; return ( - <nav className="flex items-center gap-1.5 text-sm min-w-0 flex-1"> + <nav className="flex items-center gap-1.5 text-sm min-w-0 flex-1 overflow-hidden"> <BreadcrumbLink to="/">{projectName}</BreadcrumbLink> {currentPage !== "dashboard" && ( <> diff --git a/ui/src/components/organisms/AppSidebar.tsx b/ui/src/components/organisms/AppSidebar.tsx index effab55..3511488 100644 --- a/ui/src/components/organisms/AppSidebar.tsx +++ b/ui/src/components/organisms/AppSidebar.tsx @@ -9,6 +9,7 @@ import { Search, Github, ExternalLink, + ArrowRightLeft, } from "lucide-react"; import { Link } from "@tanstack/react-router"; import logoImage from "../../public/logo.png"; @@ -31,6 +32,7 @@ import { useConfig } from "@/ui/contexts/ConfigContext"; interface AppSidebarProps { currentPage: string; onSearchClick: () => void; + onWorkspacePickerClick: () => void; } const topNavItems = [ @@ -69,6 +71,7 @@ const topNavItems = [ export function AppSidebar({ currentPage, onSearchClick, + onWorkspacePickerClick, }: AppSidebarProps) { const { state } = useSidebar(); const isMobile = useIsMobile(); @@ -98,6 +101,14 @@ export function AppSidebar({ {import.meta.env.APP_VERSION || "v0.0.0"} </span> </div> + <button + type="button" + onClick={onWorkspacePickerClick} + className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors" + title="Switch workspace" + > + <ArrowRightLeft className="h-4 w-4" /> + </button> </div> </SidebarMenuItem> </SidebarMenu> diff --git a/ui/src/components/organisms/Board.tsx b/ui/src/components/organisms/Board.tsx index f73098c..32ad300 100644 --- a/ui/src/components/organisms/Board.tsx +++ b/ui/src/components/organisms/Board.tsx @@ -5,6 +5,7 @@ import type { Task, TaskStatus } from "@/ui/models/task"; import { api } from "../../api/client"; import { navigateTo } from "../../lib/navigation"; import { useConfig } from "../../contexts/ConfigContext"; +import { useNewTaskIds } from "../../hooks/useNewTaskIds"; import { TaskDetailSheet } from "./TaskDetail/TaskDetailSheet"; import { ScrollArea, ScrollBar } from "../ui/ScrollArea"; import { @@ -72,6 +73,7 @@ interface BoardProps { export default function Board({ tasks, loading, onTasksUpdate }: BoardProps) { const location = useRouterState({ select: (state) => state.location }); const { config, updateConfig } = useConfig(); + const newTaskIds = useNewTaskIds(tasks); const [visibleColumns, setVisibleColumns] = useState<Set<TaskStatus>>(new Set()); const [columnControlsOpen, setColumnControlsOpen] = useState(false); const [isDragging, setIsDragging] = useState(false); @@ -431,6 +433,7 @@ export default function Board({ tasks, loading, onTasksUpdate }: BoardProps) { <TaskKanbanCard key={item.id} item={item} + isNew={newTaskIds.has(item.id)} statusColors={statusColors} onClick={() => handleTaskClick(item.task)} /> @@ -465,11 +468,12 @@ export default function Board({ tasks, loading, onTasksUpdate }: BoardProps) { // Task card content component for KanbanCard interface TaskKanbanCardProps { item: KanbanTaskItem; + isNew?: boolean; statusColors: Record<string, ColorName>; onClick: () => void; } -function TaskKanbanCard({ item, statusColors, onClick }: TaskKanbanCardProps) { +function TaskKanbanCard({ item, isNew, statusColors, onClick }: TaskKanbanCardProps) { const { task } = item; const statusBadgeClasses = getStatusBadgeClasses(task.status, statusColors); const ac = task.acceptanceCriteria ?? []; @@ -480,7 +484,7 @@ function TaskKanbanCard({ item, statusColors, onClick }: TaskKanbanCardProps) { id={item.id} name={item.name} column={item.column} - className="w-full group/card" + className={cn("w-full group/card", isNew && "animate-[fade-in-up_0.5s_ease-out]")} > <div onClick={onClick} diff --git a/ui/src/components/organisms/DocsFileManager.tsx b/ui/src/components/organisms/DocsFileManager.tsx index 57dac31..99e1fef 100644 --- a/ui/src/components/organisms/DocsFileManager.tsx +++ b/ui/src/components/organisms/DocsFileManager.tsx @@ -3,6 +3,7 @@ import { FileText, FolderOpen, ChevronRight, + ChevronLeft, ClipboardCheck, Filter, Plus, @@ -13,8 +14,6 @@ import { GitBranch, Package2, FolderGit2, - RefreshCw, - ExternalLink, } from "lucide-react"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -318,19 +317,6 @@ export function DocsFileManager({ return group ? group.docs : null; }, [viewingImportSource, importGroups]); - // Breadcrumb segments (strip import source prefix if viewing import source) - const breadcrumbSegments = useMemo(() => { - if (!currentFolder) return []; - const segments = currentFolder.split("/"); - - // If viewing import source, remove the source prefix from segments - if (viewingImportSource && segments[0] === viewingImportSource) { - return segments.slice(1); - } - - return segments; - }, [currentFolder, viewingImportSource]); - // Navigate back to root (clear both folder and import source view) const navigateToRoot = () => { navigateToFolder(null); @@ -353,63 +339,34 @@ export function DocsFileManager({ return ( <div className={cn("h-full flex flex-col", className)}> - {/* Breadcrumb */} - <nav className="flex items-center gap-1 text-[12px] mb-3 flex-wrap px-1 text-muted-foreground/80"> + {/* Back button when inside a folder or import source */} + {(currentFolder !== null || viewingImportSource !== null) && !isSearching && ( <button type="button" - onClick={navigateToRoot} - disabled={isSearching} - className={`hover:text-foreground transition-colors ${ - isSearching - ? "text-muted-foreground cursor-default" - : currentFolder === null && !viewingImportSource - ? "text-foreground font-medium" - : "text-muted-foreground" - }`} + onClick={currentFolder !== null ? () => { + // Navigate to parent folder or root + const parts = currentFolder.split("/"); + if (parts.length > 1) { + navigateToFolder(parts.slice(0, -1).join("/")); + } else { + navigateToFolder(null); + if (viewingImportSource) setViewingImportSource(null); + } + } : navigateToRoot} + className="flex items-center gap-1.5 mb-2 px-1 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors rounded-lg hover:bg-accent/40 w-fit" > - docs + <ChevronLeft className="w-3.5 h-3.5" /> + <span> + {currentFolder + ? currentFolder.includes("/") + ? currentFolder.split("/").slice(-2, -1)[0] + : viewingImportSource || "docs" + : "docs"} + </span> </button> - {isSearching && ( - <span className="text-muted-foreground/70">/ search</span> - )} - {/* Import source breadcrumb */} - {viewingImportSource && !isSearching && ( - <> - <ChevronRight className="w-3.5 h-3.5 text-muted-foreground/60" /> - <span className="text-foreground font-medium flex items-center gap-1"> - <Package className="w-3 h-3" /> - {viewingImportSource} - </span> - </> - )} - {/* Regular folder breadcrumb (already stripped import prefix if viewing import source) */} - {breadcrumbSegments.map((segment, i) => { - const relativePath = breadcrumbSegments.slice(0, i + 1).join("/"); - // When viewing import source, prepend the source to navigate correctly - const fullPath = viewingImportSource - ? `${viewingImportSource}/${relativePath}` - : relativePath; - const isLast = i === breadcrumbSegments.length - 1; - return ( - <span key={relativePath} className="flex items-center gap-1"> - <ChevronRight className="w-3.5 h-3.5 text-muted-foreground/60" /> - <button - type="button" - onClick={() => navigateToFolder(fullPath)} - className={`hover:text-foreground transition-colors ${ - isLast - ? "text-foreground font-medium" - : "text-muted-foreground" - }`} - > - {segment} - </button> - </span> - ); - })} - </nav> - - <div className="mb-3 px-1"> + )} + + <div className="mb-3"> <div className="relative"> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/70" /> <Input @@ -432,7 +389,7 @@ export function DocsFileManager({ </div> {/* Toolbar */} - <div className="flex items-center gap-2 mb-4 px-1"> + <div className="flex items-center gap-2 mb-4"> <Button variant={showSpecsOnly ? "default" : "outline"} size="sm" @@ -461,6 +418,7 @@ export function DocsFileManager({ {/* Entries list */} <div className="overflow-y-auto min-h-0 pr-1"> + <div key={`${currentFolder ?? "root"}__${viewingImportSource ?? ""}`} className="space-y-0.5 animate-list-in"> {/* If viewing an import source, show only its docs */} {viewingImportSource && importSourceDocs ? ( <> @@ -597,8 +555,8 @@ export function DocsFileManager({ key={doc.path} onClick={() => handleDocClick(doc)} className={cn( - "flex items-start gap-3 py-2 px-2 w-full text-left transition-colors group rounded-xl", - isSelected ? "bg-accent/70 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.04)]" : "hover:bg-accent/45", + "flex items-start gap-3 py-2 px-2 w-full text-left transition-all duration-150 group rounded-xl", + isSelected ? "bg-accent/70 text-foreground shadow-[inset_0_0_0_1px_rgba(0,0,0,0.04)]" : "hover:bg-accent/45 active:scale-[0.98] active:bg-accent/60", )} > {docIsSpec ? ( @@ -713,6 +671,7 @@ export function DocsFileManager({ </> )} </div> + </div> </div> ); } diff --git a/ui/src/components/organisms/DocsPreview/DocPreviewDialog.tsx b/ui/src/components/organisms/DocsPreview/DocPreviewDialog.tsx index 880db03..1a0c7b0 100644 --- a/ui/src/components/organisms/DocsPreview/DocPreviewDialog.tsx +++ b/ui/src/components/organisms/DocsPreview/DocPreviewDialog.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, DialogTitle } from "../../ui/dialog"; import { Button } from "../../ui/button"; import { ExternalLink, Loader2, FileText } from "lucide-react"; import { getDoc, type Doc } from "../../../api/client"; import { navigateTo } from "../../../lib/navigation"; -import { MDRender } from "../../editor"; +import { MDRenderWithHighlight } from "../../editor/MDRenderWithHighlight"; +import { parseDocFragment } from "../../editor/DocMentionBadge"; interface DocPreviewDialogProps { docPath: string | null; @@ -21,6 +22,14 @@ export function DocPreviewDialog({ const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); + const frag = useMemo(() => (docPath ? parseDocFragment(docPath) : null), [docPath]); + const lineHighlight = useMemo(() => { + if (!frag) return null; + if (frag.startLine != null && frag.endLine != null) return { start: frag.startLine, end: frag.endLine }; + if (frag.line != null) return { start: frag.line, end: frag.line }; + return null; + }, [frag]); + useEffect(() => { if (!open || !docPath) { setDoc(null); @@ -31,7 +40,10 @@ export function DocPreviewDialog({ setLoading(true); setError(null); - getDoc(docPath) + // Strip line/range/heading suffixes before fetching + const cleanPath = frag?.path || docPath; + + getDoc(cleanPath) .then((data) => { if (data) { setDoc(data); @@ -49,9 +61,6 @@ export function DocPreviewDialog({ const handleViewInDocs = () => { if (docPath) { - console.log("๐Ÿ” handleViewInDocs - docPath:", docPath); - console.log("๐Ÿ” handleViewInDocs - doc:", doc); - console.log("๐Ÿ” handleViewInDocs - navigating to:", `/docs/${docPath}`); navigateTo(`/docs/${docPath}`); onOpenChange(false); } @@ -112,8 +121,9 @@ export function DocPreviewDialog({ <div className="min-h-0 flex-1 overflow-y-auto bg-background"> <div className="mx-auto max-w-2xl px-6 py-6"> {contentPreview ? ( - <MDRender - markdown={contentPreview} + <MDRenderWithHighlight + content={contentPreview} + lineHighlight={lineHighlight} className="prose prose-sm max-w-none dark:prose-invert [&_h1]:text-2xl [&_h2]:text-xl [&_p]:leading-7" /> ) : ( diff --git a/ui/src/components/organisms/TaskNotionList.tsx b/ui/src/components/organisms/TaskNotionList.tsx new file mode 100644 index 0000000..3f23d26 --- /dev/null +++ b/ui/src/components/organisms/TaskNotionList.tsx @@ -0,0 +1,399 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Search, X, Plus, FileText, ClipboardList, ChevronRight } from "lucide-react"; +import type { Task } from "@/ui/models/task"; +import { Input } from "@/ui/components/ui/input"; +import { Button } from "@/ui/components/ui/button"; +import { StatusBadge, PriorityBadge, LabelList } from "@/ui/components/molecules"; +import { useConfig } from "@/ui/contexts/ConfigContext"; +import { buildStatusOptions } from "@/ui/utils/colors"; +import { useNewTaskIds } from "@/ui/hooks/useNewTaskIds"; +import { navigateTo } from "../../lib/navigation"; +import { cn } from "@/ui/lib/utils"; + +interface TaskNotionListProps { + tasks: Task[]; + onTaskClick: (task: Task) => void; + onNewTask?: () => void; +} + +export function TaskNotionList({ tasks, onTaskClick, onNewTask }: TaskNotionListProps) { + const { config } = useConfig(); + const newTaskIds = useNewTaskIds(tasks); + const [searchQuery, setSearchQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState<string>("all"); + const [priorityFilter, setPriorityFilter] = useState<string>("all"); + + const statusOptions = useMemo(() => { + const statuses = config.statuses || ["todo", "in-progress", "in-review", "done", "blocked"]; + return buildStatusOptions(statuses); + }, [config.statuses]); + + const filteredTasks = useMemo(() => { + let result = tasks; + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + result = result.filter( + (t) => + t.id.toLowerCase().includes(q) || + t.title.toLowerCase().includes(q) || + t.description?.toLowerCase().includes(q) || + t.assignee?.toLowerCase().includes(q) || + (t.labels ?? []).some((l: string) => l.toLowerCase().includes(q)), + ); + } + + if (statusFilter !== "all") { + result = result.filter((t) => t.status === statusFilter); + } + if (priorityFilter !== "all") { + result = result.filter((t) => t.priority === priorityFilter); + } + + return result; + }, [tasks, searchQuery, statusFilter, priorityFilter]); + + // Sort within each group: priority highโ†’low, then by order, then by id + const sortTasks = useCallback((list: Task[]) => { + const priorityOrder: Record<string, number> = { high: 0, medium: 1, low: 2 }; + return [...list].sort((a, b) => { + const pa = priorityOrder[a.priority] ?? 1; + const pb = priorityOrder[b.priority] ?? 1; + if (pa !== pb) return pa - pb; + if (a.order !== undefined && b.order !== undefined) return a.order - b.order; + if (a.order !== undefined) return -1; + if (b.order !== undefined) return 1; + return String(a.id).localeCompare(String(b.id), undefined, { numeric: true }); + }); + }, []); + + // Group tasks by status, following config status order + const groupedTasks = useMemo(() => { + const statuses = config.statuses || ["todo", "in-progress", "in-review", "done", "blocked"]; + const groups: { status: string; label: string; tasks: Task[] }[] = []; + const tasksByStatus = new Map<string, Task[]>(); + + for (const t of filteredTasks) { + const list = tasksByStatus.get(t.status) || []; + list.push(t); + tasksByStatus.set(t.status, list); + } + + // Follow config order, then any remaining statuses + const seen = new Set<string>(); + for (const s of statuses) { + seen.add(s); + const list = tasksByStatus.get(s); + if (list && list.length > 0) { + const opt = statusOptions.find((o) => o.value === s); + groups.push({ status: s, label: opt?.label || s, tasks: sortTasks(list) }); + } + } + for (const [s, list] of tasksByStatus) { + if (!seen.has(s) && list.length > 0) { + const opt = statusOptions.find((o) => o.value === s); + groups.push({ status: s, label: opt?.label || s, tasks: sortTasks(list) }); + } + } + + return groups; + }, [filteredTasks, config.statuses, statusOptions, sortTasks]); + + const isFiltered = searchQuery || statusFilter !== "all" || priorityFilter !== "all"; + + // Collapsed groups + const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set()); + const toggleGroup = useCallback((status: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(status)) next.delete(status); + else next.add(status); + return next; + }); + }, []); + + // Progressive rendering โ€” load 30 at a time across all visible groups + const BATCH_SIZE = 30; + const [visibleCount, setVisibleCount] = useState(BATCH_SIZE); + const scrollRef = useRef<HTMLDivElement>(null); + + // Reset visible count when filters change + useEffect(() => { + setVisibleCount(BATCH_SIZE); + }, [searchQuery, statusFilter, priorityFilter]); + + // Flatten visible tasks across groups for lazy loading + const { visibleGroups, totalVisible, totalTasks, hasMore } = useMemo(() => { + let remaining = visibleCount; + let total = 0; + const result: { status: string; label: string; tasks: Task[]; totalInGroup: number }[] = []; + + for (const group of groupedTasks) { + total += group.tasks.length; + if (collapsedGroups.has(group.status)) { + result.push({ ...group, tasks: [], totalInGroup: group.tasks.length }); + continue; + } + const slice = group.tasks.slice(0, remaining); + result.push({ ...group, tasks: slice, totalInGroup: group.tasks.length }); + remaining -= slice.length; + if (remaining <= 0) break; + } + + return { + visibleGroups: result, + totalVisible: visibleCount - Math.max(remaining, 0), + totalTasks: total, + hasMore: visibleCount < total, + }; + }, [groupedTasks, visibleCount, collapsedGroups]); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || !hasMore) return; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 200) { + setVisibleCount((prev) => Math.min(prev + BATCH_SIZE, totalTasks)); + } + }, [hasMore, totalTasks]); + + return ( + <div className="h-full flex flex-col"> + {/* Filter bar โ€” pill style */} + <div className="flex items-center gap-2 flex-wrap mb-4"> + <div className="relative flex-1 min-w-[180px] max-w-[280px]"> + <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" /> + <Input + placeholder="Search..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8 h-8 text-sm rounded-lg border-border/40 bg-background" + /> + {searchQuery && ( + <button + type="button" + onClick={() => setSearchQuery("")} + className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" + > + <X className="h-3 w-3" /> + </button> + )} + </div> + + {/* Status pills */} + <div className="flex items-center gap-1"> + <button + type="button" + onClick={() => setStatusFilter("all")} + className={cn( + "px-2.5 py-1 rounded-full text-xs transition-colors", + statusFilter === "all" + ? "bg-foreground text-background" + : "bg-muted/60 text-muted-foreground hover:bg-muted", + )} + > + All + </button> + {statusOptions.map((opt) => ( + <button + key={opt.value} + type="button" + onClick={() => setStatusFilter(statusFilter === opt.value ? "all" : opt.value)} + className={cn( + "px-2.5 py-1 rounded-full text-xs transition-colors", + statusFilter === opt.value + ? "bg-foreground text-background" + : "bg-muted/60 text-muted-foreground hover:bg-muted", + )} + > + {opt.label} + </button> + ))} + </div> + + {/* Priority pills */} + <div className="flex items-center gap-1"> + {(["high", "medium", "low"] as const).map((p) => ( + <button + key={p} + type="button" + onClick={() => setPriorityFilter(priorityFilter === p ? "all" : p)} + className={cn( + "px-2.5 py-1 rounded-full text-xs capitalize transition-colors", + priorityFilter === p + ? "bg-foreground text-background" + : "bg-muted/60 text-muted-foreground hover:bg-muted", + )} + > + {p} + </button> + ))} + </div> + + {isFiltered && ( + <button + type="button" + onClick={() => { + setSearchQuery(""); + setStatusFilter("all"); + setPriorityFilter("all"); + }} + className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5" + > + <X className="h-3 w-3" /> + Clear + </button> + )} + + <div className="flex-1" /> + + <span className="text-xs text-muted-foreground"> + {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} + </span> + + {onNewTask && ( + <Button onClick={onNewTask} size="sm" variant="ghost" className="h-7 gap-1 text-xs"> + <Plus className="h-3.5 w-3.5" /> + New + </Button> + )} + </div> + + {/* Task list โ€” grouped by status */} + <div + ref={scrollRef} + onScroll={handleScroll} + className="flex-1 overflow-y-auto -mx-2" + > + {groupedTasks.length === 0 ? ( + <div className="text-center py-16"> + <p className="text-sm text-muted-foreground"> + {isFiltered ? "No tasks match your filters." : "No tasks yet."} + </p> + </div> + ) : ( + <div className="space-y-1"> + {visibleGroups.map((group) => ( + <div key={group.status}> + {/* Group header */} + <button + type="button" + onClick={() => toggleGroup(group.status)} + className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/40 transition-colors" + > + <ChevronRight + className={cn( + "h-3.5 w-3.5 text-muted-foreground/60 transition-transform", + !collapsedGroups.has(group.status) && "rotate-90", + )} + /> + <StatusBadge status={group.status} /> + <span className="text-xs text-muted-foreground/60"> + {group.totalInGroup} + </span> + </button> + + {/* Group tasks */} + {!collapsedGroups.has(group.status) && group.tasks.length > 0 && ( + <div className="ml-2"> + {group.tasks.map((task) => ( + <TaskRow + key={task.id} + task={task} + isNew={newTaskIds.has(task.id)} + onClick={() => onTaskClick(task)} + /> + ))} + </div> + )} + </div> + ))} + {hasMore && ( + <div className="py-3 text-center text-xs text-muted-foreground/60"> + Loading more... + </div> + )} + </div> + )} + </div> + </div> + ); +} + + +function TaskRow({ task, isNew, onClick }: { task: Task; isNew?: boolean; onClick: () => void }) { + const criteria = task.acceptanceCriteria ?? []; + const acCompleted = criteria.filter((c: { completed: boolean }) => c.completed).length; + const acTotal = criteria.length; + + return ( + <button + type="button" + onClick={onClick} + className={cn( + "flex items-center gap-3 w-full text-left px-3 py-2.5 rounded-lg transition-colors hover:bg-muted/50 group", + isNew && "animate-[fade-in-up_0.5s_ease-out] bg-primary/5", + )} + > + {/* Title + description */} + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium truncate">{task.title}</span> + <span className="text-[11px] font-mono text-muted-foreground/60 shrink-0"> + #{task.id} + </span> + </div> + {task.description && ( + <p className="text-xs text-muted-foreground truncate mt-0.5 max-w-lg"> + {task.description} + </p> + )} + </div> + + {/* Properties โ€” right side */} + <div className="flex items-center gap-2 shrink-0"> + {/* Labels */} + {(task.labels ?? []).length > 0 && ( + <div className="hidden sm:block" onClick={(e) => e.stopPropagation()}> + <LabelList labels={task.labels} maxVisible={2} /> + </div> + )} + + {/* Spec link */} + {task.spec && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + navigateTo(`/docs/${task.spec}.md`); + }} + className="hidden md:flex items-center gap-1 text-[11px] text-purple-600 dark:text-purple-400 hover:underline shrink-0" + title={`@doc/${task.spec}`} + > + <FileText className="w-3 h-3" /> + {task.spec.split("/").pop()} + </button> + )} + + {/* AC progress */} + {acTotal > 0 && ( + <span className="hidden sm:flex items-center gap-1 text-[11px] text-muted-foreground shrink-0"> + <ClipboardList className="w-3 h-3" /> + {acCompleted}/{acTotal} + </span> + )} + + {/* Assignee */} + {task.assignee && ( + <span className="hidden lg:block text-[11px] font-mono text-muted-foreground shrink-0"> + {task.assignee} + </span> + )} + + {/* Priority */} + <div className="shrink-0" onClick={(e) => e.stopPropagation()}> + <PriorityBadge priority={task.priority} /> + </div> + </div> + </button> + ); +} diff --git a/ui/src/components/organisms/WorkspacePicker.tsx b/ui/src/components/organisms/WorkspacePicker.tsx new file mode 100644 index 0000000..6fda633 --- /dev/null +++ b/ui/src/components/organisms/WorkspacePicker.tsx @@ -0,0 +1,239 @@ +import { useState, useEffect, useCallback } from "react"; +import { FolderOpen, Trash2, Search, Plus, ArrowRightLeft, Clock } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/ui/components/ui/dialog"; +import { Button } from "@/ui/components/ui/button"; +import { Input } from "@/ui/components/ui/input"; +import { workspaceApi, type WorkspaceProject } from "@/ui/api/client"; + +interface WorkspacePickerProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function formatRelativeTime(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "just now"; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDay = Math.floor(diffHr / 24); + if (diffDay < 30) return `${diffDay}d ago`; + return date.toLocaleDateString(); +} + +export function WorkspacePicker({ open, onOpenChange }: WorkspacePickerProps) { + const [projects, setProjects] = useState<WorkspaceProject[]>([]); + const [loading, setLoading] = useState(false); + const [scanning, setScanning] = useState(false); + const [switching, setSwitching] = useState<string | null>(null); + const [addPath, setAddPath] = useState(""); + const [scanDirs, setScanDirs] = useState(""); + const [error, setError] = useState<string | null>(null); + + const loadProjects = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await workspaceApi.list(); + setProjects(data || []); + } catch (err) { + setError("Failed to load projects"); + console.error(err); + } finally { + setLoading(false); + } + }, []); + + const handleAutoScan = useCallback(async () => { + setScanning(true); + try { + const added = await workspaceApi.autoScan(); + if (added.length > 0) { + await loadProjects(); + } + } catch (err) { + console.error("Auto-scan failed:", err); + } finally { + setScanning(false); + } + }, [loadProjects]); + + useEffect(() => { + if (open) { + loadProjects(); + handleAutoScan(); + } + }, [open, loadProjects, handleAutoScan]); + + const handleSwitch = async (id: string) => { + setSwitching(id); + setError(null); + try { + await workspaceApi.switchProject(id); + onOpenChange(false); + } catch (err) { + setError("Failed to switch workspace"); + console.error(err); + } finally { + setSwitching(null); + } + }; + + const handleRemove = async (id: string) => { + setError(null); + try { + await workspaceApi.remove(id); + setProjects((prev) => prev.filter((p) => p.id !== id)); + } catch (err) { + setError("Failed to remove project"); + console.error(err); + } + }; + + const handleScan = async () => { + if (!scanDirs.trim()) return; + setError(null); + try { + const dirs = scanDirs.split(",").map((d) => d.trim()).filter(Boolean); + const added = await workspaceApi.scan(dirs); + if (added.length > 0) { + await loadProjects(); + } + setScanDirs(""); + } catch (err) { + setError("Failed to scan directories"); + console.error(err); + } + }; + + const handleAdd = async () => { + if (!addPath.trim()) return; + setError(null); + try { + // Scan the parent dir to discover the project + const dirs = [addPath.trim()]; + await workspaceApi.scan(dirs); + await loadProjects(); + setAddPath(""); + } catch (err) { + setError("Failed to add project"); + console.error(err); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <ArrowRightLeft className="h-5 w-5" /> + Switch Workspace + </DialogTitle> + <DialogDescription> + Select a project or add new ones to the registry. + </DialogDescription> + </DialogHeader> + + {error && ( + <div className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive"> + {error} + </div> + )} + + {scanning && ( + <div className="flex items-center gap-2 rounded-md bg-primary/5 px-3 py-2 text-sm text-muted-foreground"> + <Search className="h-3.5 w-3.5 animate-pulse" /> + Scanning common directories... + </div> + )} + + {/* Project List */} + <div className="max-h-64 space-y-1 overflow-y-auto"> + {loading ? ( + <div className="py-8 text-center text-sm text-muted-foreground">Loading...</div> + ) : projects.length === 0 ? ( + <div className="py-8 text-center text-sm text-muted-foreground"> + No projects registered. Scan a directory or add a path below. + </div> + ) : ( + projects.map((project) => ( + <div + key={project.id} + className="group flex items-center gap-3 rounded-lg border px-3 py-2.5 hover:bg-accent/50 transition-colors cursor-pointer" + onClick={() => handleSwitch(project.id)} + onKeyDown={(e) => e.key === "Enter" && handleSwitch(project.id)} + role="button" + tabIndex={0} + > + <FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" /> + <div className="min-w-0 flex-1"> + <div className="truncate text-sm font-medium">{project.name}</div> + <div className="truncate text-xs text-muted-foreground">{project.path}</div> + </div> + <div className="flex items-center gap-1.5 shrink-0"> + <span className="flex items-center gap-1 text-xs text-muted-foreground"> + <Clock className="h-3 w-3" /> + {formatRelativeTime(project.lastUsed)} + </span> + {switching === project.id && ( + <span className="text-xs text-primary">Switching...</span> + )} + <button + type="button" + className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-all" + onClick={(e) => { + e.stopPropagation(); + handleRemove(project.id); + }} + aria-label={`Remove ${project.name}`} + > + <Trash2 className="h-3.5 w-3.5" /> + </button> + </div> + </div> + )) + )} + </div> + + {/* Add Project */} + <div className="flex gap-2"> + <Input + placeholder="Project parent directory path..." + value={addPath} + onChange={(e) => setAddPath(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + className="text-sm" + /> + <Button variant="outline" size="sm" onClick={handleAdd} disabled={!addPath.trim()}> + <Plus className="h-4 w-4 mr-1" /> + Add + </Button> + </div> + + {/* Scan */} + <div className="flex gap-2"> + <Input + placeholder="Scan directories (comma-separated)..." + value={scanDirs} + onChange={(e) => setScanDirs(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleScan()} + className="text-sm" + /> + <Button variant="outline" size="sm" onClick={handleScan} disabled={!scanDirs.trim()}> + <Search className="h-4 w-4 mr-1" /> + Scan + </Button> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/ui/src/components/organisms/index.ts b/ui/src/components/organisms/index.ts index 7f07ecd..6a11141 100644 --- a/ui/src/components/organisms/index.ts +++ b/ui/src/components/organisms/index.ts @@ -15,3 +15,4 @@ export { default as VersionDiffViewer } from "./VersionDiffViewer"; export { default as AssigneeDropdown } from "./AssigneeDropdown"; export { TaskDataTable } from "./TaskDataTable"; export { DocsFileManager } from "./DocsFileManager"; +export { TaskNotionList } from "./TaskNotionList"; diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx index 53fdb38..e309f5a 100644 --- a/ui/src/components/ui/dialog.tsx +++ b/ui/src/components/ui/dialog.tsx @@ -41,7 +41,7 @@ const DialogContent = React.forwardRef< <DialogPrimitive.Content ref={ref} className={cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg data-[state=open]:animate-dialog-in data-[state=closed]:animate-dialog-out sm:rounded-lg", className )} {...props} diff --git a/ui/src/contexts/DocsContext.tsx b/ui/src/contexts/DocsContext.tsx index cb3b3cb..9d3a1ba 100644 --- a/ui/src/contexts/DocsContext.tsx +++ b/ui/src/contexts/DocsContext.tsx @@ -45,12 +45,17 @@ export function DocsProvider({ children }: { children: React.ReactNode }) { }); const [currentFolder, setCurrentFolder] = useState<string | null>(null); const docsRef = useRef<Doc[]>([]); + const selectedDocRef = useRef<Doc | null>(null); // Keep ref in sync useEffect(() => { docsRef.current = docs; }, [docs]); + useEffect(() => { + selectedDocRef.current = selectedDoc; + }, [selectedDoc]); + // Persist filter state useEffect(() => { localStorage.setItem("docs-specs-only", String(showSpecsOnly)); @@ -80,20 +85,27 @@ export function DocsProvider({ children }: { children: React.ReactNode }) { }, [loadDocs]); // Fetch linked tasks when a spec is selected - useEffect(() => { - if (selectedDoc && isSpec(selectedDoc)) { - const specPath = toDisplayPath(selectedDoc.path).replace(/\.md$/, ""); + const refreshLinkedTasks = useCallback(() => { + const doc = selectedDocRef.current; + if (doc && isSpec(doc)) { + const specPath = toDisplayPath(doc.path).replace(/\.md$/, ""); getTasksBySpec(specPath) .then((tasks) => setLinkedTasks(tasks)) .catch(() => setLinkedTasks([])); } else { setLinkedTasks([]); } - }, [selectedDoc]); + }, []); + + useEffect(() => { + refreshLinkedTasks(); + }, [selectedDoc, refreshLinkedTasks]); // SSE updates useSSEEvent("docs:updated", () => loadDocs()); useSSEEvent("docs:refresh", () => loadDocs()); + useSSEEvent("tasks:updated", () => refreshLinkedTasks()); + useSSEEvent("tasks:refresh", () => refreshLinkedTasks()); const setSelectedDoc = useCallback((doc: Doc | null) => { setSelectedDocState(doc); @@ -217,3 +229,8 @@ export function useDocs() { } return context; } + +/** Safe version that returns null when outside DocsProvider instead of throwing. */ +export function useDocsOptional() { + return useContext(DocsContext); +} diff --git a/ui/src/contexts/SSEContext.tsx b/ui/src/contexts/SSEContext.tsx index 86e84c3..a75332d 100644 --- a/ui/src/contexts/SSEContext.tsx +++ b/ui/src/contexts/SSEContext.tsx @@ -27,6 +27,7 @@ export type SSEEventType = | "time:refresh" | "docs:updated" | "docs:refresh" + | "refresh" | "chats:created" | "chats:updated" | "chats:deleted" @@ -44,6 +45,7 @@ export interface SSEEventPayloads { "time:refresh": Record<string, never>; "docs:updated": { docPath: string }; "docs:refresh": Record<string, never>; + "refresh": { full?: boolean; reason?: string }; "chats:created": { session: ChatSession }; "chats:updated": { session: ChatSession }; "chats:deleted": { chatId: string }; @@ -218,25 +220,6 @@ export function SSEProvider({ children }: { children: ReactNode }) { // Show disconnect toast only once (when we have been connected before) // wasConnectedRef tracks if we've ever connected, preventing toast on initial failure - if (wasConnectedRef.current && !disconnectToastIdRef.current) { - disconnectToastIdRef.current = toast("Offline", { - description: "Reconnecting...", - duration: Number.POSITIVE_INFINITY, - position: "top-center", - className: "bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800", - icon: ( - <svg className="w-4 h-4 text-amber-600 dark:text-amber-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <path d="M12 20h.01" /> - <path d="M8.5 16.429a5 5 0 0 1 7 0" /> - <path d="M5 12.859a10 10 0 0 1 5.17-2.69" /> - <path d="M13.83 10.17A10 10 0 0 1 19 12.859" /> - <path d="M2 8.82a15 15 0 0 1 4.17-2.65" /> - <path d="M10.66 5a15 15 0 0 1 11.34 3.82" /> - <path d="m2 2 20 20" /> - </svg> - ), - }); - } // EventSource will auto-reconnect automatically }; @@ -301,6 +284,12 @@ export function SSEProvider({ children }: { children: ReactNode }) { emit("docs:refresh", data); }); + addHandler(eventSource, "refresh", () => { + emit("tasks:refresh", {}); + emit("time:refresh", {}); + emit("docs:refresh", {}); + }); + addHandler(eventSource, "chats:created", (e) => { const data = JSON.parse(e.data); emit("chats:created", data); diff --git a/ui/src/hooks/useNewTaskIds.ts b/ui/src/hooks/useNewTaskIds.ts new file mode 100644 index 0000000..d67632f --- /dev/null +++ b/ui/src/hooks/useNewTaskIds.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; +import type { Task } from "@/ui/models/task"; + +/** + * Tracks newly added task IDs so components can apply entrance animations. + * Returns a Set of task IDs that were added since the last render. + * IDs are automatically cleared after the animation duration. + */ +export function useNewTaskIds(tasks: Task[], animationDurationMs = 600): Set<string> { + const [newIds, setNewIds] = useState<Set<string>>(new Set()); + const prevIdsRef = useRef<Set<string>>(new Set()); + const initialLoadRef = useRef(true); + + useEffect(() => { + const currentIds = new Set(tasks.map((t) => t.id)); + + // Skip animation on initial load + if (initialLoadRef.current) { + initialLoadRef.current = false; + prevIdsRef.current = currentIds; + return; + } + + const added = new Set<string>(); + for (const id of currentIds) { + if (!prevIdsRef.current.has(id)) { + added.add(id); + } + } + + prevIdsRef.current = currentIds; + + if (added.size === 0) return; + + setNewIds((prev) => { + const merged = new Set(prev); + for (const id of added) merged.add(id); + return merged; + }); + + // Clear after animation completes + const timer = setTimeout(() => { + setNewIds((prev) => { + const next = new Set(prev); + for (const id of added) next.delete(id); + return next; + }); + }, animationDurationMs); + + return () => clearTimeout(timer); + }, [tasks, animationDurationMs]); + + return newIds; +} diff --git a/ui/src/index.css b/ui/src/index.css index 18d2179..c8b35f0 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -3,9 +3,6 @@ /* Highlight.js themes */ @import "highlight.js/styles/github.css"; -/* BlockNote Shadcn styles */ -@source "../node_modules/@blocknote/shadcn"; - @custom-variant dark (&:is(.dark *)); :root { @@ -134,10 +131,6 @@ body { body { @apply bg-background text-foreground; } - /* BlockNote Shadcn border styles */ - .bn-shadcn * { - @apply border-border outline-ring/50; - } } /* Fix ScrollArea viewport table display causing overflow */ @@ -931,127 +924,15 @@ body { } /* ===== Shared Editor Typography ===== */ -/* These styles ensure consistent look between MDRender and BlockNoteEditor */ -/* Based on BlockNote's default styles for perfect alignment */ :root { - --editor-font-size: 1rem; /* 16px - matches BlockNote default */ - --editor-line-height: 1.5; /* matches BlockNote .bn-block-outer */ + --editor-font-size: 1rem; + --editor-line-height: 1.5; --editor-heading-line-height: 1.3; --editor-code-font-size: 0.875em; --editor-code-font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --editor-font-family: Inter, "SF Pro Display", -apple-system, BlinkMacSystemFont, "Open Sans", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; - --editor-block-padding: 3px 0; /* matches BlockNote .bn-block-content padding */ -} - -/* ===== BlockNote Editor ===== */ - -.blocknote-editor-wrapper { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - min-height: 0; -} - -.blocknote-editor-wrapper .bn-container { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; -} - -.blocknote-editor-wrapper .bn-editor { - flex: 1; - overflow-y: auto; - min-height: 0; - font-size: var(--editor-font-size); - line-height: var(--editor-line-height); -} - -.blocknote-render-wrapper { - width: 100%; -} - -.blocknote-render-wrapper .bn-editor { - min-height: auto; - font-size: var(--editor-font-size); - line-height: var(--editor-line-height); -} - -/* BlockNote typography sync */ -.blocknote-editor-wrapper .bn-block-content, -.blocknote-render-wrapper .bn-block-content { - font-size: var(--editor-font-size); - line-height: var(--editor-line-height); -} - -.blocknote-editor-wrapper [data-content-type="heading"] h1, -.blocknote-render-wrapper [data-content-type="heading"] h1 { - font-size: 1.875rem; - font-weight: 700; - line-height: var(--editor-heading-line-height); - margin-top: 1.5rem; - margin-bottom: 0.75rem; -} - -.blocknote-editor-wrapper [data-content-type="heading"] h2, -.blocknote-render-wrapper [data-content-type="heading"] h2 { - font-size: 1.5rem; - font-weight: 600; - line-height: var(--editor-heading-line-height); - margin-top: 1.25rem; - margin-bottom: 0.625rem; -} - -.blocknote-editor-wrapper [data-content-type="heading"] h3, -.blocknote-render-wrapper [data-content-type="heading"] h3 { - font-size: 1.25rem; - font-weight: 600; - line-height: var(--editor-heading-line-height); - margin-top: 1rem; - margin-bottom: 0.5rem; -} - -/* BlockNote code blocks */ -.blocknote-editor-wrapper [data-content-type="codeBlock"] pre, -.blocknote-render-wrapper [data-content-type="codeBlock"] pre { - font-family: var(--editor-code-font); - font-size: var(--editor-code-font-size); - border-radius: 0.375rem; - padding: 1rem; -} - -/* BlockNote inline code */ -.blocknote-editor-wrapper code:not(pre code), -.blocknote-render-wrapper code:not(pre code) { - font-family: var(--editor-code-font); - font-size: var(--editor-code-font-size); - padding: 0.125rem 0.375rem; - border-radius: 0.25rem; - background-color: var(--muted); -} - -/* BlockNote tables */ -.blocknote-editor-wrapper table, -.blocknote-render-wrapper table { - border-collapse: collapse; - width: 100%; - font-size: var(--editor-font-size); -} - -.blocknote-editor-wrapper th, -.blocknote-editor-wrapper td, -.blocknote-render-wrapper th, -.blocknote-render-wrapper td { - border: 1px solid var(--border); - padding: 0.5rem 0.75rem; -} - -.blocknote-editor-wrapper th, -.blocknote-render-wrapper th { - background-color: var(--muted); - font-weight: 600; + --editor-block-padding: 3px 0; } /* Mention badge styles */ @@ -1080,7 +961,7 @@ body { border-radius: 0 0 0.5rem 0.5rem !important; } -/* ===== MDRender Styles (synchronized with BlockNote) ===== */ +/* ===== MDRender Styles ===== */ .md-render-wrapper .wmde-markdown { font-size: var(--editor-font-size); @@ -1225,3 +1106,101 @@ body { .no-view-transition { view-transition-name: none; } + +/* ===== Doc Content Transition ===== */ +@keyframes doc-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-doc-in { + animation: doc-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes list-in { + from { + opacity: 0; + transform: translateX(-6px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.animate-list-in { + animation: list-in 0.18s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-doc-in, + .animate-list-in { + animation: none; + } +} + +/* ===== Dialog Animations ===== */ +@keyframes dialog-in { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +@keyframes dialog-out { + from { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + to { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } +} + +.animate-dialog-in { + animation: dialog-in 0.22s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.animate-dialog-out { + animation: dialog-out 0.18s cubic-bezier(0.4, 0, 1, 1) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-dialog-in, + .animate-dialog-out { + animation: none; + } +} + +/* ===== Page Transition Animation ===== */ +@keyframes page-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-page-in { + animation: page-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@media (prefers-reduced-motion: reduce) { + .animate-page-in { + animation: none; + } +} diff --git a/ui/src/lib/navigation.ts b/ui/src/lib/navigation.ts index b22ea1d..c95702d 100644 --- a/ui/src/lib/navigation.ts +++ b/ui/src/lib/navigation.ts @@ -1,8 +1,40 @@ import { router } from "../router"; export function navigateTo(to: string, options?: { replace?: boolean }) { + // Parse query string and hash from the path so TanStack Router handles them correctly + const hashIndex = to.indexOf("#"); + const queryIndex = to.indexOf("?"); + const splitIndex = queryIndex >= 0 ? queryIndex : hashIndex; + + let pathname = to; + let search: Record<string, string> | undefined; + let hash: string | undefined; + + if (splitIndex >= 0) { + pathname = to.slice(0, splitIndex); + const rest = to.slice(splitIndex); + + const hashStart = rest.indexOf("#"); + const queryPart = hashStart >= 0 ? rest.slice(0, hashStart) : rest; + const hashPart = hashStart >= 0 ? rest.slice(hashStart + 1) : undefined; + + if (queryPart.startsWith("?")) { + const params = new URLSearchParams(queryPart); + search = {}; + params.forEach((value, key) => { + search![key] = value; + }); + } + + if (hashPart !== undefined) { + hash = hashPart; + } + } + return router.navigate({ - to, + to: pathname, + search: search as any, + hash: hash, replace: options?.replace, }); } diff --git a/ui/src/pages/ConfigPage.tsx b/ui/src/pages/ConfigPage.tsx index 791486a..04b35ad 100644 --- a/ui/src/pages/ConfigPage.tsx +++ b/ui/src/pages/ConfigPage.tsx @@ -33,7 +33,7 @@ import { useOpenCode } from "../contexts/OpenCodeContext"; import { useOpenCodeModelManager } from "../hooks/useOpencodeModelManager"; import { OpenCodeModelManager } from "../components/organisms/OpenCodeModelManager"; import { toast } from "../components/ui/sonner"; -import { importApi, type Import, type ImportDetail, type ImportResult } from "../api/client"; +import { importApi, saveUserPreferences, type Import, type ImportDetail, type ImportResult } from "../api/client"; const DEFAULT_STATUSES = ["todo", "in-progress", "in-review", "done", "blocked", "on-hold", "urgent"]; const COLOR_OPTIONS = ["gray", "blue", "green", "yellow", "red", "purple", "orange", "pink", "cyan", "indigo"]; @@ -353,7 +353,10 @@ export default function ConfigPage() { providerResponse, status: openCodeStatus, lastLoadedAt, - onChange: (nextSettings) => update({ opencodeModels: nextSettings }), + onChange: async (nextSettings) => { + await saveUserPreferences({ opencodeModels: nextSettings }); + update({ opencodeModels: nextSettings }); + }, }); if (loading) { diff --git a/ui/src/pages/DashboardPage.tsx b/ui/src/pages/DashboardPage.tsx index da264c7..be886f0 100644 --- a/ui/src/pages/DashboardPage.tsx +++ b/ui/src/pages/DashboardPage.tsx @@ -1,51 +1,43 @@ /** * Dashboard Page - * Overview of tasks, docs, and SDD coverage โ€” Notion-like flat layout + * Overview with charts and grid layout */ import { useEffect, useState, useMemo } from "react"; import { CheckCircle2, - AlertTriangle, - ChevronDown, - ChevronUp, RefreshCw, Zap, Activity, ListTodo, - ClipboardCheck, + Clock, + TrendingUp, + Users, } from "lucide-react"; import type { Task } from "@/ui/models/task"; -import { api, getDocs, getSDDStats, type SDDResult, type Activity as ActivityType } from "../api/client"; -import { Button } from "../components/ui/button"; +import { api, type Activity as ActivityType } from "../api/client"; import { Progress } from "../components/ui/progress"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../components/ui/collapsible"; -import { cn, isSpec, parseACProgress, type Doc } from "../lib/utils"; +import { cn } from "../lib/utils"; interface DashboardPageProps { tasks: Task[]; loading: boolean; } -// Format duration in seconds to human readable function formatDuration(seconds: number): string { if (seconds < 60) return `${seconds}s`; const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) { - return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; - } + if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; return `${minutes}m`; } -// Format relative time function formatRelativeTime(date: Date): string { const now = new Date(); const diff = now.getTime() - date.getTime(); const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); - if (minutes < 1) return "just now"; if (minutes < 60) return `${minutes}m ago`; if (hours < 24) return `${hours}h ago`; @@ -53,130 +45,140 @@ function formatRelativeTime(date: Date): string { return date.toLocaleDateString(); } -// Get change description function getChangeDescription(change: { field: string; oldValue?: unknown; newValue?: unknown }): string { - const { field, oldValue, newValue } = change; + const { field, newValue } = change; switch (field) { - case "status": - return `status โ†’ ${newValue}`; - case "priority": - return `priority โ†’ ${newValue}`; - case "assignee": - return newValue ? `assigned to ${newValue}` : "unassigned"; - case "title": - return "title updated"; - case "description": - return "description updated"; - case "acceptanceCriteria": - return "AC updated"; - default: - return `${field} changed`; + case "status": return `status โ†’ ${newValue}`; + case "priority": return `priority โ†’ ${newValue}`; + case "assignee": return newValue ? `assigned to ${newValue}` : "unassigned"; + case "title": return "title updated"; + case "description": return "description updated"; + case "acceptanceCriteria": return "AC updated"; + default: return `${field} changed`; } } -export default function DashboardPage({ tasks, loading }: DashboardPageProps) { - const [docs, setDocs] = useState<Doc[]>([]); - const [docsLoading, setDocsLoading] = useState(true); - const [sddData, setSDDData] = useState<SDDResult | null>(null); - const [sddLoading, setSDDLoading] = useState(true); - const [warningsOpen, setWarningsOpen] = useState(false); - const [passedOpen, setPassedOpen] = useState(false); - const [activities, setActivities] = useState<ActivityType[]>([]); - const [activitiesLoading, setActivitiesLoading] = useState(true); +// --- Chart Components --- - // Load docs - useEffect(() => { - getDocs() - .then((d) => { - setDocs(d as unknown as Doc[]); - setDocsLoading(false); - }) - .catch(() => setDocsLoading(false)); - }, []); +interface DonutSegment { + value: number; + color: string; + label: string; +} - // Load SDD stats - const loadSDD = async () => { - try { - setSDDLoading(true); - const result = await getSDDStats(); - setSDDData(result); - } catch (err) { - console.error("Failed to load SDD stats:", err); - } finally { - setSDDLoading(false); - } - }; +function DonutChart({ segments, size = 140, strokeWidth = 20 }: { segments: DonutSegment[]; size?: number; strokeWidth?: number }) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const total = segments.reduce((sum, s) => sum + s.value, 0); + if (total === 0) { + return ( + <svg width={size} height={size} className="shrink-0"> + <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="currentColor" strokeWidth={strokeWidth} className="text-muted/30" /> + </svg> + ); + } - useEffect(() => { - loadSDD(); - }, []); + let offset = 0; + return ( + <svg width={size} height={size} className="shrink-0 -rotate-90"> + {segments.filter(s => s.value > 0).map((seg) => { + const pct = seg.value / total; + const dash = pct * circumference; + const gap = circumference - dash; + const el = ( + <circle + key={seg.label} + cx={size / 2} + cy={size / 2} + r={radius} + fill="none" + stroke={seg.color} + strokeWidth={strokeWidth} + strokeDasharray={`${dash} ${gap}`} + strokeDashoffset={-offset} + strokeLinecap="round" + className="transition-all duration-700 ease-out" + /> + ); + offset += dash; + return el; + })} + </svg> + ); +} + +function HorizontalBar({ value, max, color, label, count }: { value: number; max: number; color: string; label: string; count: number }) { + const pct = max > 0 ? (value / max) * 100 : 0; + return ( + <div className="flex items-center gap-3"> + <span className="text-xs text-muted-foreground w-16 shrink-0 text-right">{label}</span> + <div className="flex-1 h-5 bg-muted/40 rounded-full overflow-hidden"> + <div + className="h-full rounded-full transition-all duration-700 ease-out" + style={{ width: `${pct}%`, backgroundColor: color }} + /> + </div> + <span className="text-xs font-medium w-8 shrink-0">{count}</span> + </div> + ); +} + +// --- Card wrapper --- +function DashCard({ children, className }: { children: React.ReactNode; className?: string }) { + return ( + <div className={cn("rounded-xl border border-border/50 bg-card p-5", className)}> + {children} + </div> + ); +} + +function CardTitle({ icon: Icon, children, action }: { icon: React.ElementType; children: React.ReactNode; action?: React.ReactNode }) { + return ( + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-2"> + <Icon className="w-4 h-4 text-muted-foreground" /> + <h3 className="text-sm font-semibold">{children}</h3> + </div> + {action} + </div> + ); +} + +// --- Main Component --- + +export default function DashboardPage({ tasks, loading }: DashboardPageProps) { + const [activities, setActivities] = useState<ActivityType[]>([]); + const [activitiesLoading, setActivitiesLoading] = useState(true); - // Load activities useEffect(() => { api.getActivities({ limit: 10 }) - .then((data) => { - setActivities(data); - setActivitiesLoading(false); - }) + .then((data) => { setActivities(data); setActivitiesLoading(false); }) .catch(() => setActivitiesLoading(false)); }, []); - // Calculate time tracking stats const timeStats = useMemo(() => { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const weekStart = new Date(todayStart); weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - - let todaySeconds = 0; - let weekSeconds = 0; - let totalSeconds = 0; - + let todaySeconds = 0, weekSeconds = 0, totalSeconds = 0; for (const task of tasks) { totalSeconds += task.timeSpent || 0; for (const entry of task.timeEntries || []) { const entryDate = new Date(entry.startedAt); - if (entryDate >= todayStart) { - todaySeconds += entry.duration || 0; - } - if (entryDate >= weekStart) { - weekSeconds += entry.duration || 0; - } + if (entryDate >= todayStart) todaySeconds += entry.duration || 0; + if (entryDate >= weekStart) weekSeconds += entry.duration || 0; } } - return { today: todaySeconds, week: weekSeconds, total: totalSeconds }; }, [tasks]); - // Get recent tasks (sorted by updatedAt) const recentTasks = useMemo(() => { return [...tasks] .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) .slice(0, 5); }, [tasks]); - // Get spec progress data - const specProgress = useMemo(() => { - const specs = docs.filter((d) => isSpec(d)); - return specs.map((spec) => { - const progress = parseACProgress(spec.content || ""); - const linkedTasks = tasks.filter((t) => { - if (!t.spec) return false; - const normalizedSpec = t.spec.replace(/\.md$/, "").replace(/^specs\//, ""); - const docPath = spec.path?.replace(/\.md$/, "").replace(/^specs\//, "") || ""; - return normalizedSpec === docPath; - }); - const completedTasks = linkedTasks.filter((t) => t.status === "done").length; - return { - ...spec, - acProgress: progress, - linkedTasks: linkedTasks.length, - completedTasks, - }; - }).slice(0, 6); - }, [docs, tasks]); - - // Calculate task stats const taskStats = { total: tasks.length, todo: tasks.filter((t) => t.status === "todo").length, @@ -187,408 +189,389 @@ export default function DashboardPage({ tasks, loading }: DashboardPageProps) { highPriority: tasks.filter((t) => t.priority === "high" && t.status !== "done").length, }; - const taskCompletion = taskStats.total > 0 ? Math.round((taskStats.done / taskStats.total) * 100) : 0; - - // Calculate doc stats - const docStats = { - total: docs.length, + const priorityStats = { + high: tasks.filter((t) => t.priority === "high").length, + medium: tasks.filter((t) => t.priority === "medium").length, + low: tasks.filter((t) => t.priority === "low").length, }; - const sddCoverage = sddData?.stats.coverage.percent ?? 0; + const taskCompletion = taskStats.total > 0 ? Math.round((taskStats.done / taskStats.total) * 100) : 0; + + const statusSegments: DonutSegment[] = [ + { value: taskStats.todo, color: "#9ca3af", label: "To Do" }, + { value: taskStats.inProgress, color: "#eab308", label: "In Progress" }, + { value: taskStats.inReview, color: "#3b82f6", label: "In Review" }, + { value: taskStats.done, color: "#22c55e", label: "Done" }, + { value: taskStats.blocked, color: "#ef4444", label: "Blocked" }, + ]; return ( <div className="h-full overflow-auto"> - <div className="max-w-[960px] mx-auto px-6 py-10"> - {/* Page Header */} - <div className="mb-10"> + <div className="max-w-[1100px] mx-auto px-6 py-10"> + {/* Header */} + <div className="mb-8"> <h1 className="text-3xl font-semibold tracking-tight">Dashboard</h1> <p className="text-muted-foreground mt-1">Overview of your project</p> </div> - {/* Key Metrics Row */} - <div className="grid grid-cols-2 sm:grid-cols-4 gap-6 mb-2"> - <div> + {/* Top Metric Cards */} + <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6"> + <DashCard> <div className="text-3xl font-semibold tracking-tight"> {loading ? <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> : taskStats.total} </div> - <div className="text-sm text-muted-foreground mt-1">Total Tasks</div> - </div> - <div> + <div className="text-xs text-muted-foreground mt-1">Total Tasks</div> + </DashCard> + <DashCard> <div className="text-3xl font-semibold tracking-tight"> {loading ? <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> : `${taskCompletion}%`} </div> - <div className="text-sm text-muted-foreground mt-1">Completion</div> - </div> - <div> + <div className="text-xs text-muted-foreground mt-1">Completion</div> + </DashCard> + <DashCard> <div className="text-3xl font-semibold tracking-tight"> - {docsLoading ? <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> : docStats.total} + {loading ? <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> : taskStats.inProgress} </div> - <div className="text-sm text-muted-foreground mt-1">Documents</div> - </div> - <div> - <div className={cn( - "text-3xl font-semibold tracking-tight", - !sddLoading && sddData && sddCoverage >= 75 ? "text-green-600 dark:text-green-400" : - !sddLoading && sddData && sddCoverage >= 50 ? "text-yellow-600 dark:text-yellow-400" : "" - )}> - {sddLoading ? <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> : `${sddCoverage}%`} + <div className="text-xs text-muted-foreground mt-1">In Progress</div> + </DashCard> + <DashCard className="flex items-center gap-4"> + <div className="relative shrink-0"> + <DonutChart + segments={[ + { value: taskStats.done, color: "#22c55e", label: "Done" }, + { value: taskStats.total - taskStats.done, color: "hsl(var(--muted))", label: "Remaining" }, + ]} + size={56} + strokeWidth={8} + /> + <div className="absolute inset-0 flex items-center justify-center"> + <span className="text-xs font-semibold">{taskCompletion}%</span> + </div> </div> - <div className="text-sm text-muted-foreground mt-1">SDD Coverage</div> - </div> + <div> + <div className="text-sm font-semibold">{taskStats.done}/{taskStats.total}</div> + <div className="text-xs text-muted-foreground mt-0.5">Tasks Done</div> + </div> + </DashCard> </div> - {/* Tasks Section */} - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">Tasks</h2> - <a href="/tasks" className="text-xs text-muted-foreground hover:text-foreground transition-colors">View all โ†’</a> - </div> - - {loading ? ( - <div className="flex items-center justify-center py-8"> - <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> - </div> - ) : ( - <> - {/* Completion Progress */} - <div className="mb-5"> - <div className="flex items-center justify-between text-sm mb-2"> - <span className="text-muted-foreground">Completion</span> - <span className="font-medium">{taskCompletion}%</span> - </div> - <Progress value={taskCompletion} className="h-2" /> + {/* Charts Row: Status Donut + Priority Bars */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> + {/* Status Distribution */} + <DashCard> + <CardTitle icon={TrendingUp}>Status Distribution</CardTitle> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> </div> - - {/* Status Breakdown - inline flow */} - <div className="flex flex-wrap gap-x-6 gap-y-2"> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-gray-400" /> - <span className="text-sm text-muted-foreground">To Do</span> - <span className="text-sm font-medium">{taskStats.todo}</span> - </div> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-yellow-500" /> - <span className="text-sm text-muted-foreground">In Progress</span> - <span className="text-sm font-medium">{taskStats.inProgress}</span> - </div> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-blue-500" /> - <span className="text-sm text-muted-foreground">In Review</span> - <span className="text-sm font-medium">{taskStats.inReview}</span> - </div> - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-green-500" /> - <span className="text-sm text-muted-foreground">Done</span> - <span className="text-sm font-medium">{taskStats.done}</span> - </div> - {taskStats.blocked > 0 && ( - <div className="flex items-center gap-2"> - <div className="w-2 h-2 rounded-full bg-red-500" /> - <span className="text-sm text-muted-foreground">Blocked</span> - <span className="text-sm font-medium">{taskStats.blocked}</span> + ) : ( + <div className="flex items-center gap-6"> + <div className="relative"> + <DonutChart segments={statusSegments} /> + <div className="absolute inset-0 flex flex-col items-center justify-center"> + <span className="text-2xl font-semibold">{taskCompletion}%</span> + <span className="text-[10px] text-muted-foreground">done</span> </div> - )} - </div> - - {/* High Priority Alert */} - {taskStats.highPriority > 0 && ( - <div className="flex items-center gap-2 mt-4 text-sm text-red-600 dark:text-red-400"> - <Zap className="w-3.5 h-3.5" /> - <span>{taskStats.highPriority} high priority task{taskStats.highPriority > 1 ? "s" : ""} remaining</span> </div> - )} - </> - )} - </section> - - {/* Time Tracking Section */} - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">Time Tracking</h2> - </div> - - {loading ? ( - <div className="flex items-center justify-center py-8"> - <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> - </div> - ) : ( - <div className="grid grid-cols-3 gap-8"> - <div> - <div className="text-2xl font-semibold tracking-tight"> - {formatDuration(timeStats.today)} - </div> - <div className="text-sm text-muted-foreground mt-1">Today</div> - </div> - <div> - <div className="text-2xl font-semibold tracking-tight"> - {formatDuration(timeStats.week)} - </div> - <div className="text-sm text-muted-foreground mt-1">This Week</div> - </div> - <div> - <div className="text-2xl font-semibold tracking-tight"> - {formatDuration(timeStats.total)} + <div className="flex-1 space-y-2"> + {statusSegments.filter(s => s.value > 0).map((seg) => ( + <div key={seg.label} className="flex items-center gap-2 text-sm"> + <div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: seg.color }} /> + <span className="text-muted-foreground flex-1">{seg.label}</span> + <span className="font-medium">{seg.value}</span> + </div> + ))} </div> - <div className="text-sm text-muted-foreground mt-1">Total</div> </div> - </div> - )} - </section> - - {/* Recent Activity Section */} - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">Recent Activity</h2> - </div> - - {activitiesLoading ? ( - <div className="flex items-center justify-center py-8"> - <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> - </div> - ) : activities.length === 0 ? ( - <div className="flex flex-col items-center justify-center py-12 text-center"> - <Activity className="w-8 h-8 text-muted-foreground/40 mb-2" /> - <p className="text-sm text-muted-foreground">No recent activity</p> - </div> - ) : ( - <div className="space-y-0.5"> - {activities.slice(0, 5).map((activity, i) => ( - <a - key={`${activity.taskId}-${activity.version}-${i}`} - href={`/kanban/${activity.taskId}`} - className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors" - > - <div className="w-1.5 h-1.5 rounded-full bg-foreground/25 shrink-0" /> - <div className="flex-1 min-w-0"> - <span className="text-sm truncate">{activity.taskTitle}</span> - <span className="text-xs text-muted-foreground ml-2"> - {activity.changes.slice(0, 2).map((c) => getChangeDescription(c)).join(", ")} - </span> - </div> - <span className="text-xs text-muted-foreground shrink-0"> - {formatRelativeTime(activity.timestamp)} - </span> - </a> - ))} - </div> - )} - </section> - - {/* Recent Tasks Section */} - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">Recent Tasks</h2> - <a href="/tasks" className="text-xs text-muted-foreground hover:text-foreground transition-colors">View all โ†’</a> - </div> + )} + </DashCard> - {loading ? ( - <div className="flex items-center justify-center py-8"> - <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> - </div> - ) : recentTasks.length === 0 ? ( - <div className="flex flex-col items-center justify-center py-12 text-center"> - <ListTodo className="w-8 h-8 text-muted-foreground/40 mb-2" /> - <p className="text-sm text-muted-foreground">No tasks yet</p> - </div> - ) : ( - <div className="space-y-0.5"> - {recentTasks.map((task) => ( - <a - key={task.id} - href={`/kanban/${task.id}`} - className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors" - > - <div className={cn( - "w-2 h-2 rounded-full shrink-0", - task.status === "done" ? "bg-green-500" : - task.status === "in-progress" ? "bg-yellow-500" : - task.status === "blocked" ? "bg-red-500" : - task.status === "in-review" ? "bg-blue-500" : "bg-gray-400" - )} /> - <div className="flex-1 min-w-0"> - <span className="text-sm truncate block">{task.title}</span> + {/* Priority Breakdown */} + <DashCard> + <CardTitle icon={Zap}>Priority Breakdown</CardTitle> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> + </div> + ) : ( + <div className="space-y-3 mt-2"> + <HorizontalBar value={priorityStats.high} max={taskStats.total} color="#ef4444" label="High" count={priorityStats.high} /> + <HorizontalBar value={priorityStats.medium} max={taskStats.total} color="#eab308" label="Medium" count={priorityStats.medium} /> + <HorizontalBar value={priorityStats.low} max={taskStats.total} color="#3b82f6" label="Low" count={priorityStats.low} /> + {taskStats.highPriority > 0 && ( + <div className="flex items-center gap-2 pt-2 text-xs text-red-600 dark:text-red-400"> + <Zap className="w-3 h-3" /> + <span>{taskStats.highPriority} high priority remaining</span> </div> - <span className="text-xs text-muted-foreground shrink-0">#{task.id}</span> - {task.priority === "high" && ( - <span className="text-xs text-red-600 dark:text-red-400 shrink-0">HIGH</span> - )} - </a> - ))} - </div> - )} - </section> - - {/* SDD Coverage Section */} - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">SDD Coverage</h2> - <Button - variant="ghost" - size="sm" - onClick={loadSDD} - disabled={sddLoading} - className="h-7 w-7 p-0" - > - <RefreshCw className={cn("w-3.5 h-3.5", sddLoading && "animate-spin")} /> - </Button> - </div> + )} + </div> + )} + </DashCard> + </div> - {sddLoading && !sddData ? ( - <div className="flex items-center justify-center py-8"> - <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> - </div> - ) : !sddData || sddData.stats.specs.total === 0 ? ( - <div className="flex flex-col items-center justify-center py-12 text-center"> - <ClipboardCheck className="w-8 h-8 text-muted-foreground/40 mb-2" /> - <p className="text-sm text-muted-foreground">No specs found</p> - <p className="text-xs text-muted-foreground mt-1">Create specs in docs/specs/ folder</p> - </div> - ) : ( - <> - {/* Coverage Stats */} - <div className="grid grid-cols-3 gap-8 mb-5"> + {/* Middle Row: Time Tracking + Completion Progress */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> + {/* Time Tracking */} + <DashCard> + <CardTitle icon={Clock}>Time Tracking</CardTitle> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> + </div> + ) : ( + <div className="grid grid-cols-3 gap-4"> <div> - <div className="text-2xl font-semibold tracking-tight">{sddData.stats.specs.total}</div> - <div className="text-sm text-muted-foreground mt-1">Specs</div> + <div className="text-2xl font-semibold tracking-tight">{formatDuration(timeStats.today)}</div> + <div className="text-xs text-muted-foreground mt-1">Today</div> </div> <div> - <div className="text-2xl font-semibold tracking-tight">{sddData.stats.tasks.withSpec}</div> - <div className="text-sm text-muted-foreground mt-1">Linked Tasks</div> + <div className="text-2xl font-semibold tracking-tight">{formatDuration(timeStats.week)}</div> + <div className="text-xs text-muted-foreground mt-1">This Week</div> </div> <div> - <div className={cn( - "text-2xl font-semibold tracking-tight", - sddCoverage >= 75 ? "text-green-600 dark:text-green-400" : - sddCoverage >= 50 ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400" - )}> - {sddCoverage}% - </div> - <div className="text-sm text-muted-foreground mt-1">Coverage</div> + <div className="text-2xl font-semibold tracking-tight">{formatDuration(timeStats.total)}</div> + <div className="text-xs text-muted-foreground mt-1">Total</div> </div> </div> + )} + </DashCard> - {/* Coverage Progress */} - <div className="mb-5"> + {/* Task Completion */} + <DashCard> + <CardTitle icon={CheckCircle2}>Task Completion</CardTitle> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> + </div> + ) : ( + <div> <div className="flex items-center justify-between text-sm mb-2"> - <span className="text-muted-foreground">Task-Spec Coverage</span> - <span className="font-medium">{sddData.stats.coverage.linked}/{sddData.stats.coverage.total}</span> + <span className="text-muted-foreground">Progress</span> + <span className="font-medium">{taskStats.done}/{taskStats.total}</span> + </div> + <Progress value={taskCompletion} className="h-3 mb-3" /> + <div className="flex flex-wrap gap-x-5 gap-y-1"> + {statusSegments.filter(s => s.value > 0).map((seg) => ( + <div key={seg.label} className="flex items-center gap-1.5 text-xs"> + <div className="w-2 h-2 rounded-full" style={{ backgroundColor: seg.color }} /> + <span className="text-muted-foreground">{seg.label}</span> + <span className="font-medium">{seg.value}</span> + </div> + ))} </div> - <Progress value={sddCoverage} className="h-2" /> </div> + )} + </DashCard> + </div> - {/* Warnings */} - {sddData.warnings.length > 0 && ( - <Collapsible open={warningsOpen} onOpenChange={setWarningsOpen} className="mb-2"> - <CollapsibleTrigger className="flex items-center justify-between w-full py-1.5 text-sm hover:bg-muted/50 rounded px-2 -mx-2"> - <div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400"> - <AlertTriangle className="w-4 h-4" /> - <span>{sddData.warnings.length} Warning{sddData.warnings.length > 1 ? "s" : ""}</span> - </div> - {warningsOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} - </CollapsibleTrigger> - <CollapsibleContent> - <div className="mt-2 space-y-1 max-h-32 overflow-y-auto text-xs"> - {sddData.warnings.slice(0, 5).map((w, i) => ( - <div key={`${w.entity}-${i}`} className="text-muted-foreground truncate py-0.5"> - <span className="font-mono text-yellow-600 dark:text-yellow-400">{w.entity}</span>: {w.message} - </div> - ))} - {sddData.warnings.length > 5 && ( - <div className="text-muted-foreground italic">+{sddData.warnings.length - 5} more</div> - )} - </div> - </CollapsibleContent> - </Collapsible> - )} - - {/* Passed */} - {sddData.passed.length > 0 && ( - <Collapsible open={passedOpen} onOpenChange={setPassedOpen}> - <CollapsibleTrigger className="flex items-center justify-between w-full py-1.5 text-sm hover:bg-muted/50 rounded px-2 -mx-2"> - <div className="flex items-center gap-2 text-green-600 dark:text-green-400"> - <CheckCircle2 className="w-4 h-4" /> - <span>{sddData.passed.length} Passed</span> - </div> - {passedOpen ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} - </CollapsibleTrigger> - <CollapsibleContent> - <div className="mt-2 space-y-1 max-h-32 overflow-y-auto text-xs"> - {sddData.passed.map((p, i) => ( - <div key={`passed-${i}`} className="text-muted-foreground flex items-center gap-1.5 py-0.5"> - <CheckCircle2 className="w-3 h-3 text-green-600 dark:text-green-400 shrink-0" /> - <span className="truncate">{p}</span> - </div> - ))} + {/* Charts Row: Weekly Activity + Workload */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> + {/* Weekly Activity Bar Chart */} + <DashCard> + <CardTitle icon={Activity}>Weekly Activity</CardTitle> + <WeeklyActivityChart tasks={tasks} /> + </DashCard> + + {/* Labels Distribution */} + <DashCard> + <CardTitle icon={Users}>Labels Overview</CardTitle> + <LabelsChart tasks={tasks} /> + </DashCard> + </div> + + {/* Bottom Row: Activity + Recent Tasks */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> + {/* Recent Activity */} + <DashCard className="flex flex-col min-w-0 overflow-hidden"> + <CardTitle icon={Activity}>Recent Activity</CardTitle> + <div className="flex-1 min-h-[260px] min-w-0"> + {activitiesLoading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> + </div> + ) : activities.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full text-center"> + <Activity className="w-6 h-6 text-muted-foreground/40 mb-2" /> + <p className="text-xs text-muted-foreground">No recent activity</p> + </div> + ) : ( + <div className="space-y-0.5 max-h-[260px] overflow-y-auto overflow-x-hidden"> + {activities.slice(0, 8).map((activity, i) => ( + <a + key={`${activity.taskId}-${activity.version}-${i}`} + href={`/kanban/${activity.taskId}`} + className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors min-w-0" + > + <div className="w-1.5 h-1.5 rounded-full bg-foreground/25 shrink-0" /> + <div className="flex-1 min-w-0"> + <span className="text-sm truncate block">{activity.taskTitle}</span> + <span className="text-[11px] text-muted-foreground truncate block"> + {activity.changes.slice(0, 2).map((c) => getChangeDescription(c)).join(", ")} + </span> </div> - </CollapsibleContent> - </Collapsible> - )} - </> - )} - </section> - - {/* Spec Progress Section */} - {specProgress.length > 0 && ( - <section className="border-t border-border/40 pt-8 mt-8"> - <div className="flex items-center justify-between mb-4"> - <h2 className="text-lg font-semibold">Spec Progress</h2> - <a href="/docs" className="text-xs text-muted-foreground hover:text-foreground transition-colors">View all โ†’</a> + <span className="text-[11px] text-muted-foreground shrink-0"> + {formatRelativeTime(activity.timestamp)} + </span> + </a> + ))} + </div> + )} </div> - - {docsLoading ? ( + </DashCard> + + {/* Recent Tasks */} + <DashCard className="flex flex-col min-w-0 overflow-hidden"> + <CardTitle icon={ListTodo} action={ + <a href="/tasks" className="text-xs text-muted-foreground hover:text-foreground transition-colors">View all โ†’</a> + }>Recent Tasks</CardTitle> + <div className="flex-1 min-h-[260px] min-w-0"> + {loading ? ( <div className="flex items-center justify-center py-8"> <RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" /> </div> + ) : recentTasks.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-full text-center"> + <ListTodo className="w-6 h-6 text-muted-foreground/40 mb-2" /> + <p className="text-xs text-muted-foreground">No tasks yet</p> + </div> ) : ( - <div className="space-y-0.5"> - {specProgress.map((spec) => { - const acPercent = spec.acProgress.total > 0 - ? Math.round((spec.acProgress.completed / spec.acProgress.total) * 100) - : 0; - const status = spec.metadata.status || "draft"; - return ( - <a - key={spec.path} - href={`/docs/${spec.path}`} - className="flex items-center gap-4 py-2.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors" - > - <div className="flex-1 min-w-0"> - <div className="flex items-center gap-2 mb-1"> - <span className="text-sm font-medium truncate">{spec.metadata.title}</span> - <span className={cn( - "text-[10px] px-1.5 py-0.5 rounded font-medium uppercase shrink-0", - status === "implemented" ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" : - status === "approved" ? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" : - "bg-muted text-muted-foreground" - )}> - {status} - </span> - </div> - <Progress value={acPercent} className="h-1.5" /> - </div> - <div className="text-right shrink-0"> - <div className={cn( - "text-xs font-medium", - acPercent >= 75 ? "text-green-600 dark:text-green-400" : - acPercent >= 50 ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground" - )}> - {spec.acProgress.completed}/{spec.acProgress.total} AC - </div> - <div className="text-xs text-muted-foreground"> - {spec.linkedTasks} tasks ยท {spec.completedTasks} done - </div> - </div> - </a> - ); - })} + <div className="space-y-0.5 max-h-[260px] overflow-y-auto overflow-x-hidden"> + {recentTasks.map((task) => ( + <a + key={task.id} + href={`/kanban/${task.id}`} + className="flex items-center gap-3 py-1.5 px-2 -mx-2 rounded-md hover:bg-muted/50 transition-colors min-w-0" + > + <div className={cn( + "w-2 h-2 rounded-full shrink-0", + task.status === "done" ? "bg-green-500" : + task.status === "in-progress" ? "bg-yellow-500" : + task.status === "blocked" ? "bg-red-500" : + task.status === "in-review" ? "bg-blue-500" : "bg-gray-400" + )} /> + <span className="text-sm truncate flex-1">{task.title}</span> + <span className="text-[11px] text-muted-foreground shrink-0">#{task.id}</span> + {task.priority === "high" && ( + <span className="text-[11px] text-red-600 dark:text-red-400 shrink-0">HIGH</span> + )} + </a> + ))} </div> )} - </section> - )} + </div> + </DashCard> + </div> - {/* Bottom spacing */} <div className="h-10" /> </div> </div> ); } + +// --- Weekly Activity Bar Chart --- +const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +function WeeklyActivityChart({ tasks }: { tasks: Task[] }) { + const data = useMemo(() => { + const now = new Date(); + const days: { label: string; created: number; updated: number }[] = []; + + for (let i = 6; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + const dayEnd = new Date(dayStart.getTime() + 86400000); + + const created = tasks.filter((t) => { + const c = new Date(t.createdAt); + return c >= dayStart && c < dayEnd; + }).length; + + const updated = tasks.filter((t) => { + const u = new Date(t.updatedAt); + return u >= dayStart && u < dayEnd; + }).length; + + days.push({ label: DAY_LABELS[d.getDay()]!, created, updated }); + } + return days; + }, [tasks]); + + const maxVal = Math.max(1, ...data.map((d) => Math.max(d.created, d.updated))); + + return ( + <div className="flex items-end gap-2 h-[140px]"> + {data.map((day) => ( + <div key={day.label} className="flex-1 flex flex-col items-center gap-1 h-full justify-end"> + <div className="flex gap-0.5 items-end flex-1 w-full justify-center"> + <div + className="w-2.5 rounded-t bg-blue-500/80 transition-all duration-500" + style={{ height: `${(day.created / maxVal) * 100}%`, minHeight: day.created > 0 ? 4 : 0 }} + title={`${day.created} created`} + /> + <div + className="w-2.5 rounded-t bg-emerald-500/80 transition-all duration-500" + style={{ height: `${(day.updated / maxVal) * 100}%`, minHeight: day.updated > 0 ? 4 : 0 }} + title={`${day.updated} updated`} + /> + </div> + <span className="text-[10px] text-muted-foreground">{day.label}</span> + </div> + ))} + </div> + ); +} + +// --- Labels Distribution Chart --- +const LABEL_COLORS = [ + "#3b82f6", "#8b5cf6", "#ec4899", "#f97316", "#14b8a6", + "#eab308", "#ef4444", "#6366f1", "#06b6d4", "#84cc16", +]; + +function LabelsChart({ tasks }: { tasks: Task[] }) { + const labelData = useMemo(() => { + const map = new Map<string, number>(); + for (const t of tasks) { + for (const label of t.labels || []) { + map.set(label, (map.get(label) || 0) + 1); + } + } + return [...map.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + }, [tasks]); + + const maxCount = Math.max(1, ...labelData.map(([, c]) => c)); + + if (labelData.length === 0) { + return ( + <div className="flex flex-col items-center justify-center py-8 text-center"> + <Users className="w-6 h-6 text-muted-foreground/40 mb-2" /> + <p className="text-xs text-muted-foreground">No labels yet</p> + </div> + ); + } + + return ( + <div className="space-y-2.5"> + {labelData.map(([label, count], i) => ( + <div key={label} className="flex items-center gap-3"> + <span className="text-xs text-muted-foreground w-20 shrink-0 truncate text-right" title={label}> + {label} + </span> + <div className="flex-1 h-5 bg-muted/40 rounded-full overflow-hidden"> + <div + className="h-full rounded-full transition-all duration-500" + style={{ + width: `${(count / maxCount) * 100}%`, + backgroundColor: LABEL_COLORS[i % LABEL_COLORS.length], + opacity: 0.8, + }} + /> + </div> + <span className="text-xs font-medium w-6 shrink-0">{count}</span> + </div> + ))} + </div> + ); +} diff --git a/ui/src/pages/DocsPage.tsx b/ui/src/pages/DocsPage.tsx index 414a324..2f1a1a5 100644 --- a/ui/src/pages/DocsPage.tsx +++ b/ui/src/pages/DocsPage.tsx @@ -1,81 +1,61 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRouterState } from "@tanstack/react-router"; import { - Plus, - FileText, Pencil, Check, X, Copy, - ListChecks, - ClipboardCheck, - ChevronDown, - ChevronUp, - ExternalLink, ArrowLeft, Maximize2, Minimize2, Menu, } from "lucide-react"; -import { MDEditor, MDRender } from "../components/editor"; +import { MDEditor } from "../components/editor"; import { Button } from "../components/ui/button"; -import { Badge } from "../components/ui/badge"; -import { Progress } from "../components/ui/progress"; -import { createDoc, updateDoc } from "../api/client"; +import { updateDoc } from "../api/client"; import { useGlobalTask } from "../contexts/GlobalTaskContext"; -import { useDocs } from "../contexts/DocsContext"; +import { useDocsOptional } from "../contexts/DocsContext"; import { DocsFileManager } from "../components/organisms/DocsFileManager"; -import { - toDisplayPath, - normalizePathForAPI, - isSpec, - getSpecStatus, - parseACProgress, -} from "../lib/utils"; +import { toDisplayPath, normalizePathForAPI } from "../lib/utils"; import { navigateTo } from "../lib/navigation"; import { DocsTOC } from "../components/molecules/DocsTOC"; import { TaskPreviewDialog } from "../components/organisms/TaskDetail/TaskPreviewDialog"; import { Sheet, SheetContent, SheetTitle } from "../components/ui/sheet"; +import { DocsDocHeader } from "./docs/DocsDocHeader"; +import { DocsCreateView } from "./docs/DocsCreateView"; +import { DocsEmptyState } from "./docs/DocsEmptyState"; +import { MDRenderWithHighlight } from "../components/editor/MDRenderWithHighlight"; + export default function DocsPage() { const location = useRouterState({ select: (state) => state.location }); const { openTask } = useGlobalTask(); + const docsContext = useDocsOptional(); + + if (!docsContext) { + return ( + <div className="p-6 flex items-center justify-center h-64"> + <div className="text-lg text-muted-foreground">Loading documentation...</div> + </div> + ); + } + const { - docs, - loading, - error, - selectedDoc, - setSelectedDoc, - isEditing, - setIsEditing, - editedContent, - setEditedContent, - linkedTasks, - showSpecsOnly, - setShowSpecsOnly, - linkedTasksExpanded, - setLinkedTasksExpanded, - loadDocs, - currentFolder, - navigateToFolder, - } = useDocs(); + docs, loading, error, selectedDoc, setSelectedDoc, + isEditing, setIsEditing, editedContent, setEditedContent, + linkedTasks, showSpecsOnly, setShowSpecsOnly, + linkedTasksExpanded, setLinkedTasksExpanded, + loadDocs, currentFolder, navigateToFolder, + } = docsContext; const [saving, setSaving] = useState(false); const [showCreateView, setShowCreateView] = useState(false); - const [newDocTitle, setNewDocTitle] = useState(""); - const [newDocDescription, setNewDocDescription] = useState(""); - const [newDocTags, setNewDocTags] = useState(""); - const [newDocFolder, setNewDocFolder] = useState(currentFolder || ""); - const [newDocContent, setNewDocContent] = useState(""); - const [creating, setCreating] = useState(false); const [pathCopied, setPathCopied] = useState(false); const [previewTaskId, setPreviewTaskId] = useState<string | null>(null); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [docSearchQuery, setDocSearchQuery] = useState(""); - const [wideMode, setWideMode] = useState(() => { - return localStorage.getItem("docs-wide-mode") === "true"; - }); - // Inline metadata editing (Notion-like) + const [lineHighlight, setLineHighlight] = useState<{ start: number; end: number } | null>(null); + const [wideMode, setWideMode] = useState(() => localStorage.getItem("docs-wide-mode") === "true"); const [metaTitle, setMetaTitle] = useState(""); const [metaDescription, setMetaDescription] = useState(""); const [metaTags, setMetaTags] = useState(""); @@ -84,20 +64,13 @@ export default function DocsPage() { const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollPositions = useRef<Map<string, number>>(new Map()); const scrollAnimationRef = useRef<number | null>(null); + const lineHighlightRef = useRef<HTMLDivElement>(null); - const updateSectionHash = useCallback((headingId: string | null) => { - const hash = headingId ? `#${encodeURIComponent(headingId)}` : ""; - const url = `${window.location.pathname}${window.location.search}${hash}`; - window.history.replaceState(window.history.state, "", url); - }, []); - + // --- Scroll helpers --- const scrollToHeading = useCallback((headingId: string, behavior: ScrollBehavior = "smooth") => { const container = scrollContainerRef.current; if (!container) return false; - - const viewport = - container.querySelector<HTMLElement>("[data-radix-scroll-area-viewport]") || - container; + const viewport = container.querySelector<HTMLElement>("[data-radix-scroll-area-viewport]") || container; const heading = viewport.querySelector<HTMLElement>(`#${CSS.escape(headingId)}`); if (!heading) return false; @@ -119,37 +92,33 @@ export default function DocsPage() { const distance = targetTop - startTop; const duration = 280; const startTime = performance.now(); - const easeInOutCubic = (t: number) => - t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); const animate = (now: number) => { - const elapsed = now - startTime; - const progress = Math.min(elapsed / duration, 1); - viewport.scrollTop = startTop + distance * easeInOutCubic(progress); - + const progress = Math.min((now - startTime) / duration, 1); + viewport.scrollTop = startTop + distance * ease(progress); if (progress < 1) { scrollAnimationRef.current = window.requestAnimationFrame(animate); } else { scrollAnimationRef.current = null; } }; - scrollAnimationRef.current = window.requestAnimationFrame(animate); return true; }, []); + const updateSectionHash = useCallback((headingId: string | null) => { + const hash = headingId ? `#${encodeURIComponent(headingId)}` : ""; + window.history.replaceState(window.history.state, "", `${window.location.pathname}${window.location.search}${hash}`); + }, []); + const navigateToHeading = useCallback((headingId: string, behavior: ScrollBehavior = "smooth") => { - if (scrollToHeading(headingId, behavior)) { - updateSectionHash(headingId); - } + if (scrollToHeading(headingId, behavior)) updateSectionHash(headingId); }, [scrollToHeading, updateSectionHash]); - // Persist wide mode - useEffect(() => { - localStorage.setItem("docs-wide-mode", String(wideMode)); - }, [wideMode]); + // --- Effects --- + useEffect(() => { localStorage.setItem("docs-wide-mode", String(wideMode)); }, [wideMode]); - // Sync metadata state when selected doc changes useEffect(() => { if (selectedDoc) { setMetaTitle(selectedDoc.metadata.title || ""); @@ -158,234 +127,137 @@ export default function DocsPage() { } }, [selectedDoc?.path]); - // Save metadata on blur (Notion-like auto-save) const handleSaveMetadata = async (field: "title" | "description" | "tags") => { if (!selectedDoc || selectedDoc.isImported) return; - const updates: { title?: string; description?: string; tags?: string[] } = {}; - - if (field === "title" && metaTitle !== (selectedDoc.metadata.title || "")) { - updates.title = metaTitle; - } else if (field === "description" && metaDescription !== (selectedDoc.metadata.description || "")) { - updates.description = metaDescription; - } else if (field === "tags") { + if (field === "title" && metaTitle !== (selectedDoc.metadata.title || "")) updates.title = metaTitle; + else if (field === "description" && metaDescription !== (selectedDoc.metadata.description || "")) updates.description = metaDescription; + else if (field === "tags") { const newTags = metaTags.split(",").map(t => t.trim()).filter(t => t); - const oldTags = selectedDoc.metadata.tags || []; - if (JSON.stringify(newTags) !== JSON.stringify(oldTags)) { - updates.tags = newTags; - } + if (JSON.stringify(newTags) !== JSON.stringify(selectedDoc.metadata.tags || [])) updates.tags = newTags; } - if (Object.keys(updates).length === 0) return; - try { await updateDoc(normalizePathForAPI(selectedDoc.path), updates); loadDocs(); } catch (err) { console.error("Failed to save metadata:", err); - // Reset on error setMetaTitle(selectedDoc.metadata.title || ""); setMetaDescription(selectedDoc.metadata.description || ""); setMetaTags(selectedDoc.metadata.tags?.join(", ") || ""); } }; - // Check URL for ?create=true param + // URL params useEffect(() => { - if (location.search.create === true || location.search.create === "true") { + if ((location.search as Record<string, unknown>).create === true || (location.search as Record<string, unknown>).create === "true") { setShowCreateView(true); navigateTo("/docs", { replace: true }); } - }, [location.search.create]); + }, [(location.search as Record<string, unknown>).create]); - // Save scroll position before changing doc - const saveScrollPosition = useCallback(() => { - if (selectedDoc && scrollContainerRef.current) { - scrollPositions.current.set(selectedDoc.path, scrollContainerRef.current.scrollTop); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const lParam = params.get("L"); + if (!lParam) { setLineHighlight(null); return; } + const rangeMatch = lParam.match(/^(\d+)-(\d+)$/); + if (rangeMatch && rangeMatch[1] && rangeMatch[2]) { + setLineHighlight({ start: +rangeMatch[1], end: +rangeMatch[2] }); + } else { + const line = parseInt(lParam, 10); + setLineHighlight(!isNaN(line) ? { start: line, end: line } : null); } - }, [selectedDoc]); + }, [location.href]); - // Restore scroll position when doc changes + useEffect(() => { + if (lineHighlight && lineHighlightRef.current) { + requestAnimationFrame(() => lineHighlightRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })); + } + }, [lineHighlight]); + + // Scroll position restore useEffect(() => { if (selectedDoc && scrollContainerRef.current) { const activeHash = decodeURIComponent(window.location.hash.replace(/^#/, "")); if (activeHash) return; - const savedPosition = - scrollPositions.current.get(selectedDoc.path) || 0; - requestAnimationFrame(() => { - if (scrollContainerRef.current) { - scrollContainerRef.current.scrollTop = savedPosition; - } - }); + const saved = scrollPositions.current.get(selectedDoc.path) || 0; + requestAnimationFrame(() => { if (scrollContainerRef.current) scrollContainerRef.current.scrollTop = saved; }); } }, [selectedDoc?.path]); useEffect(() => { if (!selectedDoc) return; - - const applyHashNavigation = () => { - const headingId = decodeURIComponent(window.location.hash.replace(/^#/, "")); - if (!headingId) return; - window.setTimeout(() => { - scrollToHeading(headingId, "auto"); - }, 80); + const applyHash = () => { + const id = decodeURIComponent(window.location.hash.replace(/^#/, "")); + if (id) window.setTimeout(() => scrollToHeading(id, "auto"), 80); }; - - applyHashNavigation(); - window.addEventListener("hashchange", applyHashNavigation); - return () => window.removeEventListener("hashchange", applyHashNavigation); + applyHash(); + window.addEventListener("hashchange", applyHash); + return () => window.removeEventListener("hashchange", applyHash); }, [scrollToHeading, selectedDoc?.path]); - useEffect(() => { - return () => { - if (scrollAnimationRef.current !== null) { - window.cancelAnimationFrame(scrollAnimationRef.current); - } - }; - }, []); + useEffect(() => () => { if (scrollAnimationRef.current !== null) window.cancelAnimationFrame(scrollAnimationRef.current); }, []); - // Handle markdown link clicks for internal navigation + // Handle markdown link clicks useEffect(() => { const handleLinkClick = (e: MouseEvent) => { let target = e.target as HTMLElement; - while ( - target && - target.tagName !== "A" && - target !== markdownPreviewRef.current - ) { + while (target && target.tagName !== "A" && target !== markdownPreviewRef.current) { target = target.parentElement as HTMLElement; } - if (target && target.tagName === "A") { - const anchor = target as HTMLAnchorElement; - const href = anchor.getAttribute("href"); - - if (href && href.startsWith("#")) { + const href = (target as HTMLAnchorElement).getAttribute("href"); + if (href?.startsWith("#")) { e.preventDefault(); navigateToHeading(decodeURIComponent(href.slice(1))); return; } - if (href && /^@?task-[\w.]+(.md)?$/.test(href)) { e.preventDefault(); - const taskId = href - .replace(/^@/, "") - .replace(/^task-/, "") - .replace(".md", ""); - openTask(taskId); + openTask(href.replace(/^@/, "").replace(/^task-/, "").replace(".md", "")); return; } - - if (href && href.startsWith("@doc/")) { + if (href?.startsWith("@doc/")) { e.preventDefault(); - const docPath = href.replace("@doc/", ""); - navigateTo(`/docs/${docPath}.md`); + navigateTo(`/docs/${href.replace("@doc/", "")}.md`); return; } - if (href && (href.endsWith(".md") || href.includes(".md#"))) { e.preventDefault(); - const normalizedHref = href.replace(/^\.\//, "").replace(/^\//, ""); - const [docPathPart, hashPart] = normalizedHref.split("#"); - const docPath = docPathPart ?? normalizedHref; - void navigateTo(`/docs/${docPath}`).then(() => { - if (hashPart) { - window.setTimeout(() => { - navigateToHeading(decodeURIComponent(hashPart), "auto"); - }, 80); - } + const normalized = href.replace(/^\.\//, "").replace(/^\//, ""); + const [docPath, hashPart] = normalized.split("#"); + void navigateTo(`/docs/${docPath ?? normalized}`).then(() => { + if (hashPart) window.setTimeout(() => navigateToHeading(decodeURIComponent(hashPart), "auto"), 80); }); } } }; - - const previewEl = markdownPreviewRef.current; - if (previewEl) { - previewEl.addEventListener("click", handleLinkClick); - return () => previewEl.removeEventListener("click", handleLinkClick); - } + const el = markdownPreviewRef.current; + if (el) { el.addEventListener("click", handleLinkClick); return () => el.removeEventListener("click", handleLinkClick); } }, [docs, navigateToHeading, openTask, selectedDoc]); - const handleCreateDoc = async () => { - if (!newDocTitle.trim()) return; - - setCreating(true); - try { - const tags = newDocTags - .split(",") - .map((t) => t.trim()) - .filter((t) => t); - - await createDoc({ - title: newDocTitle, - description: newDocDescription, - tags, - folder: newDocFolder, - content: newDocContent, - }); - - setNewDocTitle(""); - setNewDocDescription(""); - setNewDocTags(""); - setNewDocFolder(""); - setNewDocContent(""); - setShowCreateView(false); - setMobileSidebarOpen(false); - loadDocs(); - } catch (err) { - console.error("Failed to create doc:", err); - } finally { - setCreating(false); - } - }; - - const handleEdit = () => { - if (selectedDoc) { - setEditedContent(selectedDoc.content); - setIsEditing(true); - } - }; - + // --- Handlers --- + const handleEdit = () => { if (selectedDoc) { setEditedContent(selectedDoc.content); setIsEditing(true); } }; const handleCopyPath = () => { if (selectedDoc) { - const normalizedPath = toDisplayPath(selectedDoc.path).replace( - /\.md$/, - "", - ); - const refPath = `@doc/${normalizedPath}`; - navigator.clipboard.writeText(refPath).then(() => { + navigator.clipboard.writeText(`@doc/${toDisplayPath(selectedDoc.path).replace(/\.md$/, "")}`).then(() => { setPathCopied(true); setTimeout(() => setPathCopied(false), 2000); }); } }; - const handleSave = async () => { if (!selectedDoc) return; - setSaving(true); - try { - await updateDoc(normalizePathForAPI(selectedDoc.path), { - content: editedContent, - }); - loadDocs(); - setIsEditing(false); - } catch (err) { - console.error("Failed to save doc:", err); - } finally { - setSaving(false); - } + try { await updateDoc(normalizePathForAPI(selectedDoc.path), { content: editedContent }); loadDocs(); setIsEditing(false); } + catch (err) { console.error("Failed to save doc:", err); } + finally { setSaving(false); } }; - - const handleCancel = () => { - setIsEditing(false); - setEditedContent(""); - }; - - const openCreateView = () => { - setNewDocFolder(currentFolder || ""); - setShowCreateView(true); - setMobileSidebarOpen(false); + const handleCancel = () => { setIsEditing(false); setEditedContent(""); }; + const openCreateView = () => { setShowCreateView(true); setMobileSidebarOpen(false); }; + const dismissLineHighlight = () => { + setLineHighlight(null); + window.history.replaceState(window.history.state, "", window.location.pathname + window.location.hash); }; const sidebarContent = ( @@ -405,118 +277,67 @@ export default function DocsPage() { /> ); - if (loading) { - return ( - <div className="p-6 flex items-center justify-center h-64"> - <div className="text-lg text-muted-foreground"> - Loading documentation... - </div> - </div> - ); - } - - if (error) { - return ( - <div className="p-6 flex items-center justify-center h-64"> - <div className="text-center"> - <p className="text-lg text-destructive mb-2"> - Failed to load documentation - </p> - <p className="text-sm text-muted-foreground mb-4">{error}</p> - <Button - onClick={() => loadDocs()} - variant="outline" - > - Retry - </Button> - </div> + if (loading) return <div className="p-6 flex items-center justify-center h-64"><div className="text-lg text-muted-foreground">Loading documentation...</div></div>; + if (error) return ( + <div className="p-6 flex items-center justify-center h-64"> + <div className="text-center"> + <p className="text-lg text-destructive mb-2">Failed to load documentation</p> + <p className="text-sm text-muted-foreground mb-4">{error}</p> + <Button onClick={() => loadDocs()} variant="outline">Retry</Button> </div> - ); - } + </div> + ); return ( <div className="h-full flex overflow-hidden bg-background"> <aside className="hidden lg:flex w-[300px] xl:w-[320px] shrink-0 bg-[#fafaf8] dark:bg-muted/10 border-r border-border/40"> - <div className="h-full w-full px-0 py-5">{sidebarContent}</div> + <div className="h-full w-full px-3 py-5">{sidebarContent}</div> </aside> <div className="min-w-0 flex-1 flex flex-col overflow-hidden"> <Sheet open={mobileSidebarOpen} onOpenChange={setMobileSidebarOpen}> <SheetContent side="left" className="w-[92vw] max-w-none p-0 sm:max-w-md"> <div className="flex h-full flex-col"> - <div className="border-b border-border/50 px-4 py-3"> - <SheetTitle>Browse docs</SheetTitle> - </div> - <div className="min-h-0 flex-1 px-4 py-4">{sidebarContent}</div> + <div className="border-b border-border/50 px-4 py-3"><SheetTitle>Browse docs</SheetTitle></div> + <div className="min-h-0 flex-1 px-3 py-4">{sidebarContent}</div> </div> </SheetContent> </Sheet> {selectedDoc ? ( <> + {/* Toolbar */} <div className="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2 border-b border-border/40 shrink-0 bg-background/90 backdrop-blur-sm"> - <Button - variant="ghost" - size="sm" - onClick={() => setMobileSidebarOpen(true)} - className="h-7 px-2 text-muted-foreground hover:text-foreground lg:hidden" - > + <Button variant="ghost" size="sm" onClick={() => setMobileSidebarOpen(true)} className="h-7 px-2 text-muted-foreground hover:text-foreground lg:hidden"> <Menu className="w-3.5 h-3.5" /> </Button> - <Button - variant="ghost" - size="sm" - onClick={() => navigateToFolder(currentFolder)} - className="h-8 px-2 text-muted-foreground hover:text-foreground" - > - <ArrowLeft className="w-3.5 h-3.5 sm:mr-1" /> - <span className="hidden sm:inline text-xs">Back</span> + <Button variant="ghost" size="sm" onClick={() => navigateToFolder(selectedDoc.folder || currentFolder || null)} className="h-7 px-2 text-muted-foreground hover:text-foreground"> + <ArrowLeft className="w-3.5 h-3.5 sm:mr-1" /><span className="hidden sm:inline text-xs">Back</span> </Button> - <button - type="button" - onClick={handleCopyPath} - className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors min-w-0 rounded-full px-2 py-1 hover:bg-accent/60" - title="Click to copy reference" - > + <button type="button" onClick={handleCopyPath} className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors min-w-0 rounded-full px-2 py-1 hover:bg-accent/60" title="Click to copy reference"> <Copy className="w-3 h-3 shrink-0" /> - <span className="font-mono truncate max-w-[180px] sm:max-w-[240px] opacity-85"> + <span className="font-mono truncate max-w-[240px] sm:max-w-[320px] lg:max-w-[400px] opacity-85"> @doc/{toDisplayPath(selectedDoc.path).replace(/\.md$/, "")} </span> </button> {pathCopied && <span className="text-green-600 text-[11px]">Copied</span>} <div className="flex-1" /> {!isEditing && ( - <Button - variant="ghost" - size="sm" - onClick={() => setWideMode(!wideMode)} - className="h-8 px-2 text-muted-foreground hover:text-foreground" - title={wideMode ? "Normal width" : "Full width"} - > + <Button variant="ghost" size="sm" onClick={() => setWideMode(!wideMode)} className="h-7 px-2 text-muted-foreground hover:text-foreground" title={wideMode ? "Normal width" : "Full width"}> {wideMode ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />} </Button> )} {!isEditing ? ( - <Button - size="sm" - variant="ghost" - onClick={handleEdit} - disabled={selectedDoc.isImported} - className="h-8 px-2" - title={selectedDoc.isImported ? "Imported docs are read-only" : "Edit document"} - > - <Pencil className="w-3.5 h-3.5 sm:mr-1" /> - <span className="hidden sm:inline text-xs">Edit</span> + <Button size="sm" variant="ghost" onClick={handleEdit} disabled={selectedDoc.isImported} className="h-7 px-2" title={selectedDoc.isImported ? "Imported docs are read-only" : "Edit document"}> + <Pencil className="w-3.5 h-3.5 sm:mr-1" /><span className="hidden sm:inline text-xs">Edit</span> </Button> ) : ( <> - <Button size="sm" onClick={handleSave} disabled={saving} className="h-8 px-2.5 rounded-full"> - <Check className="w-3.5 h-3.5 sm:mr-1" /> - <span className="hidden sm:inline text-xs">{saving ? "Saving..." : "Save"}</span> + <Button size="sm" onClick={handleSave} disabled={saving} className="h-7 px-2.5 rounded-full"> + <Check className="w-3.5 h-3.5 sm:mr-1" /><span className="hidden sm:inline text-xs">{saving ? "Saving..." : "Save"}</span> </Button> - <Button size="sm" variant="secondary" onClick={handleCancel} disabled={saving} className="h-8 px-2.5 rounded-full"> - <X className="w-3.5 h-3.5 sm:mr-1" /> - <span className="hidden sm:inline text-xs">Cancel</span> + <Button size="sm" variant="secondary" onClick={handleCancel} disabled={saving} className="h-7 px-2.5 rounded-full"> + <X className="w-3.5 h-3.5 sm:mr-1" /><span className="hidden sm:inline text-xs">Cancel</span> </Button> </> )} @@ -524,212 +345,39 @@ export default function DocsPage() { {isEditing ? ( <div className="flex-1 min-h-0 overflow-hidden p-4 sm:p-6"> - <MDEditor - markdown={editedContent} - onChange={setEditedContent} - placeholder="Write your documentation here..." - height="100%" - className="h-full" - /> + <MDEditor markdown={editedContent} onChange={setEditedContent} placeholder="Write your documentation here..." height="100%" className="h-full" /> </div> ) : ( <div className="flex-1 overflow-y-auto" ref={scrollContainerRef}> <div className="flex justify-center"> - <article className={`w-full px-6 sm:px-8 py-10 sm:py-12 transition-[max-width] duration-300 ease-in-out ${wideMode ? "max-w-[1040px]" : "max-w-[760px]"}`}> - {/* Document header โ€” Notion-like inline editing */} - <header className="mb-10"> - {/* Title โ€” inline editable */} - {selectedDoc.isImported ? ( - <h1 className="text-4xl font-semibold tracking-tight mb-2 text-balance"> - {selectedDoc.metadata.title} - </h1> - ) : ( - <input - type="text" - value={metaTitle} - onChange={(e) => setMetaTitle(e.target.value)} - onBlur={() => handleSaveMetadata("title")} - onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} - className="text-4xl font-semibold tracking-tight bg-transparent w-full outline-none border-none p-0 mb-2 placeholder:text-muted-foreground/35" - placeholder="Untitled" + <article key={selectedDoc.path} className={`w-full px-6 sm:px-8 py-10 sm:py-12 transition-[max-width] duration-300 ease-in-out animate-doc-in ${wideMode ? "max-w-[1040px]" : "max-w-[760px]"}`}> + <DocsDocHeader + selectedDoc={selectedDoc} + metaTitle={metaTitle} setMetaTitle={setMetaTitle} + metaDescription={metaDescription} setMetaDescription={setMetaDescription} + metaTags={metaTags} setMetaTags={setMetaTags} + handleSaveMetadata={handleSaveMetadata} + linkedTasks={linkedTasks} + linkedTasksExpanded={linkedTasksExpanded} setLinkedTasksExpanded={setLinkedTasksExpanded} + openTask={openTask} /> - )} - - {/* Description โ€” inline editable */} - {selectedDoc.isImported ? ( - selectedDoc.metadata.description && ( - <p className="text-[15px] leading-7 text-muted-foreground mb-4 max-w-2xl"> - {selectedDoc.metadata.description} - </p> - ) - ) : ( - <input - type="text" - value={metaDescription} - onChange={(e) => setMetaDescription(e.target.value)} - onBlur={() => handleSaveMetadata("description")} - onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} - className="text-[15px] leading-7 text-muted-foreground bg-transparent w-full outline-none border-none p-0 mb-4 placeholder:text-muted-foreground/35 max-w-2xl" - placeholder="Add a description..." - /> - )} - - <div className="mb-4 space-y-2"> - {!selectedDoc.isImported ? ( - <input - type="text" - value={metaTags} - onChange={(e) => setMetaTags(e.target.value)} - onBlur={() => handleSaveMetadata("tags")} - onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} - className="bg-transparent outline-none border-none p-0 text-[12px] text-muted-foreground/75 placeholder:text-muted-foreground/40 w-full" - placeholder="Add tags..." - /> - ) : ( - selectedDoc.metadata.tags && - selectedDoc.metadata.tags.length > 0 && ( - <div className="flex flex-wrap items-center gap-1.5"> - {selectedDoc.metadata.tags.map((tag) => ( - <span - key={tag} - className="rounded-full bg-muted/50 px-2 py-0.5 text-[10px] text-muted-foreground" - > - {tag} - </span> - ))} - </div> - ) - )} - <div className="text-[11px] font-mono text-muted-foreground/65 break-all"> - @doc/{toDisplayPath(selectedDoc.path).replace(/\.md$/, "")} - </div> - </div> - - {/* Metadata row */} - <div className="flex items-center gap-2.5 flex-wrap text-[11px] text-muted-foreground/85"> - {isSpec(selectedDoc) && ( - <span className="px-2 py-0.5 text-[10px] font-medium bg-sky-100 text-sky-800 dark:bg-sky-950/60 dark:text-sky-200 rounded-full"> - SPEC - </span> - )} - {isSpec(selectedDoc) && getSpecStatus(selectedDoc) && ( - <span - className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${ - getSpecStatus(selectedDoc) === "approved" - ? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" - : getSpecStatus(selectedDoc) === "implemented" - ? "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" - : "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" - }`} - > - {(getSpecStatus(selectedDoc) ?? "").charAt(0).toUpperCase() + - (getSpecStatus(selectedDoc) ?? "").slice(1)} - </span> - )} - {selectedDoc.isImported && ( - <span className="px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground rounded-full"> - Imported - </span> - )} - - <span> - Updated {new Date(selectedDoc.metadata.updatedAt).toLocaleDateString()} - </span> - </div> - {/* Spec AC Progress */} - {isSpec(selectedDoc) && - (() => { - const acProgress = parseACProgress(selectedDoc.content); - return acProgress.total > 0 ? ( - <div className="flex items-center gap-2 mt-4 rounded-2xl bg-muted/35 px-3 py-2 w-fit"> - <ListChecks className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> - <Progress - value={Math.round( - (acProgress.completed / acProgress.total) * 100, - )} - className="flex-1 h-1.5 max-w-[180px]" - /> - <span className="text-xs text-muted-foreground"> - {acProgress.completed}/{acProgress.total} - </span> - </div> - ) : null; - })()} - {/* Linked tasks */} - {isSpec(selectedDoc) && ( - <div className="mt-4 rounded-2xl bg-muted/25 px-3 py-2.5"> - <button - type="button" - onClick={() => setLinkedTasksExpanded(!linkedTasksExpanded)} - className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" - > - <FileText className="w-3.5 h-3.5" /> - <span>{linkedTasks.length} linked tasks</span> - {linkedTasksExpanded ? ( - <ChevronUp className="w-3 h-3" /> - ) : ( - <ChevronDown className="w-3 h-3" /> - )} - </button> - {linkedTasksExpanded && ( - <div className="space-y-1 mt-2"> - {linkedTasks.length === 0 ? ( - <p className="text-xs text-muted-foreground"> - No tasks are linked to this spec yet. - </p> - ) : linkedTasks.map((task) => ( - <button - type="button" - key={task.id} - onClick={() => openTask(task.id)} - className="flex items-center gap-1.5 p-1.5 rounded-xl hover:bg-accent/60 transition-colors w-full text-left" - > - <span - className={`w-1.5 h-1.5 rounded-full shrink-0 ${ - task.status === "done" - ? "bg-green-500" - : task.status === "in-progress" - ? "bg-yellow-500" - : task.status === "blocked" - ? "bg-red-500" - : "bg-gray-400" - }`} - /> - <span className="text-xs truncate"> - {task.title} - </span> - </button> - ))} - </div> - )} - </div> - )} - </header> - - {/* Document content */} <div ref={markdownPreviewRef} className="prose-neutral dark:prose-invert"> - <MDRender - markdown={selectedDoc.content || ""} + <MDRenderWithHighlight + ref={lineHighlightRef} + content={selectedDoc.content || ""} + lineHighlight={lineHighlight} + onDismissHighlight={lineHighlight ? dismissLineHighlight : undefined} onTaskLinkClick={setPreviewTaskId} - onDocLinkClick={(path) => { - // In DocsPage, navigate directly without preview - navigateTo(`/docs/${path}`); - }} + onDocLinkClick={(path) => navigateTo(`/docs/${path}`)} onHeadingAnchorClick={navigateToHeading} showHeadingAnchors /> </div> </article> - - {/* Right TOC */} {!isEditing && ( <div className="w-52 shrink-0 hidden xl:block pt-12 pr-6"> - <div className="sticky top-8"> - <DocsTOC - markdown={selectedDoc.content || ""} - scrollContainerRef={scrollContainerRef} - onHeadingSelect={navigateToHeading} - /> + <div className="sticky top-8"> + <DocsTOC markdown={selectedDoc.content || ""} scrollContainerRef={scrollContainerRef} onHeadingSelect={navigateToHeading} /> </div> </div> )} @@ -738,126 +386,17 @@ export default function DocsPage() { )} </> ) : showCreateView ? ( - <> - <div className="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2 border-b border-border/40 shrink-0 bg-background/90 backdrop-blur-sm"> - <Button - variant="ghost" - size="sm" - onClick={() => setMobileSidebarOpen(true)} - className="h-7 px-2 text-muted-foreground hover:text-foreground lg:hidden" - > - <Menu className="w-3.5 h-3.5" /> - </Button> - <Button - variant="ghost" - size="sm" - onClick={() => { - setShowCreateView(false); - setNewDocTitle(""); - setNewDocDescription(""); - setNewDocTags(""); - setNewDocFolder(""); - setNewDocContent(""); - }} - disabled={creating} - className="h-7 px-2 text-muted-foreground hover:text-foreground" - > - <ArrowLeft className="w-3.5 h-3.5 sm:mr-1" /> - <span className="hidden sm:inline text-xs">Back</span> - </Button> - <div className="flex-1" /> - <Button size="sm" onClick={handleCreateDoc} disabled={creating || !newDocTitle.trim()} className="h-7 px-3"> - <Check className="w-3.5 h-3.5 sm:mr-1" /> - <span className="text-xs">{creating ? "Creating..." : "Create"}</span> - </Button> - </div> - - <div className="flex-1 flex flex-col min-h-0 overflow-y-auto"> - <div className="px-6 pt-6 pb-4 shrink-0 flex justify-center"> - <div className="w-full max-w-[720px]"> - <input - type="text" - value={newDocTitle} - onChange={(e) => setNewDocTitle(e.target.value)} - className="text-3xl font-semibold tracking-tight bg-transparent w-full outline-none border-none p-0 mb-1 placeholder:text-muted-foreground/40" - placeholder="Untitled" - autoFocus - /> - <input - type="text" - value={newDocDescription} - onChange={(e) => setNewDocDescription(e.target.value)} - className="text-base text-muted-foreground bg-transparent w-full outline-none border-none p-0 mb-4 placeholder:text-muted-foreground/40" - placeholder="Add a description..." - /> - <div className="flex items-center gap-3 text-xs text-muted-foreground"> - <div className="flex items-center gap-1.5"> - <span className="text-muted-foreground/60">Folder</span> - <input - type="text" - value={newDocFolder} - onChange={(e) => setNewDocFolder(e.target.value)} - className="bg-transparent outline-none border-none p-0 text-xs text-foreground placeholder:text-muted-foreground/40 w-[120px]" - placeholder="root" - /> - </div> - <span className="text-border">|</span> - <div className="flex items-center gap-1.5 flex-1"> - <span className="text-muted-foreground/60 shrink-0">Tags</span> - <input - type="text" - value={newDocTags} - onChange={(e) => setNewDocTags(e.target.value)} - className="bg-transparent outline-none border-none p-0 text-xs text-foreground placeholder:text-muted-foreground/40 flex-1" - placeholder="guide, tutorial, api" - /> - </div> - </div> - </div> - </div> - <div className="flex-1 min-h-0 px-6 pb-6"> - <MDEditor - markdown={newDocContent} - onChange={setNewDocContent} - placeholder="Write your documentation here..." - height="100%" - className="h-full" - /> - </div> - </div> - </> - ) : ( - <div className="flex-1 min-h-0 flex items-center justify-center p-6 sm:p-10"> - <div className="max-w-md text-center"> - <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-[20px] bg-muted/50 text-muted-foreground"> - <FileText className="w-6 h-6" /> - </div> - <h2 className="text-2xl font-semibold tracking-tight">Browse your docs</h2> - <p className="mt-3 text-sm leading-6 text-muted-foreground"> - Pick a document from the left sidebar or create a new one in - <span className="font-medium text-foreground">{currentFolder || "root"}</span>. - </p> - <div className="mt-4 flex items-center justify-center gap-2"> - <Button onClick={openCreateView}> - <Plus className="w-4 h-4 mr-1.5" /> - New Doc - </Button> - <Button variant="outline" onClick={() => setMobileSidebarOpen(true)} className="lg:hidden"> - <Menu className="w-4 h-4 mr-1.5" /> - Browse - </Button> - </div> - </div> - </div> - )} + <DocsCreateView + currentFolder={currentFolder} + onClose={() => setShowCreateView(false)} + onCreated={() => { setShowCreateView(false); setMobileSidebarOpen(false); loadDocs(); }} + onOpenMobileSidebar={() => setMobileSidebarOpen(true)} + /> + ) : ( + <DocsEmptyState currentFolder={currentFolder} onCreateDoc={openCreateView} onOpenMobileSidebar={() => setMobileSidebarOpen(true)} /> + )} </div> - <TaskPreviewDialog - taskId={previewTaskId} - open={!!previewTaskId} - onOpenChange={(open) => { - if (!open) setPreviewTaskId(null); - }} - /> + <TaskPreviewDialog taskId={previewTaskId} open={!!previewTaskId} onOpenChange={(open) => { if (!open) setPreviewTaskId(null); }} /> </div> ); } diff --git a/ui/src/pages/TasksPage.tsx b/ui/src/pages/TasksPage.tsx index 86593ff..bb19645 100644 --- a/ui/src/pages/TasksPage.tsx +++ b/ui/src/pages/TasksPage.tsx @@ -2,9 +2,8 @@ import { useEffect, useState } from "react"; import { LayoutList, LayoutGrid } from "lucide-react"; import type { Task } from "@/ui/models/task"; import { navigateTo } from "../lib/navigation"; -import { TaskDataTable } from "../components/organisms"; +import { TaskNotionList } from "../components/organisms"; import { TaskDetailSheet } from "../components/organisms/TaskDetail/TaskDetailSheet"; -import { ScrollArea } from "../components/ui/ScrollArea"; import { TaskGroupedView } from "./TasksPage/TaskGroupedView"; interface TasksPageProps { @@ -97,15 +96,11 @@ export default function TasksPage({ {/* Content */} <div className="flex-1 overflow-hidden px-6 pb-6"> {viewMode === "table" ? ( - <ScrollArea className="h-full"> - <div className="pr-4"> - <TaskDataTable - tasks={tasks} - onTaskClick={handleTaskClick} - onNewTask={onNewTask} - /> - </div> - </ScrollArea> + <TaskNotionList + tasks={tasks} + onTaskClick={handleTaskClick} + onNewTask={onNewTask} + /> ) : ( <TaskGroupedView tasks={tasks} diff --git a/ui/src/pages/chat/useChatPage.ts b/ui/src/pages/chat/useChatPage.ts index 654961d..a7719f1 100644 --- a/ui/src/pages/chat/useChatPage.ts +++ b/ui/src/pages/chat/useChatPage.ts @@ -8,7 +8,7 @@ import { useOpenCodeModelManager } from "../../hooks/useOpencodeModelManager"; import { useSlashItems } from "../../data/skills"; import { useChatNotifications } from "../../hooks/useChatNotifications"; import { playNotificationSound } from "../../lib/notifications"; -import { opencodeApi, type OpenCodePendingPermission } from "../../api/client"; +import { opencodeApi, saveUserPreferences, type OpenCodePendingPermission } from "../../api/client"; import { toast } from "../../components/ui/sonner"; import type { ChatComposerFile, ChatSession } from "../../models/chat"; import { @@ -265,7 +265,10 @@ export function useChatPage() { providerResponse, status: opencodeStatus, lastLoadedAt, - onChange: async (nextSettings) => updateConfig({ opencodeModels: nextSettings }), + onChange: async (nextSettings) => { + await saveUserPreferences({ opencodeModels: nextSettings }); + await updateConfig({ opencodeModels: nextSettings }); + }, }); const pickerProviders = useMemo(() => getPickerModels(catalog), [catalog]); const autoModelLabel = useMemo(() => buildAutoModelLabel(catalog), [catalog]); diff --git a/ui/src/pages/docs/DocsCreateView.tsx b/ui/src/pages/docs/DocsCreateView.tsx new file mode 100644 index 0000000..e5cba6b --- /dev/null +++ b/ui/src/pages/docs/DocsCreateView.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { Check, ArrowLeft, Menu } from "lucide-react"; +import { MDEditor } from "../../components/editor"; +import { Button } from "../../components/ui/button"; +import { createDoc } from "../../api/client"; + +interface DocsCreateViewProps { + currentFolder: string | null; + onClose: () => void; + onCreated: () => void; + onOpenMobileSidebar: () => void; +} + +export function DocsCreateView({ currentFolder, onClose, onCreated, onOpenMobileSidebar }: DocsCreateViewProps) { + const [newDocTitle, setNewDocTitle] = useState(""); + const [newDocDescription, setNewDocDescription] = useState(""); + const [newDocTags, setNewDocTags] = useState(""); + const [newDocFolder, setNewDocFolder] = useState(currentFolder || ""); + const [newDocContent, setNewDocContent] = useState(""); + const [creating, setCreating] = useState(false); + + const handleCreateDoc = async () => { + if (!newDocTitle.trim()) return; + setCreating(true); + try { + const tags = newDocTags.split(",").map((t) => t.trim()).filter((t) => t); + await createDoc({ + title: newDocTitle, + description: newDocDescription, + tags, + folder: newDocFolder, + content: newDocContent, + }); + onCreated(); + } catch (err) { + console.error("Failed to create doc:", err); + } finally { + setCreating(false); + } + }; + + return ( + <> + <div className="flex items-center gap-1.5 sm:gap-2 px-3 sm:px-5 py-2 border-b border-border/40 shrink-0 bg-background/90 backdrop-blur-sm"> + <Button + variant="ghost" + size="sm" + onClick={onOpenMobileSidebar} + className="h-7 px-2 text-muted-foreground hover:text-foreground lg:hidden" + > + <Menu className="w-3.5 h-3.5" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={onClose} + disabled={creating} + className="h-7 px-2 text-muted-foreground hover:text-foreground" + > + <ArrowLeft className="w-3.5 h-3.5 sm:mr-1" /> + <span className="hidden sm:inline text-xs">Back</span> + </Button> + <div className="flex-1" /> + <Button size="sm" onClick={handleCreateDoc} disabled={creating || !newDocTitle.trim()} className="h-7 px-3"> + <Check className="w-3.5 h-3.5 sm:mr-1" /> + <span className="text-xs">{creating ? "Creating..." : "Create"}</span> + </Button> + </div> + + <div className="flex-1 flex flex-col min-h-0 overflow-y-auto"> + <div className="px-6 pt-6 pb-4 shrink-0 flex justify-center"> + <div className="w-full max-w-[720px]"> + <input + type="text" + value={newDocTitle} + onChange={(e) => setNewDocTitle(e.target.value)} + className="text-3xl font-semibold tracking-tight bg-transparent w-full outline-none border-none p-0 mb-1 placeholder:text-muted-foreground/40" + placeholder="Untitled" + autoFocus + /> + <input + type="text" + value={newDocDescription} + onChange={(e) => setNewDocDescription(e.target.value)} + className="text-base text-muted-foreground bg-transparent w-full outline-none border-none p-0 mb-4 placeholder:text-muted-foreground/40" + placeholder="Add a description..." + /> + <div className="flex items-center gap-3 text-xs text-muted-foreground"> + <div className="flex items-center gap-1.5"> + <span className="text-muted-foreground/60">Folder</span> + <input + type="text" + value={newDocFolder} + onChange={(e) => setNewDocFolder(e.target.value)} + className="bg-transparent outline-none border-none p-0 text-xs text-foreground placeholder:text-muted-foreground/40 w-[120px]" + placeholder="root" + /> + </div> + <span className="text-border">|</span> + <div className="flex items-center gap-1.5 flex-1"> + <span className="text-muted-foreground/60 shrink-0">Tags</span> + <input + type="text" + value={newDocTags} + onChange={(e) => setNewDocTags(e.target.value)} + className="bg-transparent outline-none border-none p-0 text-xs text-foreground placeholder:text-muted-foreground/40 flex-1" + placeholder="guide, tutorial, api" + /> + </div> + </div> + </div> + </div> + <div className="flex-1 min-h-0 px-6 pb-6"> + <MDEditor + markdown={newDocContent} + onChange={setNewDocContent} + placeholder="Write your documentation here..." + height="100%" + className="h-full" + /> + </div> + </div> + </> + ); +} diff --git a/ui/src/pages/docs/DocsDocHeader.tsx b/ui/src/pages/docs/DocsDocHeader.tsx new file mode 100644 index 0000000..d7d6e51 --- /dev/null +++ b/ui/src/pages/docs/DocsDocHeader.tsx @@ -0,0 +1,213 @@ +import { FileText, ListChecks, ChevronDown, ChevronUp } from "lucide-react"; +import { Progress } from "../../components/ui/progress"; +import { toDisplayPath, isSpec, getSpecStatus, parseACProgress } from "../../lib/utils"; + +interface DocData { + path: string; + content: string; + isImported?: boolean; + metadata: { + title?: string; + description?: string; + tags?: string[]; + updatedAt: string; + }; +} + +interface LinkedTask { + id: string; + title: string; + status: string; +} + +interface DocsDocHeaderProps { + selectedDoc: DocData; + metaTitle: string; + setMetaTitle: (v: string) => void; + metaDescription: string; + setMetaDescription: (v: string) => void; + metaTags: string; + setMetaTags: (v: string) => void; + handleSaveMetadata: (field: "title" | "description" | "tags") => void; + linkedTasks: LinkedTask[]; + linkedTasksExpanded: boolean; + setLinkedTasksExpanded: (v: boolean) => void; + openTask: (id: string) => void; +} + +export function DocsDocHeader({ + selectedDoc, + metaTitle, + setMetaTitle, + metaDescription, + setMetaDescription, + metaTags, + setMetaTags, + handleSaveMetadata, + linkedTasks, + linkedTasksExpanded, + setLinkedTasksExpanded, + openTask, +}: DocsDocHeaderProps) { + return ( + <header className="mb-10"> + {/* Title */} + {selectedDoc.isImported ? ( + <h1 className="text-4xl font-semibold tracking-tight mb-2 text-balance"> + {selectedDoc.metadata.title} + </h1> + ) : ( + <input + type="text" + value={metaTitle} + onChange={(e) => setMetaTitle(e.target.value)} + onBlur={() => handleSaveMetadata("title")} + onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} + className="text-4xl font-semibold tracking-tight bg-transparent w-full outline-none border-none p-0 mb-2 placeholder:text-muted-foreground/35" + placeholder="Untitled" + /> + )} + + {/* Description */} + {selectedDoc.isImported ? ( + selectedDoc.metadata.description && ( + <p className="text-[15px] leading-7 text-muted-foreground mb-4 max-w-2xl"> + {selectedDoc.metadata.description} + </p> + ) + ) : ( + <input + type="text" + value={metaDescription} + onChange={(e) => setMetaDescription(e.target.value)} + onBlur={() => handleSaveMetadata("description")} + onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} + className="text-[15px] leading-7 text-muted-foreground bg-transparent w-full outline-none border-none p-0 mb-4 placeholder:text-muted-foreground/35 max-w-2xl" + placeholder="Add a description..." + /> + )} + + {/* Tags */} + <div className="mb-4 space-y-2"> + {!selectedDoc.isImported ? ( + <input + type="text" + value={metaTags} + onChange={(e) => setMetaTags(e.target.value)} + onBlur={() => handleSaveMetadata("tags")} + onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()} + className="bg-transparent outline-none border-none p-0 text-[12px] text-muted-foreground/75 placeholder:text-muted-foreground/40 w-full" + placeholder="Add tags..." + /> + ) : ( + selectedDoc.metadata.tags && + selectedDoc.metadata.tags.length > 0 && ( + <div className="flex flex-wrap items-center gap-1.5"> + {selectedDoc.metadata.tags.map((tag) => ( + <span key={tag} className="rounded-full bg-muted/50 px-2 py-0.5 text-[10px] text-muted-foreground"> + {tag} + </span> + ))} + </div> + ) + )} + <div className="text-[11px] font-mono text-muted-foreground/65 break-all"> + @doc/{toDisplayPath(selectedDoc.path).replace(/\.md$/, "")} + </div> + </div> + + {/* Metadata row */} + <div className="flex items-center gap-2.5 flex-wrap text-[11px] text-muted-foreground/85"> + {isSpec(selectedDoc) && ( + <span className="px-2 py-0.5 text-[10px] font-medium bg-sky-100 text-sky-800 dark:bg-sky-950/60 dark:text-sky-200 rounded-full"> + SPEC + </span> + )} + {isSpec(selectedDoc) && getSpecStatus(selectedDoc) && ( + <span + className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${ + getSpecStatus(selectedDoc) === "approved" + ? "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300" + : getSpecStatus(selectedDoc) === "implemented" + ? "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300" + : "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300" + }`} + > + {(getSpecStatus(selectedDoc) ?? "").charAt(0).toUpperCase() + + (getSpecStatus(selectedDoc) ?? "").slice(1)} + </span> + )} + {selectedDoc.isImported && ( + <span className="px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground rounded-full"> + Imported + </span> + )} + <span> + Updated {new Date(selectedDoc.metadata.updatedAt).toLocaleDateString()} + </span> + </div> + + {/* Spec AC Progress */} + {isSpec(selectedDoc) && + (() => { + const acProgress = parseACProgress(selectedDoc.content); + return acProgress.total > 0 ? ( + <div className="flex items-center gap-2 mt-4 rounded-2xl bg-muted/35 px-3 py-2 w-fit"> + <ListChecks className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> + <Progress + value={Math.round((acProgress.completed / acProgress.total) * 100)} + className="flex-1 h-1.5 max-w-[180px]" + /> + <span className="text-xs text-muted-foreground"> + {acProgress.completed}/{acProgress.total} + </span> + </div> + ) : null; + })()} + + {/* Linked tasks */} + {isSpec(selectedDoc) && ( + <div className="mt-4 rounded-2xl bg-muted/25 px-3 py-2.5"> + <button + type="button" + onClick={() => setLinkedTasksExpanded(!linkedTasksExpanded)} + className="flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors" + > + <FileText className="w-3.5 h-3.5" /> + <span>{linkedTasks.length} linked tasks</span> + {linkedTasksExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} + </button> + {linkedTasksExpanded && ( + <div className="space-y-1 mt-2"> + {linkedTasks.length === 0 ? ( + <p className="text-xs text-muted-foreground">No tasks are linked to this spec yet.</p> + ) : ( + linkedTasks.map((task) => ( + <button + type="button" + key={task.id} + onClick={() => openTask(task.id)} + className="flex items-center gap-1.5 p-1.5 rounded-xl hover:bg-accent/60 transition-colors w-full text-left" + > + <span + className={`w-1.5 h-1.5 rounded-full shrink-0 ${ + task.status === "done" + ? "bg-green-500" + : task.status === "in-progress" + ? "bg-yellow-500" + : task.status === "blocked" + ? "bg-red-500" + : "bg-gray-400" + }`} + /> + <span className="text-xs truncate">{task.title}</span> + </button> + )) + )} + </div> + )} + </div> + )} + </header> + ); +} diff --git a/ui/src/pages/docs/DocsEmptyState.tsx b/ui/src/pages/docs/DocsEmptyState.tsx new file mode 100644 index 0000000..891563f --- /dev/null +++ b/ui/src/pages/docs/DocsEmptyState.tsx @@ -0,0 +1,35 @@ +import { FileText, Plus, Menu } from "lucide-react"; +import { Button } from "../../components/ui/button"; + +interface DocsEmptyStateProps { + currentFolder: string | null; + onCreateDoc: () => void; + onOpenMobileSidebar: () => void; +} + +export function DocsEmptyState({ currentFolder, onCreateDoc, onOpenMobileSidebar }: DocsEmptyStateProps) { + return ( + <div className="flex-1 min-h-0 flex items-center justify-center p-6 sm:p-10"> + <div className="max-w-md text-center"> + <div className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-[20px] bg-muted/50 text-muted-foreground"> + <FileText className="w-6 h-6" /> + </div> + <h2 className="text-2xl font-semibold tracking-tight">Browse your docs</h2> + <p className="mt-3 text-sm leading-6 text-muted-foreground"> + Pick a document from the left sidebar or create a new one in + {" "}<span className="font-medium text-foreground">{currentFolder || "root"}</span>. + </p> + <div className="mt-4 flex items-center justify-center gap-2"> + <Button onClick={onCreateDoc}> + <Plus className="w-4 h-4 mr-1.5" /> + New Doc + </Button> + <Button variant="outline" onClick={onOpenMobileSidebar} className="lg:hidden"> + <Menu className="w-4 h-4 mr-1.5" /> + Browse + </Button> + </div> + </div> + </div> + ); +} diff --git a/ui/src/pages/docs/DocsLineHighlight.tsx b/ui/src/pages/docs/DocsLineHighlight.tsx new file mode 100644 index 0000000..5c8c315 --- /dev/null +++ b/ui/src/pages/docs/DocsLineHighlight.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from "react"; + +interface DocsLineHighlightProps { + content: string; + lineHighlight: { start: number; end: number }; + onDismiss: () => void; +} + +export const DocsLineHighlight = forwardRef<HTMLDivElement, DocsLineHighlightProps>( + ({ content, lineHighlight, onDismiss }, ref) => { + const lines = content.split("\n"); + const start = Math.max(1, lineHighlight.start); + const end = Math.min(lines.length, lineHighlight.end); + const excerptLines = lines.slice(start - 1, end); + if (excerptLines.length === 0) return null; + + return ( + <div ref={ref} className="mb-6 rounded-xl border border-amber-300/50 bg-amber-50/60 dark:border-amber-500/30 dark:bg-amber-950/20 overflow-hidden"> + <div className="flex items-center justify-between px-4 py-2 border-b border-amber-300/30 dark:border-amber-500/20"> + <span className="text-xs font-medium text-amber-800 dark:text-amber-300"> + {start === end ? `Line ${start}` : `Lines ${start}โ€“${end}`} + </span> + <button + type="button" + onClick={onDismiss} + className="text-xs text-amber-600 hover:text-amber-800 dark:text-amber-400 dark:hover:text-amber-200 transition-colors" + > + Dismiss + </button> + </div> + <div className="overflow-x-auto"> + <pre className="text-sm leading-6 p-0 m-0 bg-transparent"> + <code> + {excerptLines.map((line, i) => ( + <div + key={start + i} + className="flex bg-amber-100/60 dark:bg-amber-900/20" + > + <span className="select-none shrink-0 w-12 text-right pr-3 pl-3 text-amber-500/70 dark:text-amber-500/50 text-xs leading-6 font-mono"> + {start + i} + </span> + <span className="flex-1 pr-4 whitespace-pre-wrap break-all font-mono"> + {line || "\u00A0"} + </span> + </div> + ))} + </code> + </pre> + </div> + </div> + ); + } +); + +DocsLineHighlight.displayName = "DocsLineHighlight";