Compare commits
9 Commits
31862f3a40
...
2926645b10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2926645b10 | ||
|
|
6e566cfe82 | ||
|
|
117784f8ef | ||
|
|
de994bb837 | ||
|
|
b9c1bd6ff1 | ||
|
|
3dc10aa060 | ||
|
|
0d15787c7a | ||
|
|
dcbaf12f07 | ||
|
|
fa1ad4b22b |
11
.beads/.gitignore
vendored
Normal file
11
.beads/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
*.lock
|
||||||
|
|
||||||
|
# Temporary
|
||||||
|
last-touched
|
||||||
|
*.tmp
|
||||||
4
.beads/config.yaml
Normal file
4
.beads/config.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Beads Project Configuration
|
||||||
|
# issue_prefix: bd
|
||||||
|
# default_priority: 2
|
||||||
|
# default_type: task
|
||||||
21
.beads/issues.jsonl
Normal file
21
.beads/issues.jsonl
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{"id":"bd-1ba","title":"Add fetchSkills API helper","description":"## Overview\nAdd fetchSkills() function to dashboard/utils/api.js for calling the skills endpoint.\n\n## Background\nThis is the client-side helper that fetches the autocomplete config. It's a simple wrapper around fetch() following the existing API pattern in the file.\n\n## Implementation (from plan IMP-3)\n```javascript\nexport const API_SKILLS = '/api/skills';\n\nexport async function fetchSkills(agent) {\n const url = \\`\\${API_SKILLS}?agent=\\${encodeURIComponent(agent)}\\`;\n const response = await fetch(url);\n if (\\!response.ok) return null;\n return response.json();\n}\n```\n\n## Return Type\n```typescript\ntype AutocompleteConfig = {\n trigger: '/' | '$';\n skills: Array<{ name: string; description: string }>;\n}\n```\n\n## Error Handling\n- HTTP error: return null (graceful degradation, no autocomplete)\n- Network failure: returns null via failed fetch\n\n## Why Return null on Error\n- Autocomplete is a convenience feature, not critical\n- Modal should still work without autocomplete\n- Avoids error toasts for non-critical failure\n\n## Success Criteria\n- Function exported from api.js\n- Agent param properly encoded in URL\n- Returns parsed JSON on success\n- Returns null on any failure","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:08:42.713976Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:08:47.543516Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ba","depends_on_id":"bd-30p","type":"blocks","created_at":"2026-02-26T20:08:47.543493Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-1y3","title":"Add autocomplete state management","description":"## Overview\nAdd state variables and refs for managing autocomplete visibility and selection in SimpleInput.js.\n\n## Background\nThe autocomplete dropdown needs state to track:\n- Whether it's currently visible\n- Which item is highlighted (for keyboard navigation)\n- The current trigger info (for insertion)\n- Refs for DOM elements (for click-outside detection)\n\n## Implementation\nAdd to SimpleInput component:\n```javascript\n// State\nconst [showAutocomplete, setShowAutocomplete] = useState(false);\nconst [selectedIndex, setSelectedIndex] = useState(0);\n\n// Refs for click-outside detection\nconst autocompleteRef = useRef(null);\n\n// Compute triggerInfo on input change\nconst [triggerInfo, setTriggerInfo] = useState(null);\n\n// Update trigger info and visibility on text/cursor change\nuseEffect(() => {\n const textarea = textareaRef.current;\n if (!textarea) return;\n \n const info = getTriggerInfo(text, textarea.selectionStart);\n setTriggerInfo(info);\n setShowAutocomplete(!!info);\n \n // Reset selection when dropdown opens\n if (info && !triggerInfo) {\n setSelectedIndex(0);\n }\n}, [text, getTriggerInfo]);\n```\n\n## State Flow\n1. User types -> text state updates\n2. useEffect computes triggerInfo\n3. If trigger detected -> showAutocomplete = true\n4. selectedIndex tracks keyboard navigation\n5. On selection/dismiss -> showAutocomplete = false\n\n## Reset Behavior\n- selectedIndex resets to 0 when dropdown opens\n- When filter changes, clamp selectedIndex to valid range\n\n## Success Criteria\n- showAutocomplete correctly reflects trigger state\n- selectedIndex tracks current selection\n- State resets appropriately on open/close\n- Refs available for DOM interaction","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:09:43.067983Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:09:45.583079Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1y3","depends_on_id":"bd-29o","type":"blocks","created_at":"2026-02-26T20:09:45.583055Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-253","title":"Implement autocomplete dropdown UI","description":"## Overview\nAdd the autocomplete dropdown JSX to SimpleInput.js render method, positioned above the input.\n\n## Background\nThe dropdown shows filtered skills with visual highlighting. Per AC-11, AC-15, AC-16-19:\n- Positioned above input (bottom-anchored)\n- Left-aligned\n- Max height with scroll\n- Highlighted item visually distinct\n- Respects color scheme\n- Handles both \"no skills available\" AND \"no matching skills\"\n\n## Implementation (from plan IMP-9, with AC-15 fix)\n```javascript\n${showAutocomplete && html`\n <div\n ref=${autocompleteRef}\n class=\"absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50\"\n >\n ${autocompleteConfig.skills.length === 0 ? html`\n <!-- AC-15: Session has no skills at all -->\n <div class=\"px-3 py-2 text-sm text-dim\">No skills available</div>\n ` : filteredSkills.length === 0 ? html`\n <!-- AC-11: Filter matched nothing -->\n <div class=\"px-3 py-2 text-sm text-dim\">No matching skills</div>\n ` : filteredSkills.map((skill, i) => html`\n <div\n key=${i}\n class=\"px-3 py-2 cursor-pointer text-sm transition-colors ${\n i === selectedIndex\n ? \"bg-selection/50 text-bright\"\n : \"text-fg hover:bg-selection/25\"\n }\"\n onClick=${() => insertSkill(skill)}\n onMouseEnter=${() => setSelectedIndex(i)}\n >\n <div class=\"font-medium font-mono text-bright\">\n ${autocompleteConfig.trigger}${skill.name}\n </div>\n <div class=\"text-micro text-dim truncate\">${skill.description}</div>\n </div>\n `)}\n </div>\n`}\n```\n\n## Two Empty States (IMPORTANT)\n1. **\"No skills available\"** (AC-15): `autocompleteConfig.skills.length === 0`\n - Agent has zero skills installed\n - Dropdown still appears to inform user\n \n2. **\"No matching skills\"** (AC-11): `filteredSkills.length === 0`\n - Skills exist, but filter text matches none\n - User typed something that does not match any skill\n\n## Styling Decisions\n- **bottom-full mb-1**: Positions above input with 4px gap\n- **max-h-48**: ~192px max height (AC-17)\n- **overflow-y-auto**: Vertical scroll for long lists\n- **z-50**: Ensures dropdown above other content\n- **bg-selection/50**: Highlighted item background\n- **text-dim/text-bright**: Color hierarchy\n\n## Key Details\n- Uses index as key (plan note: handles duplicate skill names from curated + user)\n- onMouseEnter updates selectedIndex (mouse hover selection)\n- onClick calls insertSkill directly\n- Nested ternary: skills.length → filteredSkills.length → map\n\n## Container Requirements\nSimpleInput outer container needs position: relative for absolute positioning.\n\n## Success Criteria\n- Dropdown appears above input when showAutocomplete true\n- \"No skills available\" when agent has zero skills\n- \"No matching skills\" when filter matches nothing\n- Selected item visually highlighted\n- Scrollable when many skills\n- Mouse hover updates selection\n- Click inserts skill","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:10:35.317055Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:18:15.324880Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-253","depends_on_id":"bd-2uj","type":"blocks","created_at":"2026-02-26T20:10:38.204758Z","created_by":"tayloreernisse"},{"issue_id":"bd-253","depends_on_id":"bd-3us","type":"blocks","created_at":"2026-02-26T20:12:27.289036Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-29o","title":"Implement filtered skills computation","description":"## Overview\nAdd useMemo for filteredSkills in SimpleInput.js that filters the skills list based on user input.\n\n## Background\nAs the user types after the trigger character, the list should filter to show only matching skills. This provides instant feedback and makes finding skills faster.\n\n## Implementation (from plan IMP-6)\n```javascript\nconst filteredSkills = useMemo(() => {\n if (!autocompleteConfig || !triggerInfo) return [];\n \n const { skills } = autocompleteConfig;\n const { filterText } = triggerInfo;\n \n let filtered = skills;\n if (filterText) {\n filtered = skills.filter(s =>\n s.name.toLowerCase().includes(filterText)\n );\n }\n \n // Already sorted by server, but ensure alphabetical\n return filtered.sort((a, b) => a.name.localeCompare(b.name));\n}, [autocompleteConfig, triggerInfo]);\n```\n\n## Filtering Behavior (from AC-6)\n- Case-insensitive matching\n- Matches anywhere in skill name (not just prefix)\n- Empty filterText shows all skills\n- No matches returns empty array (handled by UI)\n\n## Why useMemo\n- Skills list could be large (50+)\n- Filter runs on every keystroke\n- Sorting on each render would be wasteful\n- Memoization prevents unnecessary recalculation\n\n## Key Details\n- triggerInfo.filterText is already lowercase\n- Server pre-sorts, but we re-sort after filtering (stability)\n- localeCompare for proper alphabetical ordering\n\n## Success Criteria\n- Returns empty array when no autocompleteConfig\n- Filters based on filterText (case-insensitive)\n- Results sorted alphabetically\n- Empty input shows all skills\n- Memoized to prevent unnecessary recalculation","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:09:29.436812Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:09:31.898798Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-29o","depends_on_id":"bd-3s3","type":"blocks","created_at":"2026-02-26T20:09:31.898778Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-2a1","title":"Implement backspace dismissal of autocomplete","description":"## Overview\nEnsure that backspacing over the trigger character dismisses the autocomplete dropdown (AC-9).\n\n## Background\nWhen the user types '/com' and then backspaces to '/', they might continue backspacing to remove the '/'. At that point, the autocomplete should dismiss.\n\n## Implementation\nThis is already handled by the existing trigger detection:\n- When text changes, triggerInfo is recomputed\n- If cursor is before the trigger char, getTriggerInfo returns null\n- showAutocomplete becomes false\n\n## Verification Needed\n- Type '/com'\n- Backspace to '/'\n- Backspace to ''\n- Dropdown should dismiss when '/' is deleted\n\n## Success Criteria\n- Backspacing past trigger dismisses dropdown\n- No special code needed if detection is correct\n- Add test case if not already covered","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-26T20:12:00.191481Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:12:03.544224Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2a1","depends_on_id":"bd-3s3","type":"blocks","created_at":"2026-02-26T20:12:03.544203Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-2n7","title":"Load autocomplete config in Modal","description":"## Overview\nAdd useEffect in Modal.js to load skills when a session is opened, and pass autocompleteConfig to SimpleInput.\n\n## Background\nSkills are agent-global, not session-specific. We fetch once when session.agent changes and cache client-side. This follows the plan's data flow: Modal opens -> GET /api/skills -> SimpleInput gets config.\n\n## Implementation (from plan IMP-4)\n```javascript\nconst [autocompleteConfig, setAutocompleteConfig] = useState(null);\n\n// Load skills when agent type changes\nuseEffect(() => {\n if (!session) {\n setAutocompleteConfig(null);\n return;\n }\n \n const agent = session.agent || 'claude';\n fetchSkills(agent)\n .then(config => setAutocompleteConfig(config))\n .catch(() => setAutocompleteConfig(null));\n}, [session?.agent]);\n\n// In render, pass to SimpleInput:\n<\\${SimpleInput}\n ...\n autocompleteConfig=\\${autocompleteConfig}\n/>\n```\n\n## Dependency Array\n- Uses session?.agent to re-fetch when agent type changes\n- NOT session.id (would refetch on every session switch unnecessarily)\n- Skills are agent-global, so same agent = same skills\n\n## Error Handling\n- fetch failure: autocompleteConfig stays null\n- null config: SimpleInput disables autocomplete silently\n\n## Props to Add\nSimpleInput gets new optional prop:\n- autocompleteConfig: { trigger, skills } | null\n\n## Success Criteria\n- useEffect fetches on session.agent change\n- autocompleteConfig state managed correctly\n- Passed to SimpleInput as prop\n- Null on error (graceful degradation)","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:08:58.959680Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:09:01.617221Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2n7","depends_on_id":"bd-1ba","type":"blocks","created_at":"2026-02-26T20:09:01.617198Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-2uj","title":"Implement skill insertion","description":"## Overview\nAdd insertSkill() function in SimpleInput.js that replaces the trigger+filter text with the full skill name.\n\n## Background\nWhen user selects a skill (Enter, Tab, or click), we need to:\n1. Replace the trigger+partial text with trigger+full skill name\n2. Add a trailing space\n3. Position cursor after the inserted text\n\n## Implementation (from plan IMP-8)\n```javascript\nconst insertSkill = useCallback((skill) => {\n if (\\!triggerInfo || \\!autocompleteConfig) return;\n \n const { trigger } = autocompleteConfig;\n const { replaceStart, replaceEnd } = triggerInfo;\n \n const before = text.slice(0, replaceStart);\n const after = text.slice(replaceEnd);\n const inserted = \\`\\${trigger}\\${skill.name} \\`;\n \n setText(before + inserted + after);\n setShowAutocomplete(false);\n \n // Move cursor after inserted text\n const newCursorPos = replaceStart + inserted.length;\n setTimeout(() => {\n if (textareaRef.current) {\n textareaRef.current.selectionStart = newCursorPos;\n textareaRef.current.selectionEnd = newCursorPos;\n textareaRef.current.focus();\n }\n }, 0);\n}, [text, triggerInfo, autocompleteConfig]);\n```\n\n## Example\nUser types: 'please run /com|' (cursor at |)\nUser selects 'commit'\nResult: 'please run /commit |'\n\n## Why setTimeout for Cursor\n- React may not have updated the textarea value yet\n- setTimeout(0) defers until after React render\n- Ensures cursor positioning happens on updated DOM\n\n## AC-20 Compliance\n'After skill insertion, cursor is positioned after the trailing space, ready to continue typing.'\n\n## Success Criteria\n- Replaces trigger+filter with trigger+skill+space\n- Preserves text before and after\n- Cursor positioned at end of insertion\n- Dropdown closes after insertion\n- Focus remains on textarea","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:10:14.663971Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:10:18.401505Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2uj","depends_on_id":"bd-3vd","type":"blocks","created_at":"2026-02-26T20:10:18.401490Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-30p","title":"Add /api/skills route to HttpMixin","description":"## Overview\nAdd the GET /api/skills endpoint to HttpMixin.do_GET that routes to SkillsMixin._serve_skills().\n\n## Background\nThe skills endpoint lives in HttpMixin because that's where all GET routing is handled. The handler just composes mixins. This follows the existing architecture pattern.\n\n## Implementation (from plan IMP-2)\nIn amc_server/mixins/http.py, add to do_GET method:\n```python\nelif path == '/api/skills':\n agent = query_params.get('agent', ['claude'])[0]\n self._serve_skills(agent)\n```\n\n## API Design\n- **Endpoint**: GET /api/skills?agent={claude|codex}\n- **Default agent**: 'claude' (if param missing)\n- **Response**: JSON from _serve_skills()\n\n## Why This Location\n- HttpMixin.do_GET handles ALL GET routing\n- Keeps routing logic centralized\n- SkillsMixin provides the implementation\n- Handler composes both mixins\n\n## Required Import\nAdd SkillsMixin to handler.py mixin list:\n```python\nfrom amc_server.mixins.skills import SkillsMixin\n# Add to AMCHandler class inheritance\n```\n\n## Success Criteria\n- Route /api/skills accessible via GET\n- Query param 'agent' passed to _serve_skills\n- Missing agent param defaults to 'claude'\n- Follows existing routing patterns in do_GET","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:08:29.491653Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:08:32.597481Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-30p","depends_on_id":"bd-g9t","type":"blocks","created_at":"2026-02-26T20:08:32.597460Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-35a","title":"Integration: Wire up complete autocomplete feature","description":"## Overview\nFinal integration step: ensure all pieces work together in Modal.js and SimpleInput.js.\n\n## Background\nThis is the capstone bead that verifies the complete feature is wired up correctly. All previous beads implement individual pieces; this ensures they connect.\n\n## Checklist\n1. **SkillsMixin added to handler**\n - Import SkillsMixin in handler.py\n - Add to AMCHandler class inheritance\n\n2. **Modal loads config**\n - fetchSkills called on session.agent change\n - autocompleteConfig passed to SimpleInput\n\n3. **SimpleInput receives config**\n - New prop: autocompleteConfig\n - State management wired up\n - Keyboard handlers connected\n - Dropdown renders correctly\n\n## Verification Steps\n1. Start dev server\n2. Open a session modal\n3. Type '/' -> dropdown should appear\n4. Arrow down, Enter -> skill inserted\n5. Check Codex session with '$'\n\n## Common Issues to Check\n- Import paths correct\n- Mixin in correct position in inheritance\n- Props threaded through correctly\n- No console errors\n\n## Success Criteria\n- Feature works end-to-end\n- No console errors\n- Both Claude and Codex agents work\n- Manual testing checklist passes","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:12:39.799374Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:12:43.180050Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35a","depends_on_id":"bd-2a1","type":"blocks","created_at":"2026-02-26T20:12:43.160167Z","created_by":"tayloreernisse"},{"issue_id":"bd-35a","depends_on_id":"bd-362","type":"blocks","created_at":"2026-02-26T20:12:43.135755Z","created_by":"tayloreernisse"},{"issue_id":"bd-35a","depends_on_id":"bd-3ny","type":"blocks","created_at":"2026-02-26T20:12:43.111964Z","created_by":"tayloreernisse"},{"issue_id":"bd-35a","depends_on_id":"bd-4lc","type":"blocks","created_at":"2026-02-26T20:12:43.180034Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-362","title":"Unit tests for SkillsMixin","description":"## Overview\nAdd comprehensive unit tests for amc_server/mixins/skills.py in tests/test_skills.py.\n\n## Background\nThe skill enumeration is the foundation of autocomplete. We need to test:\n- Both enumeration methods\n- Edge cases (missing files, bad JSON, empty directories)\n- Sorting behavior\n- Response format\n\n## Test Cases\n\n### Claude Enumeration\n```python\ndef test_enumerate_claude_skills_empty_directory(tmp_home):\n '''Returns empty list when ~/.claude/skills doesn't exist'''\n \ndef test_enumerate_claude_skills_reads_skill_md(tmp_home, claude_skill):\n '''Reads description from SKILL.md'''\n \ndef test_enumerate_claude_skills_fallback_to_readme(tmp_home):\n '''Falls back to README.md when SKILL.md missing'''\n \ndef test_enumerate_claude_skills_skips_hidden_dirs(tmp_home):\n '''Ignores directories starting with .'''\n \ndef test_enumerate_claude_skills_truncates_description(tmp_home):\n '''Description truncated to 100 chars'''\n \ndef test_enumerate_claude_skills_skips_headers(tmp_home):\n '''First non-header line used as description'''\n```\n\n### Codex Enumeration\n```python\ndef test_enumerate_codex_skills_reads_cache(tmp_home):\n '''Reads from skills-curated-cache.json'''\n \ndef test_enumerate_codex_skills_invalid_json(tmp_home):\n '''Continues without cache if JSON invalid'''\n \ndef test_enumerate_codex_skills_combines_cache_and_user(tmp_home):\n '''Returns both curated and user skills'''\n \ndef test_enumerate_codex_skills_no_deduplication(tmp_home):\n '''Duplicate names from cache and user both appear'''\n```\n\n### Serve Skills\n```python\ndef test_serve_skills_claude_trigger(mock_handler):\n '''Returns / trigger for claude agent'''\n \ndef test_serve_skills_codex_trigger(mock_handler):\n '''Returns $ trigger for codex agent'''\n \ndef test_serve_skills_default_to_claude(mock_handler):\n '''Unknown agent defaults to claude'''\n \ndef test_serve_skills_alphabetical_sort(mock_handler, skills_fixture):\n '''Skills sorted alphabetically (case-insensitive)'''\n```\n\n## Fixtures\n- tmp_home: pytest fixture that sets HOME to temp directory\n- claude_skill: creates a skill directory with SKILL.md\n- mock_handler: SkillsMixin instance with mocked _send_json\n\n## Success Criteria\n- All enumeration paths tested\n- Error handling verified (bad JSON, missing files)\n- Sorting correctness verified\n- Response format validated","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-26T20:11:15.502753Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:11:18.589476Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-362","depends_on_id":"bd-30p","type":"blocks","created_at":"2026-02-26T20:11:18.589458Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-3cc","title":"E2E tests for autocomplete workflow","description":"## Overview\nCreate end-to-end test script that validates the complete autocomplete workflow from typing to insertion.\n\n## Background\nThe plan includes a manual testing checklist (lines 462-480). We should automate key workflows to ensure the feature works end-to-end.\n\n## Test Scenarios (from plan's testing checklist)\n\n### Core Flow\n```\n1. Claude session: Type \"/\" -> dropdown appears with Claude skills\n2. Codex session: Type \"$\" -> dropdown appears with Codex skills\n3. Claude session: Type \"$\" -> nothing happens (wrong trigger)\n4. Type \"/com\" -> list filters to skills containing \"com\"\n5. Mid-message: Type \"please run /commit\" -> autocomplete triggers on \"/\"\n6. Arrow keys navigate, Enter selects\n7. Escape dismisses without selection\n8. Click outside dismisses\n9. Selected skill shows as \"{trigger}skill-name \" in input\n10. Verify alphabetical ordering of skills\n11. Verify vertical scroll with many skills\n```\n\n### Edge Cases (from plan section)\n```\n- Session without skills (dropdown shows \"No skills available\")\n- Single skill (still shows dropdown)\n- Very long skill descriptions (CSS truncates with ellipsis - visual check)\n- Multiple triggers in one message (each \"/\" can trigger independently)\n- Backspace over trigger (dismisses autocomplete)\n```\n\n### Multiple Triggers Test (important edge case)\nUser types: \"first /commit then /review-pr finally\"\n- First \"/\" at position 6 can trigger\n- After inserting \"/commit \", cursor at position 14\n- Second \"/\" at position after text can trigger again\n- Verify each trigger works independently\n\n## Implementation Approach\nUse the Playwright MCP tools to:\n1. Navigate to dashboard\n2. Open a session modal\n3. Type trigger character\n4. Verify dropdown appears\n5. Navigate with arrows\n6. Select with Enter\n7. Verify insertion\n\n## Logging Requirements\n- Log each step being performed\n- Log expected vs actual behavior\n- Log timing for performance visibility\n- Log any errors with context\n\n## Test Script Location\ntests/e2e/test_autocomplete.py or similar\n\n## Success Criteria\n- All core scenarios pass\n- Edge cases handled\n- Detailed logging for debugging\n- Can run in CI environment","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-26T20:11:47.556903Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:18:56.881498Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3cc","depends_on_id":"bd-3ny","type":"blocks","created_at":"2026-02-26T20:11:50.670698Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-3eu","title":"Implement Codex skill enumeration","description":"## Overview\nImplement _enumerate_codex_skills() method in SkillsMixin to discover skills from both the curated cache and user directory.\n\n## Background\nCodex stores skills in two locations:\n1. **Curated cache**: ~/.codex/vendor_imports/skills-curated-cache.json (pre-installed skills)\n2. **User skills**: ~/.codex/skills/*/ (user-created or installed)\n\nBoth need to be combined for the full skills list.\n\n## Implementation (from plan IMP-1)\n```python\ndef _enumerate_codex_skills(self):\n skills = []\n \n # 1. Curated skills from cache\n cache_file = Path.home() / '.codex/vendor_imports/skills-curated-cache.json'\n if cache_file.exists():\n try:\n data = json.loads(cache_file.read_text())\n for skill in data.get('skills', []):\n skills.append({\n 'name': skill.get('id', skill.get('name', '')),\n 'description': skill.get('shortDescription', skill.get('description', ''))[:100]\n })\n except (json.JSONDecodeError, OSError):\n pass # Continue without curated skills\n \n # 2. User-installed skills\n user_skills_dir = Path.home() / '.codex/skills'\n if user_skills_dir.exists():\n for skill_dir in user_skills_dir.iterdir():\n if skill_dir.is_dir() and not skill_dir.name.startswith('.'):\n skill_md = skill_dir / 'SKILL.md'\n description = ''\n if skill_md.exists():\n try:\n for line in skill_md.read_text().splitlines():\n line = line.strip()\n if line and not line.startswith('#'):\n description = line[:100]\n break\n except OSError:\n pass\n skills.append({\n 'name': skill_dir.name,\n 'description': description or f'User skill: {skill_dir.name}'\n })\n \n return skills\n```\n\n## Key Decisions\n- **Cache file structure**: Expected format {skills: [{id, shortDescription}, ...]}\n- **Fallback for missing fields**: Use 'name' if 'id' missing, 'description' if 'shortDescription' missing\n- **No deduplication**: If curated and user skills share a name, both appear (per Known Limitations)\n- **Error resilience**: JSON parse errors don't prevent user skills from loading\n\n## Out of Scope (per plan)\n- Duplicate skill names are NOT deduplicated\n- Server-side caching of enumeration results\n\n## Success Criteria\n- Returns combined list from cache + user directory\n- Handles missing files/directories gracefully\n- Truncates descriptions to 100 chars\n- JSON parse errors don't crash enumeration","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:07:58.579276Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:08:01.832064Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3eu","depends_on_id":"bd-3q1","type":"blocks","created_at":"2026-02-26T20:08:01.832043Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-3ny","title":"Implement click-outside dismissal","description":"## Overview\nAdd useEffect in SimpleInput.js that dismisses the autocomplete dropdown when clicking outside.\n\n## Background\nAC-9 requires clicking outside to dismiss the dropdown. This is a common UX pattern that requires:\n- Event listener on document\n- Check if click target is inside dropdown or textarea\n- Cleanup on unmount\n\n## Implementation (from plan IMP-10)\n```javascript\nuseEffect(() => {\n if (!showAutocomplete) return;\n \n const handleClickOutside = (e) => {\n if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&\n textareaRef.current && !textareaRef.current.contains(e.target)) {\n setShowAutocomplete(false);\n }\n };\n \n document.addEventListener('mousedown', handleClickOutside);\n return () => document.removeEventListener('mousedown', handleClickOutside);\n}, [showAutocomplete]);\n```\n\n## Why mousedown (not click)\n- mousedown fires immediately on press\n- click fires after release (feels sluggish)\n- Standard pattern for dropdown dismissal\n\n## What Counts as 'Inside'\n- Inside autocompleteRef (the dropdown)\n- Inside textareaRef (the input)\n- Both should keep dropdown open\n\n## Cleanup\n- Effect only adds listener when dropdown is visible\n- Cleanup removes listener when:\n - Dropdown closes\n - Component unmounts\n - Dependencies change\n\n## Success Criteria\n- Clicking outside dropdown+textarea dismisses\n- Clicking inside dropdown doesn't dismiss (onClick handles selection)\n- Clicking in textarea doesn't dismiss (keeps typing)\n- Listener cleaned up properly","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:10:49.738217Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:10:52.252361Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ny","depends_on_id":"bd-253","type":"blocks","created_at":"2026-02-26T20:10:52.252341Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-3q1","title":"Create SkillsMixin class for skill enumeration","description":"## Overview\nCreate a new SkillsMixin class in amc_server/mixins/skills.py that handles enumerating available skills for both Claude and Codex agents.\n\n## Background & Rationale\nThe autocomplete feature needs to know what skills are available for each agent type. Skills are agent-global (not session-specific), so we enumerate them from well-known filesystem locations. This follows the existing mixin pattern used throughout the server (HttpMixin, StateMixin, etc.).\n\n## Implementation Details\n- Create new file: amc_server/mixins/skills.py\n- Class: SkillsMixin with methods:\n - _serve_skills(agent: str) -> serves JSON response\n - _enumerate_claude_skills() -> list of {name, description}\n - _enumerate_codex_skills() -> list of {name, description}\n- Sort skills alphabetically by name (case-insensitive)\n- Return JSON: {trigger: '/' or '$', skills: [...]}\n\n## File Locations\n- Claude skills: ~/.claude/skills/*/\n- Codex curated: ~/.codex/vendor_imports/skills-curated-cache.json\n- Codex user: ~/.codex/skills/*/\n\n## Acceptance Criteria (from plan)\n- AC-12: On session open, agent-specific config loaded with trigger + skills\n- AC-13: Claude skills from ~/.claude/skills/\n- AC-14: Codex skills from cache + user directory\n- AC-15: Empty skills list handled gracefully\n\n## Error Handling\n- Directory doesn't exist: return empty list\n- JSON parse error (Codex cache): skip cache, continue with user skills\n- File read error: skip that skill, continue enumeration\n\n## Success Criteria\n- SkillsMixin class exists with all three methods\n- Claude enumeration reads SKILL.md (canonical), falls back to skill.md, prompt.md, README.md\n- Codex enumeration reads curated cache + user skills directory\n- All skills sorted alphabetically\n- Empty directories return empty list (no error)","status":"open","priority":1,"issue_type":"feature","created_at":"2026-02-26T20:07:30.389323Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:07:30.389323Z","compaction_level":0,"original_size":0}
|
||||||
|
{"id":"bd-3s3","title":"Implement trigger detection logic","description":"## Overview\nAdd getTriggerInfo() function in SimpleInput.js that detects when autocomplete should activate.\n\n## Background\nAutocomplete triggers when the agent-specific trigger character (/ for Claude, $ for Codex) appears at:\n1. Position 0 (start of input)\n2. After whitespace (space, newline, tab)\n\nThis enables mid-message skill invocation like \"please run /commit\".\n\n## Acceptance Criteria Covered\n- **AC-1**: Triggers at position 0 or after whitespace\n- **AC-2**: Claude uses /, Codex uses $ (from autocompleteConfig.trigger)\n- **AC-3**: Wrong trigger for agent type is ignored (returns null)\n\n## Implementation (from plan IMP-5)\n```javascript\nconst getTriggerInfo = useCallback((value, cursorPos) => {\n // AC-3: If no config, ignore all triggers\n if (\\!autocompleteConfig) return null;\n \n const { trigger } = autocompleteConfig;\n \n // Find the start of the current \"word\" (after last whitespace before cursor)\n let wordStart = cursorPos;\n while (wordStart > 0 && \\!/\\s/.test(value[wordStart - 1])) {\n wordStart--;\n }\n \n // AC-3: Only match THIS agent's trigger, ignore others\n // Claude session typing \"$\" returns null (no autocomplete)\n // Codex session typing \"/\" returns null (no autocomplete)\n if (value[wordStart] === trigger) {\n return {\n trigger,\n filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),\n replaceStart: wordStart,\n replaceEnd: cursorPos\n };\n }\n \n return null;\n}, [autocompleteConfig]);\n```\n\n## Return Value\n```typescript\ntype TriggerInfo = {\n trigger: string; // The trigger char (/ or $)\n filterText: string; // Text after trigger for filtering (lowercase)\n replaceStart: number; // Start position for replacement\n replaceEnd: number; // End position for replacement (cursor pos)\n} | null;\n```\n\n## AC-3 Behavior (Wrong Trigger Ignored)\nThe function only checks for `autocompleteConfig.trigger`. If user types a different character:\n- Claude session: \"$\" is just a dollar sign, no autocomplete\n- Codex session: \"/\" is just a slash, no autocomplete\n\nThis is implicit in the implementation - we compare against ONE trigger character only.\n\n## Edge Cases\n- Empty input: wordStart = 0, checks value[0]\n- Mid-word: wordStart finds last whitespace, checks that position\n- Multiple triggers in message: each \"/\" or \"$\" can independently trigger\n- Backspace over trigger: cursorPos moves, wordStart recalculates\n\n## When This Returns null\n1. No autocompleteConfig (not loaded yet)\n2. Cursor not preceded by trigger character\n3. Wrong trigger for this agent type (AC-3)\n\n## Success Criteria\n- Returns TriggerInfo when trigger at valid position\n- Returns null for wrong trigger (AC-3)\n- Returns null when no config\n- filterText is lowercase for case-insensitive matching\n- replaceStart/End correct for skill insertion","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:09:15.230355Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:18:36.037711Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3s3","depends_on_id":"bd-2n7","type":"blocks","created_at":"2026-02-26T20:09:18.843732Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-3us","title":"Ensure SimpleInput container has relative positioning","description":"## Overview\nVerify/add position: relative to SimpleInput's container for proper dropdown positioning.\n\n## Background\nThe autocomplete dropdown uses absolute positioning with 'bottom-full'. This requires the parent container to have position: relative.\n\n## Implementation\nCheck SimpleInput.js render:\n```javascript\n// The outer wrapper needs position: relative\n<div class=\"relative ...\">\n <textarea ... />\n ${/* autocomplete dropdown here */}\n</div>\n```\n\n## Why This Matters\nWithout position: relative on parent:\n- Dropdown positions relative to nearest positioned ancestor\n- Could appear in wrong location or off-screen\n- Layout becomes unpredictable\n\n## Verification\n1. Check existing SimpleInput wrapper class\n2. Add 'relative' if not present\n3. Test dropdown appears correctly above input\n\n## Success Criteria\n- Dropdown appears directly above textarea\n- No layout shifts or misalignment\n- Works in both modal and any other contexts","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:12:24.541494Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:12:24.541494Z","compaction_level":0,"original_size":0}
|
||||||
|
{"id":"bd-3vd","title":"Implement keyboard navigation for autocomplete","description":"## Overview\nAdd onKeyDown handler in SimpleInput.js for arrow key navigation, Enter/Tab selection, and Escape dismissal.\n\n## Background\nAC-7, AC-8, AC-9, AC-10 require keyboard control of the autocomplete dropdown. This must work WITHOUT interfering with normal textarea behavior when dropdown is closed.\n\n## Implementation (from plan IMP-7)\n```javascript\nonKeyDown=${(e) => {\n if (showAutocomplete) {\n // Always handle Escape when dropdown is open\n if (e.key === \"Escape\") {\n e.preventDefault();\n setShowAutocomplete(false);\n return;\n }\n \n // Handle Enter/Tab: select if matches exist, otherwise dismiss\n if (e.key === \"Enter\" || e.key === \"Tab\") {\n e.preventDefault();\n if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {\n insertSkill(filteredSkills[selectedIndex]);\n } else {\n setShowAutocomplete(false);\n }\n return;\n }\n \n // Arrow navigation only when there are matches\n if (filteredSkills.length > 0) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));\n return;\n }\n if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setSelectedIndex(i => Math.max(i - 1, 0));\n return;\n }\n }\n \n // AC-10: ArrowLeft/ArrowRight are NOT handled here.\n // They fall through to default behavior but since we only call\n // getTriggerInfo on input events, cursor movement does not\n // reposition the dropdown. The dropdown stays anchored to the\n // original trigger position.\n }\n \n // Existing Enter-to-submit logic (only when dropdown is closed)\n if (e.key === \"Enter\" && \\!e.shiftKey) {\n e.preventDefault();\n handleSubmit(e);\n }\n}}\n```\n\n## Key Behaviors\n- **AC-7**: ArrowUp/Down navigates highlighted option\n- **AC-8**: Enter or Tab inserts selected skill\n- **AC-9**: Escape dismisses without insertion\n- **AC-10**: ArrowLeft/Right fall through - dropdown position locked to trigger\n- **Enter with no matches**: Dismisses dropdown (does NOT submit message\\!)\n\n## Why ArrowLeft/Right Are Ignored (AC-10)\nThe plan specifies that cursor movement should not reposition the dropdown. Since we only recalculate trigger position on `onInput` events (not cursor movement), ArrowLeft/Right naturally do not affect the dropdown. No special handling needed - just document this behavior.\n\n## Success Criteria\n- ArrowUp/Down changes selectedIndex\n- Enter/Tab inserts selected skill or dismisses if no matches\n- Escape always dismisses\n- ArrowLeft/Right do not interfere with dropdown\n- Normal Enter-to-submit works when dropdown closed","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:09:59.805771Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:17:56.727953Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3vd","depends_on_id":"bd-1y3","type":"blocks","created_at":"2026-02-26T20:10:03.108299Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-4lc","title":"Implement scroll-into-view for selected item","description":"## Overview\nEnsure the highlighted item in the autocomplete dropdown scrolls into view when navigating with arrow keys.\n\n## Background\nFrom plan Slice 5 (Polish): 'Scroll into view for long lists'. When the user arrows down through a list longer than max-height, the selected item should remain visible.\n\n## Implementation\n```javascript\n// In the keyboard navigation effect or handler\nuseEffect(() => {\n if (showAutocomplete && autocompleteRef.current) {\n const container = autocompleteRef.current;\n const selectedEl = container.children[selectedIndex];\n if (selectedEl) {\n selectedEl.scrollIntoView({ block: 'nearest' });\n }\n }\n}, [selectedIndex, showAutocomplete]);\n```\n\n## Key Details\n- block: 'nearest' prevents jarring scroll when item already visible\n- Only scroll when autocomplete is open\n- children[selectedIndex] assumes direct children are the items\n\n## Alternative: scrollIntoViewIfNeeded\nSome browsers support scrollIntoViewIfNeeded, but scrollIntoView with block: 'nearest' is more widely supported and achieves the same effect.\n\n## Success Criteria\n- Arrowing to item below viewport scrolls it into view\n- Arrowing to item above viewport scrolls it into view\n- Items in viewport don't cause scroll","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-26T20:12:12.819926Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:12:15.489217Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4lc","depends_on_id":"bd-253","type":"blocks","created_at":"2026-02-26T20:12:15.489198Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-93q","title":"Unit tests for trigger detection and filtering","description":"## Overview\nAdd unit tests for the client-side autocomplete logic (trigger detection, filtering) in a test file.\n\n## Background\nThe client-side logic is pure functions that can be tested in isolation:\n- getTriggerInfo: detects trigger and extracts filter text\n- filteredSkills: filters and sorts skills list\n\n## Test Cases\n\n### Trigger Detection\n```javascript\ndescribe('getTriggerInfo', () => {\n it('returns null when no autocompleteConfig')\n it('detects trigger at position 0')\n it('detects trigger after space')\n it('detects trigger after newline')\n it('returns null for non-trigger character')\n it('returns null for wrong trigger (/ in codex session)')\n it('extracts filterText correctly')\n it('filterText is lowercase')\n it('replaceStart/replaceEnd are correct')\n})\n```\n\n### Filtered Skills\n```javascript\ndescribe('filteredSkills', () => {\n it('returns empty array without config')\n it('returns all skills with empty filter')\n it('filters case-insensitively')\n it('matches anywhere in name')\n it('sorts alphabetically')\n it('returns empty array when no matches')\n})\n```\n\n## Test Data\n```javascript\nconst mockConfig = {\n trigger: '/',\n skills: [\n { name: 'commit', description: 'Create a git commit' },\n { name: 'review-pr', description: 'Review a pull request' },\n { name: 'comment', description: 'Add a comment' }\n ]\n};\n```\n\n## Implementation Notes\n- Extract getTriggerInfo to a testable module if needed\n- filteredSkills computation can be tested by extracting the logic\n\n## Success Criteria\n- All trigger detection scenarios covered\n- All filtering edge cases tested\n- Tests run in CI","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-26T20:11:31.262928Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:11:34.080388Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-93q","depends_on_id":"bd-29o","type":"blocks","created_at":"2026-02-26T20:11:34.080369Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-g9t","title":"Implement _serve_skills endpoint handler","description":"## Overview\nImplement _serve_skills(agent) method in SkillsMixin that serves the skills API response.\n\n## Background\nThis is the main entry point called by the HTTP route. It determines the agent type, calls the appropriate enumeration method, and returns a properly formatted JSON response.\n\n## Implementation (from plan IMP-1)\n```python\ndef _serve_skills(self, agent):\n \"\"\"Return autocomplete config for an agent.\"\"\"\n if agent == 'codex':\n trigger = '$'\n skills = self._enumerate_codex_skills()\n else: # claude (default)\n trigger = '/'\n skills = self._enumerate_claude_skills()\n \n # Sort alphabetically\n skills.sort(key=lambda s: s['name'].lower())\n \n self._send_json(200, {'trigger': trigger, 'skills': skills})\n```\n\n## Response Format\n```json\n{\n \"trigger\": \"/\",\n \"skills\": [\n { \"name\": \"commit\", \"description\": \"Create a git commit with a message\" },\n { \"name\": \"review-pr\", \"description\": \"Review a pull request\" }\n ]\n}\n```\n\n## Key Decisions\n- **Claude is default**: Unknown agents get Claude behavior (fail-safe)\n- **Sorting is server-side**: Client receives pre-sorted list (AC-5)\n- **Uses _send_json**: Follows existing pattern from HttpMixin\n\n## Success Criteria\n- Returns correct trigger character per agent\n- Skills are alphabetically sorted (case-insensitive)\n- Unknown agents default to Claude\n- Valid JSON response with 200 status","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:08:14.837644Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:08:18.595551Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-g9t","depends_on_id":"bd-3eu","type":"blocks","created_at":"2026-02-26T20:08:18.595535Z","created_by":"tayloreernisse"},{"issue_id":"bd-g9t","depends_on_id":"bd-sv1","type":"blocks","created_at":"2026-02-26T20:08:18.574532Z","created_by":"tayloreernisse"}]}
|
||||||
|
{"id":"bd-sv1","title":"Implement Claude skill enumeration","description":"## Overview\nImplement _enumerate_claude_skills() method in SkillsMixin to discover skills from ~/.claude/skills/.\n\n## Background\nClaude Code stores user skills as directories under ~/.claude/skills/. Each skill directory contains a SKILL.md (canonical casing) with the skill definition. We need to enumerate these and extract descriptions.\n\n## Implementation (from plan IMP-1)\n```python\ndef _enumerate_claude_skills(self):\n skills = []\n skills_dir = Path.home() / '.claude/skills'\n \n if skills_dir.exists():\n for skill_dir in skills_dir.iterdir():\n if skill_dir.is_dir() and not skill_dir.name.startswith('.'):\n description = ''\n # Check files in priority order\n for md_name in ['SKILL.md', 'skill.md', 'prompt.md', 'README.md']:\n md_file = skill_dir / md_name\n if md_file.exists():\n try:\n content = md_file.read_text()\n for line in content.splitlines():\n line = line.strip()\n if line and not line.startswith('#') and not line.startswith('<!--'):\n description = line[:100]\n break\n if description:\n break\n except OSError:\n pass\n \n skills.append({\n 'name': skill_dir.name,\n 'description': description or f'Skill: {skill_dir.name}'\n })\n \n return skills\n```\n\n## Key Decisions\n- **SKILL.md first**: This is the canonical casing used by Claude Code\n- **Fallbacks**: skill.md, prompt.md, README.md for compatibility\n- **Description extraction**: First non-empty, non-header, non-comment line (truncated to 100 chars)\n- **Hidden directories skipped**: Names starting with '.' are ignored\n\n## Success Criteria\n- Returns list of {name, description} dicts\n- Handles missing ~/.claude/skills/ gracefully (returns [])\n- Extracts meaningful descriptions from SKILL.md\n- Ignores hidden directories","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-26T20:07:43.358309Z","created_by":"tayloreernisse","updated_at":"2026-02-26T20:07:46.134852Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-sv1","depends_on_id":"bd-3q1","type":"blocks","created_at":"2026-02-26T20:07:46.134836Z","created_by":"tayloreernisse"}]}
|
||||||
4
.beads/metadata.json
Normal file
4
.beads/metadata.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"database": "beads.db",
|
||||||
|
"jsonl_export": "issues.jsonl"
|
||||||
|
}
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# bv (beads viewer) local config and caches
|
||||||
|
.bv/
|
||||||
509
PLAN-slash-autocomplete.md
Normal file
509
PLAN-slash-autocomplete.md
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
# Plan: Skill Autocomplete for Agent Sessions
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add autocomplete functionality to the SimpleInput component that displays available skills when the user types the agent-specific trigger character (`/` for Claude, `$` for Codex). Autocomplete triggers at the start of input or after any whitespace, enabling quick skill discovery and selection mid-message.
|
||||||
|
|
||||||
|
## User Workflow
|
||||||
|
|
||||||
|
1. User opens a session modal or card with the input field
|
||||||
|
2. User types the trigger character (`/` for Claude, `$` for Codex):
|
||||||
|
- At position 0, OR
|
||||||
|
- After a space/newline (mid-message)
|
||||||
|
3. Autocomplete dropdown appears showing available skills (alphabetically sorted)
|
||||||
|
4. User can:
|
||||||
|
- Continue typing to filter the list
|
||||||
|
- Use arrow keys to navigate
|
||||||
|
- Press Enter/Tab to select and insert the skill name
|
||||||
|
- Press Escape or click outside to dismiss
|
||||||
|
5. Selected skill replaces the trigger with `{trigger}skill-name ` (e.g., `/commit ` or `$yeet `)
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- **AC-1**: Autocomplete triggers when trigger character is typed at position 0 or after whitespace
|
||||||
|
- **AC-2**: Claude sessions use `/` trigger; Codex sessions use `$` trigger
|
||||||
|
- **AC-3**: Wrong trigger character for agent type is ignored (no autocomplete)
|
||||||
|
- **AC-4**: Dropdown displays skill names with trigger prefix and descriptions
|
||||||
|
- **AC-5**: Skills are sorted alphabetically by name
|
||||||
|
- **AC-6**: Typing additional characters filters the skill list (case-insensitive match on name)
|
||||||
|
- **AC-7**: Arrow up/down navigates the highlighted option
|
||||||
|
- **AC-8**: Enter or Tab inserts the selected skill name (with trigger) followed by a space
|
||||||
|
- **AC-9**: Escape, clicking outside, or backspacing over the trigger character dismisses the dropdown without insertion
|
||||||
|
- **AC-10**: Cursor movement (arrow left/right) is ignored while autocomplete is open; dropdown position is locked to trigger location
|
||||||
|
- **AC-11**: If no skills match the filter, dropdown shows "No matching skills"
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
- **AC-12**: On session open, an agent-specific config is loaded containing: (a) trigger character (`/` for Claude, `$` for Codex), (b) enumerated skills list
|
||||||
|
- **AC-13**: Claude skills are enumerated from `~/.claude/skills/`
|
||||||
|
- **AC-14**: Codex skills are loaded from `~/.codex/vendor_imports/skills-curated-cache.json` plus `~/.codex/skills/`
|
||||||
|
- **AC-15**: If session has no skills, dropdown shows "No skills available" when trigger is typed
|
||||||
|
|
||||||
|
### UX Polish
|
||||||
|
- **AC-16**: Dropdown positions above the input (bottom-anchored), aligned left
|
||||||
|
- **AC-17**: Dropdown has max height with vertical scroll for long lists
|
||||||
|
- **AC-18**: Currently highlighted item is visually distinct
|
||||||
|
- **AC-19**: Dropdown respects the existing color scheme
|
||||||
|
- **AC-20**: After skill insertion, cursor is positioned after the trailing space, ready to continue typing
|
||||||
|
|
||||||
|
### Known Limitations (Out of Scope)
|
||||||
|
- **Duplicate skill names**: If curated and user skills share a name, both appear (no deduplication)
|
||||||
|
- **Long skill names**: No truncation; names may overflow if extremely long
|
||||||
|
- **Accessibility**: ARIA roles, active-descendant, screen reader support deferred to future iteration
|
||||||
|
- **IME/composition**: Japanese/Korean input edge cases not handled in v1
|
||||||
|
- **Server-side caching**: Skills re-enumerated on each request; mtime-based cache could improve performance at scale
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Autocomplete Config Per Agent
|
||||||
|
|
||||||
|
Each session gets an autocomplete config loaded at modal open:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AutocompleteConfig = {
|
||||||
|
trigger: '/' | '$';
|
||||||
|
skills: Array<{ name: string; description: string }>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Agent | Trigger | Skill Sources |
|
||||||
|
|-------|---------|---------------|
|
||||||
|
| Claude | `/` | Enumerate `~/.claude/skills/*/` |
|
||||||
|
| Codex | `$` | `~/.codex/vendor_imports/skills-curated-cache.json` + `~/.codex/skills/*/` |
|
||||||
|
|
||||||
|
### Server-Side: New Endpoint for Skills
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/skills?agent={claude|codex}`
|
||||||
|
|
||||||
|
**Response**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"trigger": "/",
|
||||||
|
"skills": [
|
||||||
|
{ "name": "commit", "description": "Create a git commit with a message" },
|
||||||
|
{ "name": "review-pr", "description": "Review a pull request" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**: Skills are agent-global, not session-specific. The client already knows `session.agent` from state, so no session_id is needed. Server enumerates skill directories directly.
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
SimpleInput.js
|
||||||
|
├── Props: sessionId, status, onRespond, agent, autocompleteConfig
|
||||||
|
├── State: text, focused, sending, error
|
||||||
|
├── State: showAutocomplete, selectedIndex
|
||||||
|
├── Derived: triggerMatch (detects trigger at valid position)
|
||||||
|
├── Derived: filterText, filteredSkills (alphabetically sorted)
|
||||||
|
├── onInput: detect trigger character at pos 0 or after whitespace
|
||||||
|
├── onKeyDown: arrow/enter/escape handling for autocomplete
|
||||||
|
└── Render: textarea + autocomplete dropdown
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
|
||||||
|
│ Modal opens │────▶│ GET /api/skills?agent│────▶│ SimpleInput │
|
||||||
|
│ (session) │ │ (server) │ │ (dropdown) │
|
||||||
|
└─────────────────┘ └──────────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Skills are agent-global, so the same response can be cached client-side per agent type.
|
||||||
|
|
||||||
|
## Implementation Specifications
|
||||||
|
|
||||||
|
### IMP-1: Server-side skill enumeration (fulfills AC-12, AC-13, AC-14, AC-15)
|
||||||
|
|
||||||
|
**Location**: `amc_server/mixins/skills.py` (new file)
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SkillsMixin:
|
||||||
|
def _serve_skills(self, agent):
|
||||||
|
"""Return autocomplete config for a session."""
|
||||||
|
if agent == "codex":
|
||||||
|
trigger = "$"
|
||||||
|
skills = self._enumerate_codex_skills()
|
||||||
|
else: # claude
|
||||||
|
trigger = "/"
|
||||||
|
skills = self._enumerate_claude_skills()
|
||||||
|
|
||||||
|
# Sort alphabetically
|
||||||
|
skills.sort(key=lambda s: s["name"].lower())
|
||||||
|
|
||||||
|
self._send_json(200, {"trigger": trigger, "skills": skills})
|
||||||
|
|
||||||
|
def _enumerate_codex_skills(self):
|
||||||
|
"""Load Codex skills from cache + user directory."""
|
||||||
|
skills = []
|
||||||
|
|
||||||
|
# Curated skills from cache
|
||||||
|
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
|
||||||
|
if cache_file.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(cache_file.read_text())
|
||||||
|
for skill in data.get("skills", []):
|
||||||
|
skills.append({
|
||||||
|
"name": skill.get("id", skill.get("name", "")),
|
||||||
|
"description": skill.get("shortDescription", skill.get("description", ""))[:100]
|
||||||
|
})
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# User-installed skills
|
||||||
|
user_skills_dir = Path.home() / ".codex/skills"
|
||||||
|
if user_skills_dir.exists():
|
||||||
|
for skill_dir in user_skills_dir.iterdir():
|
||||||
|
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
description = ""
|
||||||
|
if skill_md.exists():
|
||||||
|
# Parse first non-empty line as description
|
||||||
|
try:
|
||||||
|
for line in skill_md.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#"):
|
||||||
|
description = line[:100]
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
skills.append({
|
||||||
|
"name": skill_dir.name,
|
||||||
|
"description": description or f"User skill: {skill_dir.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return skills
|
||||||
|
|
||||||
|
def _enumerate_claude_skills(self):
|
||||||
|
"""Load Claude skills from user directory.
|
||||||
|
|
||||||
|
Note: Checks SKILL.md first (canonical casing used by Claude Code),
|
||||||
|
then falls back to lowercase variants for compatibility.
|
||||||
|
"""
|
||||||
|
skills = []
|
||||||
|
skills_dir = Path.home() / ".claude/skills"
|
||||||
|
|
||||||
|
if skills_dir.exists():
|
||||||
|
for skill_dir in skills_dir.iterdir():
|
||||||
|
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
|
||||||
|
# Look for SKILL.md (canonical), then fallbacks
|
||||||
|
description = ""
|
||||||
|
for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]:
|
||||||
|
md_file = skill_dir / md_name
|
||||||
|
if md_file.exists():
|
||||||
|
try:
|
||||||
|
content = md_file.read_text()
|
||||||
|
# Find first meaningful line
|
||||||
|
for line in content.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith("#") and not line.startswith("<!--"):
|
||||||
|
description = line[:100]
|
||||||
|
break
|
||||||
|
if description:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
skills.append({
|
||||||
|
"name": skill_dir.name,
|
||||||
|
"description": description or f"Skill: {skill_dir.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return skills
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2: Add skills endpoint to HttpMixin (fulfills AC-12)
|
||||||
|
|
||||||
|
**Location**: `amc_server/mixins/http.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In HttpMixin.do_GET, add route handling:
|
||||||
|
elif path == "/api/skills":
|
||||||
|
agent = query_params.get("agent", ["claude"])[0]
|
||||||
|
self._serve_skills(agent)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Route goes in `HttpMixin.do_GET` (where all GET routing lives), not `handler.py`. The handler just composes mixins.
|
||||||
|
|
||||||
|
### IMP-3: Client-side API call (fulfills AC-12)
|
||||||
|
|
||||||
|
**Location**: `dashboard/utils/api.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export const API_SKILLS = '/api/skills';
|
||||||
|
|
||||||
|
export async function fetchSkills(agent) {
|
||||||
|
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-4: Autocomplete config loading in Modal (fulfills AC-12)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/Modal.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
|
||||||
|
|
||||||
|
// Load skills when agent type changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session) {
|
||||||
|
setAutocompleteConfig(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = session.agent || 'claude';
|
||||||
|
fetchSkills(agent)
|
||||||
|
.then(config => setAutocompleteConfig(config))
|
||||||
|
.catch(() => setAutocompleteConfig(null));
|
||||||
|
}, [session?.agent]);
|
||||||
|
|
||||||
|
// Pass to SimpleInput
|
||||||
|
<${SimpleInput}
|
||||||
|
...
|
||||||
|
autocompleteConfig=${autocompleteConfig}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-5: Trigger detection logic (fulfills AC-1, AC-2, AC-3)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Detect if we should show autocomplete
|
||||||
|
const getTriggerInfo = useCallback((value, cursorPos) => {
|
||||||
|
if (!autocompleteConfig) return null;
|
||||||
|
|
||||||
|
const { trigger } = autocompleteConfig;
|
||||||
|
|
||||||
|
// Find the start of the current "word" (after last whitespace before cursor)
|
||||||
|
let wordStart = cursorPos;
|
||||||
|
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
|
||||||
|
wordStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if word starts with trigger
|
||||||
|
if (value[wordStart] === trigger) {
|
||||||
|
return {
|
||||||
|
trigger,
|
||||||
|
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
|
||||||
|
replaceStart: wordStart,
|
||||||
|
replaceEnd: cursorPos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [autocompleteConfig]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-6: Filtered and sorted skills (fulfills AC-5, AC-6)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const filteredSkills = useMemo(() => {
|
||||||
|
if (!autocompleteConfig || !triggerInfo) return [];
|
||||||
|
|
||||||
|
const { skills } = autocompleteConfig;
|
||||||
|
const { filterText } = triggerInfo;
|
||||||
|
|
||||||
|
let filtered = skills;
|
||||||
|
if (filterText) {
|
||||||
|
filtered = skills.filter(s =>
|
||||||
|
s.name.toLowerCase().includes(filterText)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already sorted by server, but ensure alphabetical
|
||||||
|
return filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [autocompleteConfig, triggerInfo]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-7: Keyboard navigation (fulfills AC-7, AC-8, AC-9)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
**Note**: Enter with empty filter dismisses dropdown (doesn't submit message). This prevents accidental submissions when user types a partial match that has no results.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
onKeyDown=${(e) => {
|
||||||
|
if (showAutocomplete) {
|
||||||
|
// Always handle Escape when dropdown is open
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Enter/Tab: select if matches exist, otherwise dismiss (don't submit)
|
||||||
|
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
|
||||||
|
insertSkill(filteredSkills[selectedIndex]);
|
||||||
|
} else {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow navigation only when there are matches
|
||||||
|
if (filteredSkills.length > 0) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(i => Math.max(i - 1, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing Enter-to-submit logic (only when dropdown is closed)
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-8: Skill insertion (fulfills AC-8)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const insertSkill = useCallback((skill) => {
|
||||||
|
if (!triggerInfo || !autocompleteConfig) return;
|
||||||
|
|
||||||
|
const { trigger } = autocompleteConfig;
|
||||||
|
const { replaceStart, replaceEnd } = triggerInfo;
|
||||||
|
|
||||||
|
const before = text.slice(0, replaceStart);
|
||||||
|
const after = text.slice(replaceEnd);
|
||||||
|
const inserted = `${trigger}${skill.name} `;
|
||||||
|
|
||||||
|
setText(before + inserted + after);
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
|
||||||
|
// Move cursor after inserted text
|
||||||
|
const newCursorPos = replaceStart + inserted.length;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.selectionStart = newCursorPos;
|
||||||
|
textareaRef.current.selectionEnd = newCursorPos;
|
||||||
|
textareaRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [text, triggerInfo, autocompleteConfig]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-9: Autocomplete dropdown UI (fulfills AC-4, AC-10, AC-15, AC-16, AC-17, AC-18)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
**Note**: Uses index as `key` instead of `skill.name` to handle potential duplicate skill names (curated + user skills with same name).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
${showAutocomplete && html`
|
||||||
|
<div
|
||||||
|
ref=${autocompleteRef}
|
||||||
|
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
|
||||||
|
>
|
||||||
|
${filteredSkills.length === 0 ? html`
|
||||||
|
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
|
||||||
|
` : filteredSkills.map((skill, i) => html`
|
||||||
|
<div
|
||||||
|
key=${i}
|
||||||
|
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
|
||||||
|
i === selectedIndex
|
||||||
|
? 'bg-selection/50 text-bright'
|
||||||
|
: 'text-fg hover:bg-selection/25'
|
||||||
|
}"
|
||||||
|
onClick=${() => insertSkill(skill)}
|
||||||
|
onMouseEnter=${() => setSelectedIndex(i)}
|
||||||
|
>
|
||||||
|
<div class="font-medium font-mono text-bright">
|
||||||
|
${autocompleteConfig.trigger}${skill.name}
|
||||||
|
</div>
|
||||||
|
<div class="text-micro text-dim truncate">${skill.description}</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-10: Click-outside dismissal (fulfills AC-9)
|
||||||
|
|
||||||
|
**Location**: `dashboard/components/SimpleInput.js`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAutocomplete) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
|
||||||
|
textareaRef.current && !textareaRef.current.contains(e.target)) {
|
||||||
|
setShowAutocomplete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showAutocomplete]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Considerations
|
||||||
|
|
||||||
|
### Manual Testing Checklist
|
||||||
|
1. Claude session: Type `/` - dropdown appears with Claude skills
|
||||||
|
2. Codex session: Type `$` - dropdown appears with Codex skills
|
||||||
|
3. Claude session: Type `$` - nothing happens (wrong trigger)
|
||||||
|
4. Type `/com` - list filters to skills containing "com"
|
||||||
|
5. Mid-message: Type "please run /commit" - autocomplete triggers on `/`
|
||||||
|
6. Arrow keys navigate, Enter selects
|
||||||
|
7. Escape dismisses without selection
|
||||||
|
8. Click outside dismisses
|
||||||
|
9. Selected skill shows as `{trigger}skill-name ` in input
|
||||||
|
10. Verify alphabetical ordering
|
||||||
|
11. Verify vertical scroll with many skills
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- Session without skills (dropdown doesn't appear)
|
||||||
|
- Single skill (still shows dropdown)
|
||||||
|
- Very long skill descriptions (truncated with ellipsis)
|
||||||
|
- Multiple triggers in one message (each can trigger independently)
|
||||||
|
- Backspace over trigger (dismisses autocomplete)
|
||||||
|
|
||||||
|
## Rollout Slices
|
||||||
|
|
||||||
|
### Slice 1: Server-side skill enumeration
|
||||||
|
- Add `SkillsMixin` with `_enumerate_codex_skills()` and `_enumerate_claude_skills()`
|
||||||
|
- Add `/api/skills?agent=` endpoint in `HttpMixin.do_GET`
|
||||||
|
- Test endpoint returns correct data for each agent type
|
||||||
|
|
||||||
|
### Slice 2: Client-side skill loading
|
||||||
|
- Add `fetchSkills()` API helper
|
||||||
|
- Load skills in Modal.js on session open
|
||||||
|
- Pass `autocompleteConfig` to SimpleInput
|
||||||
|
|
||||||
|
### Slice 3: Basic autocomplete trigger
|
||||||
|
- Add trigger detection logic (position 0 + after whitespace)
|
||||||
|
- Show/hide dropdown based on trigger
|
||||||
|
- Basic filtered list display
|
||||||
|
|
||||||
|
### Slice 4: Keyboard navigation + selection
|
||||||
|
- Arrow key navigation
|
||||||
|
- Enter/Tab selection
|
||||||
|
- Escape dismissal
|
||||||
|
- Click-outside dismissal
|
||||||
|
|
||||||
|
### Slice 5: Polish
|
||||||
|
- Mouse hover to select
|
||||||
|
- Scroll into view for long lists
|
||||||
|
- Cursor positioning after insertion
|
||||||
@@ -62,7 +62,8 @@ _codex_transcript_cache = {}
|
|||||||
_CODEX_CACHE_MAX = 200
|
_CODEX_CACHE_MAX = 200
|
||||||
|
|
||||||
# Codex sessions dismissed during this server lifetime (prevents re-discovery)
|
# Codex sessions dismissed during this server lifetime (prevents re-discovery)
|
||||||
_dismissed_codex_ids = set()
|
# Uses dict (not set) for O(1) lookup + FIFO eviction via insertion order (Python 3.7+)
|
||||||
|
_dismissed_codex_ids = {}
|
||||||
_DISMISSED_MAX = 500
|
_DISMISSED_MAX = 500
|
||||||
|
|
||||||
# Serialize state collection because it mutates session files/caches.
|
# Serialize state collection because it mutates session files/caches.
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ class SessionControlMixin:
|
|||||||
safe_id = os.path.basename(session_id)
|
safe_id = os.path.basename(session_id)
|
||||||
session_file = SESSIONS_DIR / f"{safe_id}.json"
|
session_file = SESSIONS_DIR / f"{safe_id}.json"
|
||||||
# Track dismissed Codex sessions to prevent re-discovery
|
# Track dismissed Codex sessions to prevent re-discovery
|
||||||
# Evict oldest entries if set is full (prevents unbounded growth)
|
# Evict oldest entries via FIFO (dict maintains insertion order in Python 3.7+)
|
||||||
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
|
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
|
||||||
_dismissed_codex_ids.pop()
|
oldest_key = next(iter(_dismissed_codex_ids))
|
||||||
_dismissed_codex_ids.add(safe_id)
|
del _dismissed_codex_ids[oldest_key]
|
||||||
|
_dismissed_codex_ids[safe_id] = True
|
||||||
session_file.unlink(missing_ok=True)
|
session_file.unlink(missing_ok=True)
|
||||||
self._send_json(200, {"ok": True})
|
self._send_json(200, {"ok": True})
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ class ConversationMixin:
|
|||||||
def _parse_claude_conversation(self, session_id, project_dir):
|
def _parse_claude_conversation(self, session_id, project_dir):
|
||||||
"""Parse Claude Code JSONL conversation format."""
|
"""Parse Claude Code JSONL conversation format."""
|
||||||
messages = []
|
messages = []
|
||||||
|
msg_id = 0
|
||||||
|
|
||||||
conv_file = self._get_claude_conversation_file(session_id, project_dir)
|
conv_file = self._get_claude_conversation_file(session_id, project_dir)
|
||||||
|
|
||||||
@@ -58,10 +59,12 @@ class ConversationMixin:
|
|||||||
# Only include actual human messages (strings), not tool results (arrays)
|
# Only include actual human messages (strings), not tool results (arrays)
|
||||||
if content and isinstance(content, str):
|
if content and isinstance(content, str):
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"claude-{session_id[:8]}-{msg_id}",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": content,
|
"content": content,
|
||||||
"timestamp": entry.get("timestamp", ""),
|
"timestamp": entry.get("timestamp", ""),
|
||||||
})
|
})
|
||||||
|
msg_id += 1
|
||||||
|
|
||||||
elif msg_type == "assistant":
|
elif msg_type == "assistant":
|
||||||
# Assistant messages have structured content
|
# Assistant messages have structured content
|
||||||
@@ -90,6 +93,7 @@ class ConversationMixin:
|
|||||||
text_parts.append(part)
|
text_parts.append(part)
|
||||||
if text_parts or tool_calls or thinking_parts:
|
if text_parts or tool_calls or thinking_parts:
|
||||||
msg = {
|
msg = {
|
||||||
|
"id": f"claude-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "\n".join(text_parts) if text_parts else "",
|
"content": "\n".join(text_parts) if text_parts else "",
|
||||||
"timestamp": entry.get("timestamp", ""),
|
"timestamp": entry.get("timestamp", ""),
|
||||||
@@ -99,6 +103,7 @@ class ConversationMixin:
|
|||||||
if thinking_parts:
|
if thinking_parts:
|
||||||
msg["thinking"] = "\n\n".join(thinking_parts)
|
msg["thinking"] = "\n\n".join(thinking_parts)
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
msg_id += 1
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
@@ -117,6 +122,7 @@ class ConversationMixin:
|
|||||||
"""
|
"""
|
||||||
messages = []
|
messages = []
|
||||||
pending_tool_calls = [] # Accumulate tool calls to attach to next assistant message
|
pending_tool_calls = [] # Accumulate tool calls to attach to next assistant message
|
||||||
|
msg_id = 0
|
||||||
|
|
||||||
conv_file = self._find_codex_transcript_file(session_id)
|
conv_file = self._find_codex_transcript_file(session_id)
|
||||||
|
|
||||||
@@ -161,19 +167,23 @@ class ConversationMixin:
|
|||||||
# Flush any pending tool calls first
|
# Flush any pending tool calls first
|
||||||
if pending_tool_calls:
|
if pending_tool_calls:
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"tool_calls": pending_tool_calls,
|
"tool_calls": pending_tool_calls,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
})
|
})
|
||||||
|
msg_id += 1
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
# Add thinking as assistant message
|
# Add thinking as assistant message
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"thinking": "\n".join(thinking_text),
|
"thinking": "\n".join(thinking_text),
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
})
|
})
|
||||||
|
msg_id += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Handle message (user/assistant text)
|
# Handle message (user/assistant text)
|
||||||
@@ -208,19 +218,24 @@ class ConversationMixin:
|
|||||||
# Flush any pending tool calls before user message
|
# Flush any pending tool calls before user message
|
||||||
if pending_tool_calls:
|
if pending_tool_calls:
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"tool_calls": pending_tool_calls,
|
"tool_calls": pending_tool_calls,
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
})
|
})
|
||||||
|
msg_id += 1
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "\n".join(text_parts),
|
"content": "\n".join(text_parts),
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
})
|
})
|
||||||
|
msg_id += 1
|
||||||
elif role == "assistant":
|
elif role == "assistant":
|
||||||
msg = {
|
msg = {
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "\n".join(text_parts) if text_parts else "",
|
"content": "\n".join(text_parts) if text_parts else "",
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
@@ -231,6 +246,7 @@ class ConversationMixin:
|
|||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
if text_parts or msg.get("tool_calls"):
|
if text_parts or msg.get("tool_calls"):
|
||||||
messages.append(msg)
|
messages.append(msg)
|
||||||
|
msg_id += 1
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
@@ -238,6 +254,7 @@ class ConversationMixin:
|
|||||||
# Flush any remaining pending tool calls
|
# Flush any remaining pending tool calls
|
||||||
if pending_tool_calls:
|
if pending_tool_calls:
|
||||||
messages.append({
|
messages.append({
|
||||||
|
"id": f"codex-{session_id[:8]}-{msg_id}",
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"tool_calls": pending_tool_calls,
|
"tool_calls": pending_tool_calls,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from amc_server.context import (
|
from amc_server.context import (
|
||||||
EVENTS_DIR,
|
EVENTS_DIR,
|
||||||
@@ -119,6 +120,11 @@ class StateMixin:
|
|||||||
if context_usage:
|
if context_usage:
|
||||||
data["context_usage"] = context_usage
|
data["context_usage"] = context_usage
|
||||||
|
|
||||||
|
# Track conversation file mtime for real-time update detection
|
||||||
|
conv_mtime = self._get_conversation_mtime(data)
|
||||||
|
if conv_mtime:
|
||||||
|
data["conversation_mtime_ns"] = conv_mtime
|
||||||
|
|
||||||
sessions.append(data)
|
sessions.append(data)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
continue
|
continue
|
||||||
@@ -166,6 +172,38 @@ class StateMixin:
|
|||||||
|
|
||||||
return None # Return None on error (don't clean up if we can't verify)
|
return None # Return None on error (don't clean up if we can't verify)
|
||||||
|
|
||||||
|
def _get_conversation_mtime(self, session_data):
|
||||||
|
"""Get the conversation file's mtime for real-time change detection."""
|
||||||
|
agent = session_data.get("agent")
|
||||||
|
|
||||||
|
if agent == "claude":
|
||||||
|
conv_file = self._get_claude_conversation_file(
|
||||||
|
session_data.get("session_id", ""),
|
||||||
|
session_data.get("project_dir", ""),
|
||||||
|
)
|
||||||
|
if conv_file:
|
||||||
|
try:
|
||||||
|
return conv_file.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif agent == "codex":
|
||||||
|
transcript_path = session_data.get("transcript_path", "")
|
||||||
|
if transcript_path:
|
||||||
|
try:
|
||||||
|
return Path(transcript_path).stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Fallback to discovery
|
||||||
|
transcript_file = self._find_codex_transcript_file(session_data.get("session_id", ""))
|
||||||
|
if transcript_file:
|
||||||
|
try:
|
||||||
|
return transcript_file.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def _cleanup_stale(self, sessions):
|
def _cleanup_stale(self, sessions):
|
||||||
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
|
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
|
||||||
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
|
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}
|
||||||
|
|||||||
@@ -82,10 +82,14 @@ def _extract_questions(hook):
|
|||||||
"options": [],
|
"options": [],
|
||||||
}
|
}
|
||||||
for opt in q.get("options", []):
|
for opt in q.get("options", []):
|
||||||
entry["options"].append({
|
opt_entry = {
|
||||||
"label": opt.get("label", ""),
|
"label": opt.get("label", ""),
|
||||||
"description": opt.get("description", ""),
|
"description": opt.get("description", ""),
|
||||||
})
|
}
|
||||||
|
# Include markdown preview if present
|
||||||
|
if opt.get("markdown"):
|
||||||
|
opt_entry["markdown"] = opt.get("markdown")
|
||||||
|
entry["options"].append(opt_entry)
|
||||||
result.append(entry)
|
result.append(entry)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import { Sidebar } from './Sidebar.js';
|
|||||||
import { SessionCard } from './SessionCard.js';
|
import { SessionCard } from './SessionCard.js';
|
||||||
import { Modal } from './Modal.js';
|
import { Modal } from './Modal.js';
|
||||||
import { EmptyState } from './EmptyState.js';
|
import { EmptyState } from './EmptyState.js';
|
||||||
|
import { ToastContainer, trackError, clearErrorCount } from './Toast.js';
|
||||||
|
|
||||||
|
let optimisticMsgId = 0;
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
const [modalSession, setModalSession] = useState(null);
|
const [modalSession, setModalSession] = useState(null);
|
||||||
const [conversations, setConversations] = useState({});
|
const [conversations, setConversations] = useState({});
|
||||||
const [conversationLoading, setConversationLoading] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedProject, setSelectedProject] = useState(null);
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
const [sseConnected, setSseConnected] = useState(false);
|
const [sseConnected, setSseConnected] = useState(false);
|
||||||
|
|
||||||
// Silent conversation refresh (no loading state, used for background polling)
|
// Background conversation refresh with error tracking
|
||||||
// Defined early so fetchState can reference it
|
|
||||||
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
||||||
try {
|
try {
|
||||||
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||||||
@@ -26,53 +27,73 @@ export function App() {
|
|||||||
if (agent) params.set('agent', agent);
|
if (agent) params.set('agent', agent);
|
||||||
if (params.toString()) url += '?' + params.toString();
|
if (params.toString()) url += '?' + params.toString();
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) return;
|
if (!response.ok) {
|
||||||
|
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setConversations(prev => ({
|
setConversations(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[sessionId]: data.messages || []
|
[sessionId]: data.messages || []
|
||||||
}));
|
}));
|
||||||
|
clearErrorCount(`conversation-${sessionId}`); // Clear on success
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Silent failure for background refresh
|
trackError(`conversation-${sessionId}`, `Failed to fetch conversation: ${err.message}`);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Track last_event_at for each session to detect actual changes
|
// Track last_event_at for each session to detect actual changes
|
||||||
const lastEventAtRef = useRef({});
|
const lastEventAtRef = useRef({});
|
||||||
|
|
||||||
|
// Refs for stable callback access (avoids recreation on state changes)
|
||||||
|
const sessionsRef = useRef(sessions);
|
||||||
|
const conversationsRef = useRef(conversations);
|
||||||
|
const modalSessionRef = useRef(null);
|
||||||
|
sessionsRef.current = sessions;
|
||||||
|
conversationsRef.current = conversations;
|
||||||
|
|
||||||
// Apply state payload from polling or SSE stream
|
// Apply state payload from polling or SSE stream
|
||||||
const applyStateData = useCallback((data) => {
|
const applyStateData = useCallback((data) => {
|
||||||
const newSessions = data.sessions || [];
|
const newSessions = data.sessions || [];
|
||||||
|
const newSessionIds = new Set(newSessions.map(s => s.session_id));
|
||||||
setSessions(newSessions);
|
setSessions(newSessions);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Update modalSession if it's still open (to get latest pending_questions, etc.)
|
// Update modalSession if it's still open (to get latest pending_questions, etc.)
|
||||||
let modalId = null;
|
const modalId = modalSessionRef.current;
|
||||||
setModalSession(prev => {
|
if (modalId) {
|
||||||
if (!prev) return null;
|
const updatedSession = newSessions.find(s => s.session_id === modalId);
|
||||||
modalId = prev.session_id;
|
if (updatedSession) {
|
||||||
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
|
setModalSession(updatedSession);
|
||||||
return updatedSession || prev;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up conversation cache for sessions that no longer exist
|
||||||
|
setConversations(prev => {
|
||||||
|
const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id));
|
||||||
|
if (activeIds.length === Object.keys(prev).length) return prev; // No cleanup needed
|
||||||
|
const cleaned = {};
|
||||||
|
for (const id of activeIds) {
|
||||||
|
cleaned[id] = prev[id];
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only refresh conversations for sessions that have actually changed
|
// Refresh conversations for sessions that have actually changed
|
||||||
// (compare last_event_at to avoid flooding the API)
|
// Use conversation_mtime_ns for real-time updates (changes on every file write),
|
||||||
|
// falling back to last_event_at for sessions without mtime tracking
|
||||||
const prevEventMap = lastEventAtRef.current;
|
const prevEventMap = lastEventAtRef.current;
|
||||||
const nextEventMap = {};
|
const nextEventMap = {};
|
||||||
|
|
||||||
for (const session of newSessions) {
|
for (const session of newSessions) {
|
||||||
const id = session.session_id;
|
const id = session.session_id;
|
||||||
const newEventAt = session.last_event_at || '';
|
// Prefer mtime (changes on every write) over last_event_at (only on hook events)
|
||||||
nextEventMap[id] = newEventAt;
|
const newKey = session.conversation_mtime_ns || session.last_event_at || '';
|
||||||
|
nextEventMap[id] = newKey;
|
||||||
|
|
||||||
// Only refresh if:
|
const oldKey = prevEventMap[id] || '';
|
||||||
// 1. Session is active/attention AND
|
if (newKey !== oldKey) {
|
||||||
// 2. last_event_at has actually changed OR it's the currently open modal
|
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
|
||||||
if (session.status === 'active' || session.status === 'needs_attention') {
|
|
||||||
const oldEventAt = prevEventMap[id] || '';
|
|
||||||
if (newEventAt !== oldEventAt || id === modalId) {
|
|
||||||
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastEventAtRef.current = nextEventMap;
|
lastEventAtRef.current = nextEventMap;
|
||||||
@@ -89,22 +110,19 @@ export function App() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
applyStateData(data);
|
applyStateData(data);
|
||||||
|
clearErrorCount('state-fetch');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
||||||
console.error('Failed to fetch state:', msg);
|
trackError('state-fetch', `Failed to fetch state: ${msg}`);
|
||||||
setError(msg);
|
setError(msg);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [applyStateData]);
|
}, [applyStateData]);
|
||||||
|
|
||||||
// Fetch conversation for a session
|
// Fetch conversation for a session (explicit fetch, e.g., on modal open)
|
||||||
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => {
|
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
|
||||||
// Skip if already fetched and not forcing refresh
|
// Skip if already fetched and not forcing refresh
|
||||||
if (!force && conversations[sessionId]) return;
|
if (!force && conversationsRef.current[sessionId]) return;
|
||||||
|
|
||||||
if (showLoading) {
|
|
||||||
setConversationLoading(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||||||
@@ -114,7 +132,7 @@ export function App() {
|
|||||||
if (params.toString()) url += '?' + params.toString();
|
if (params.toString()) url += '?' + params.toString();
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn('Failed to fetch conversation for', sessionId);
|
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -122,22 +140,33 @@ export function App() {
|
|||||||
...prev,
|
...prev,
|
||||||
[sessionId]: data.messages || []
|
[sessionId]: data.messages || []
|
||||||
}));
|
}));
|
||||||
|
clearErrorCount(`conversation-${sessionId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching conversation:', err);
|
trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`);
|
||||||
} finally {
|
|
||||||
if (showLoading) {
|
|
||||||
setConversationLoading(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [conversations]);
|
}, []);
|
||||||
|
|
||||||
// Respond to a session's pending question
|
// Respond to a session's pending question with optimistic update
|
||||||
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
|
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
|
||||||
const payload = { text };
|
const payload = { text };
|
||||||
if (isFreeform) {
|
if (isFreeform) {
|
||||||
payload.freeform = true;
|
payload.freeform = true;
|
||||||
payload.optionCount = optionCount;
|
payload.optionCount = optionCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optimistic update: immediately show user's message
|
||||||
|
const optimisticMsg = {
|
||||||
|
id: `optimistic-${++optimisticMsgId}`,
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
_optimistic: true, // Flag for identification
|
||||||
|
};
|
||||||
|
setConversations(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: [...(prev[sessionId] || []), optimisticMsg]
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
|
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -145,14 +174,22 @@ export function App() {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (!data.ok) {
|
||||||
// Trigger refresh
|
throw new Error(data.error || 'Failed to send response');
|
||||||
fetchState();
|
|
||||||
}
|
}
|
||||||
|
clearErrorCount(`respond-${sessionId}`);
|
||||||
|
// SSE will push state update when Claude processes the message,
|
||||||
|
// which triggers conversation refresh via applyStateData
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error responding to session:', err);
|
// Remove optimistic message on failure
|
||||||
|
setConversations(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sessionId]: (prev[sessionId] || []).filter(m => m !== optimisticMsg)
|
||||||
|
}));
|
||||||
|
trackError(`respond-${sessionId}`, `Failed to send message: ${err.message}`);
|
||||||
|
throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error
|
||||||
}
|
}
|
||||||
}, [fetchState]);
|
}, []);
|
||||||
|
|
||||||
// Dismiss a session
|
// Dismiss a session
|
||||||
const dismissSession = useCallback(async (sessionId) => {
|
const dismissSession = useCallback(async (sessionId) => {
|
||||||
@@ -164,9 +201,11 @@ export function App() {
|
|||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
// Trigger refresh
|
// Trigger refresh
|
||||||
fetchState();
|
fetchState();
|
||||||
|
} else {
|
||||||
|
trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error dismissing session:', err);
|
trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`);
|
||||||
}
|
}
|
||||||
}, [fetchState]);
|
}, [fetchState]);
|
||||||
|
|
||||||
@@ -182,7 +221,7 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
eventSource = new EventSource(API_STREAM);
|
eventSource = new EventSource(API_STREAM);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to initialize EventSource:', err);
|
trackError('sse-init', `Failed to initialize EventSource: ${err.message}`);
|
||||||
setSseConnected(false);
|
setSseConnected(false);
|
||||||
reconnectTimer = setTimeout(connect, 2000);
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
return;
|
return;
|
||||||
@@ -192,6 +231,9 @@ export function App() {
|
|||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
setSseConnected(true);
|
setSseConnected(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
// Clear event cache on reconnect to force refresh of all conversations
|
||||||
|
// (handles updates missed during disconnect)
|
||||||
|
lastEventAtRef.current = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
eventSource.addEventListener('state', (event) => {
|
eventSource.addEventListener('state', (event) => {
|
||||||
@@ -199,8 +241,9 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
applyStateData(data);
|
applyStateData(data);
|
||||||
|
clearErrorCount('sse-parse');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE state payload:', err);
|
trackError('sse-parse', `Failed to parse SSE state payload: ${err.message}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -255,26 +298,17 @@ export function App() {
|
|||||||
|
|
||||||
// Handle card click - open modal and fetch conversation if not cached
|
// Handle card click - open modal and fetch conversation if not cached
|
||||||
const handleCardClick = useCallback(async (session) => {
|
const handleCardClick = useCallback(async (session) => {
|
||||||
|
modalSessionRef.current = session.session_id;
|
||||||
setModalSession(session);
|
setModalSession(session);
|
||||||
|
|
||||||
// Fetch conversation if not already cached
|
// Fetch conversation if not already cached
|
||||||
if (!conversations[session.session_id]) {
|
if (!conversationsRef.current[session.session_id]) {
|
||||||
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true);
|
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
|
||||||
}
|
}
|
||||||
}, [conversations, fetchConversation]);
|
|
||||||
|
|
||||||
// Refresh conversation (force re-fetch, used after sending messages)
|
|
||||||
const refreshConversation = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
|
||||||
// Force refresh by clearing cache first
|
|
||||||
setConversations(prev => {
|
|
||||||
const updated = { ...prev };
|
|
||||||
delete updated[sessionId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
await fetchConversation(sessionId, projectDir, agent, false, true);
|
|
||||||
}, [fetchConversation]);
|
}, [fetchConversation]);
|
||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
const handleCloseModal = useCallback(() => {
|
||||||
|
modalSessionRef.current = null;
|
||||||
setModalSession(null);
|
setModalSession(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -383,10 +417,12 @@ export function App() {
|
|||||||
<${Modal}
|
<${Modal}
|
||||||
session=${modalSession}
|
session=${modalSession}
|
||||||
conversations=${conversations}
|
conversations=${conversations}
|
||||||
conversationLoading=${conversationLoading}
|
|
||||||
onClose=${handleCloseModal}
|
onClose=${handleCloseModal}
|
||||||
onSendMessage=${respondToSession}
|
onFetchConversation=${fetchConversation}
|
||||||
onRefreshConversation=${refreshConversation}
|
onRespond=${respondToSession}
|
||||||
|
onDismiss=${dismissSession}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<${ToastContainer} />
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,33 @@ import { html } from '../lib/preact.js';
|
|||||||
import { getUserMessageBg } from '../utils/status.js';
|
import { getUserMessageBg } from '../utils/status.js';
|
||||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||||
|
|
||||||
export function ChatMessages({ messages, status }) {
|
function getMessageKey(msg, index) {
|
||||||
|
// Server-assigned ID (preferred)
|
||||||
|
if (msg.id) return msg.id;
|
||||||
|
// Fallback: role + timestamp + index (for legacy/edge cases)
|
||||||
|
return `${msg.role}-${msg.timestamp || ''}-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatMessages({ messages, status, limit = 20 }) {
|
||||||
const userBgClass = getUserMessageBg(status);
|
const userBgClass = getUserMessageBg(status);
|
||||||
|
|
||||||
if (!messages || messages.length === 0) {
|
if (!messages || messages.length === 0) {
|
||||||
return html`
|
return html`
|
||||||
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
|
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
|
||||||
No messages yet
|
No messages to show
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allDisplayMessages = filterDisplayMessages(messages);
|
const allDisplayMessages = filterDisplayMessages(messages);
|
||||||
const displayMessages = allDisplayMessages.slice(-20);
|
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
|
||||||
const offset = allDisplayMessages.length - displayMessages.length;
|
const offset = allDisplayMessages.length - displayMessages.length;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="space-y-2.5">
|
<div class="space-y-2.5">
|
||||||
${displayMessages.map((msg, i) => html`
|
${displayMessages.map((msg, i) => html`
|
||||||
<${MessageBubble}
|
<${MessageBubble}
|
||||||
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
|
key=${getMessageKey(msg, offset + i)}
|
||||||
msg=${msg}
|
msg=${msg}
|
||||||
userBg=${userBgClass}
|
userBg=${userBgClass}
|
||||||
compact=${true}
|
compact=${true}
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js';
|
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
|
||||||
import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
|
import { SessionCard } from './SessionCard.js';
|
||||||
import { formatDuration, formatTime } from '../utils/formatting.js';
|
|
||||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
|
||||||
|
|
||||||
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
|
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [inputFocused, setInputFocused] = useState(false);
|
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
const inputRef = useRef(null);
|
|
||||||
const wasAtBottomRef = useRef(true);
|
|
||||||
const prevConversationLenRef = useRef(0);
|
|
||||||
const chatContainerRef = useRef(null);
|
|
||||||
|
|
||||||
const conversation = session ? (conversations[session.session_id] || []) : [];
|
// Reset closing state when session changes
|
||||||
|
|
||||||
// Reset state when session changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setClosing(false);
|
setClosing(false);
|
||||||
prevConversationLenRef.current = 0;
|
|
||||||
}, [session?.session_id]);
|
}, [session?.session_id]);
|
||||||
|
|
||||||
// Animated close handler
|
// Animated close handler
|
||||||
@@ -30,40 +18,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
}, 200);
|
}, 200);
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Track scroll position
|
|
||||||
useEffect(() => {
|
|
||||||
const container = chatContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const threshold = 50;
|
|
||||||
wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScroll);
|
|
||||||
return () => container.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Only scroll to bottom on NEW messages, and only if user was already at bottom
|
|
||||||
useEffect(() => {
|
|
||||||
const container = chatContainerRef.current;
|
|
||||||
if (!container || !conversation) return;
|
|
||||||
|
|
||||||
const hasNewMessages = conversation.length > prevConversationLenRef.current;
|
|
||||||
prevConversationLenRef.current = conversation.length;
|
|
||||||
|
|
||||||
if (hasNewMessages && wasAtBottomRef.current) {
|
|
||||||
container.scrollTop = container.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [conversation]);
|
|
||||||
|
|
||||||
// Focus input when modal opens
|
|
||||||
useEffect(() => {
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [session?.session_id]);
|
|
||||||
|
|
||||||
// Lock body scroll when modal is open
|
// Lock body scroll when modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -71,9 +25,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [!!session]);
|
}, [session?.session_id]);
|
||||||
|
|
||||||
// Handle keyboard events
|
// Handle escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
@@ -81,146 +35,26 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
|
|||||||
};
|
};
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [!!session, handleClose]);
|
}, [session?.session_id, handleClose]);
|
||||||
|
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
|
const conversation = conversations[session.session_id] || [];
|
||||||
const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0;
|
|
||||||
const status = getStatusMeta(session.status);
|
|
||||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
|
||||||
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
|
||||||
|
|
||||||
const handleInputKeyDown = (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const text = inputValue.trim();
|
|
||||||
if (!text || sending) return;
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
if (onSendMessage) {
|
|
||||||
await onSendMessage(session.session_id, text, true, optionCount);
|
|
||||||
}
|
|
||||||
setInputValue('');
|
|
||||||
if (onRefreshConversation) {
|
|
||||||
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to send message:', err);
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayMessages = filterDisplayMessages(conversation);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
|
||||||
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
||||||
>
|
>
|
||||||
<div
|
<div class=${closing ? 'modal-panel-out' : 'modal-panel-in'} onClick=${(e) => e.stopPropagation()}>
|
||||||
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
|
<${SessionCard}
|
||||||
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
|
session=${session}
|
||||||
onClick=${(e) => e.stopPropagation()}
|
conversation=${conversation}
|
||||||
>
|
onFetchConversation=${onFetchConversation}
|
||||||
<!-- Modal Header -->
|
onRespond=${onRespond}
|
||||||
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
|
onDismiss=${onDismiss}
|
||||||
<div class="flex-1 min-w-0">
|
enlarged=${true}
|
||||||
<div class="mb-1 flex items-center gap-3">
|
/>
|
||||||
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
|
|
||||||
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
|
|
||||||
<span class="h-2 w-2 rounded-full ${status.dot} ${status.spinning ? 'spinner-dot' : ''}" style=${{ color: status.borderColor }}></span>
|
|
||||||
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
|
|
||||||
</div>
|
|
||||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
|
||||||
${agent}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4 text-sm text-dim">
|
|
||||||
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
|
|
||||||
${session.started_at && html`
|
|
||||||
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
|
||||||
onClick=${handleClose}
|
|
||||||
>
|
|
||||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Content -->
|
|
||||||
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
|
||||||
${conversationLoading ? html`
|
|
||||||
<div class="flex items-center justify-center py-12 animate-fade-in-up">
|
|
||||||
<div class="font-mono text-dim">Loading conversation...</div>
|
|
||||||
</div>
|
|
||||||
` : displayMessages.length > 0 ? html`
|
|
||||||
<div class="space-y-4">
|
|
||||||
${displayMessages.map((msg, i) => html`
|
|
||||||
<${MessageBubble}
|
|
||||||
key=${`${msg.role}-${msg.timestamp || i}`}
|
|
||||||
msg=${msg}
|
|
||||||
userBg=${getUserMessageBg(session.status)}
|
|
||||||
compact=${false}
|
|
||||||
formatTime=${formatTime}
|
|
||||||
/>
|
|
||||||
`)}
|
|
||||||
</div>
|
|
||||||
` : html`
|
|
||||||
<p class="text-dim text-center py-12">No conversation messages</p>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modal Footer -->
|
|
||||||
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
|
||||||
${hasPendingQuestions && html`
|
|
||||||
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
|
|
||||||
Agent is waiting for a response
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
<div class="flex items-end gap-2.5">
|
|
||||||
<textarea
|
|
||||||
ref=${inputRef}
|
|
||||||
value=${inputValue}
|
|
||||||
onInput=${(e) => {
|
|
||||||
setInputValue(e.target.value);
|
|
||||||
e.target.style.height = 'auto';
|
|
||||||
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
|
|
||||||
}}
|
|
||||||
onKeyDown=${handleInputKeyDown}
|
|
||||||
onFocus=${() => setInputFocused(true)}
|
|
||||||
onBlur=${() => setInputFocused(false)}
|
|
||||||
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
|
||||||
rows="1"
|
|
||||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
|
||||||
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
|
||||||
disabled=${sending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="rounded-xl px-4 py-2 font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
|
|
||||||
onClick=${handleSend}
|
|
||||||
disabled=${sending || !inputValue.trim()}
|
|
||||||
>
|
|
||||||
${sending ? 'Sending...' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 font-mono text-label text-dim">
|
|
||||||
Press Enter to send, Shift+Enter for new line, Escape to close
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { html } from '../lib/preact.js';
|
import { html } from '../lib/preact.js';
|
||||||
|
|
||||||
export function OptionButton({ number, label, description, onClick }) {
|
export function OptionButton({ number, label, description, selected, onClick, onMouseEnter, onFocus }) {
|
||||||
|
const selectedStyles = selected
|
||||||
|
? 'border-starting/60 bg-starting/15 shadow-sm'
|
||||||
|
: 'border-selection/70 bg-surface2/55';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
onClick=${onClick}
|
onClick=${onClick}
|
||||||
class="group w-full rounded-xl border border-selection/70 bg-surface2/55 p-3.5 text-left transition-[transform,border-color,background-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo"
|
onMouseEnter=${onMouseEnter}
|
||||||
|
onFocus=${onFocus}
|
||||||
|
class="group w-full rounded-lg border px-3 py-2 text-left transition-[transform,border-color,background-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo ${selectedStyles}"
|
||||||
>
|
>
|
||||||
<div class="flex items-baseline gap-2.5">
|
<div class="flex items-baseline gap-2">
|
||||||
<span class="font-mono text-starting">${number}.</span>
|
<span class="font-mono text-sm text-starting">${number}.</span>
|
||||||
<span class="font-medium text-bright">${label}</span>
|
<span class="text-sm font-medium text-bright">${label}</span>
|
||||||
</div>
|
</div>
|
||||||
${description && html`
|
${description && html`
|
||||||
<p class="mt-1 pl-5 text-sm text-dim">${description}</p>
|
<p class="mt-0.5 pl-4 text-xs text-dim">${description}</p>
|
||||||
`}
|
`}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { html, useState } from '../lib/preact.js';
|
import { html, useState, useRef } from '../lib/preact.js';
|
||||||
import { getStatusMeta } from '../utils/status.js';
|
import { getStatusMeta } from '../utils/status.js';
|
||||||
import { OptionButton } from './OptionButton.js';
|
import { OptionButton } from './OptionButton.js';
|
||||||
|
import { renderContent } from '../lib/markdown.js';
|
||||||
|
|
||||||
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
||||||
const [freeformText, setFreeformText] = useState('');
|
const [freeformText, setFreeformText] = useState('');
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [previewIndex, setPreviewIndex] = useState(0);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
const meta = getStatusMeta(status);
|
const meta = getStatusMeta(status);
|
||||||
|
|
||||||
if (!questions || questions.length === 0) return null;
|
if (!questions || questions.length === 0) return null;
|
||||||
@@ -13,22 +18,148 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
|||||||
const question = questions[0];
|
const question = questions[0];
|
||||||
const remainingCount = questions.length - 1;
|
const remainingCount = questions.length - 1;
|
||||||
const options = question.options || [];
|
const options = question.options || [];
|
||||||
|
|
||||||
|
// Check if any option has markdown preview content
|
||||||
|
const hasMarkdownPreviews = options.some(opt => opt.markdown);
|
||||||
|
|
||||||
const handleOptionClick = (optionLabel) => {
|
const handleOptionClick = async (optionLabel) => {
|
||||||
onRespond(sessionId, optionLabel, false, options.length);
|
if (sending) return;
|
||||||
};
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
const handleFreeformSubmit = (e) => {
|
try {
|
||||||
e.preventDefault();
|
await onRespond(sessionId, optionLabel, false, options.length);
|
||||||
e.stopPropagation();
|
} catch (err) {
|
||||||
if (freeformText.trim()) {
|
setError('Failed to send response');
|
||||||
onRespond(sessionId, freeformText.trim(), true, options.length);
|
console.error('QuestionBlock option error:', err);
|
||||||
setFreeformText('');
|
} finally {
|
||||||
|
setSending(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFreeformSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (freeformText.trim() && !sending) {
|
||||||
|
setSending(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onRespond(sessionId, freeformText.trim(), true, options.length);
|
||||||
|
setFreeformText('');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to send response');
|
||||||
|
console.error('QuestionBlock freeform error:', err);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
// Refocus the textarea after submission
|
||||||
|
// Use setTimeout to ensure React has re-rendered with disabled=false
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Side-by-side layout when options have markdown previews
|
||||||
|
if (hasMarkdownPreviews) {
|
||||||
|
const currentMarkdown = options[previewIndex]?.markdown || '';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="space-y-2.5" onClick=${(e) => e.stopPropagation()}>
|
||||||
|
${error && html`
|
||||||
|
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
|
||||||
|
${error}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<!-- Question Header Badge -->
|
||||||
|
${question.header && html`
|
||||||
|
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
|
||||||
|
${question.header}
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<!-- Question Text -->
|
||||||
|
<p class="font-medium text-bright">${question.question || question.text}</p>
|
||||||
|
|
||||||
|
<!-- Side-by-side: Options | Preview -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<!-- Options List (left side) -->
|
||||||
|
<div class="w-2/5 space-y-1.5 shrink-0">
|
||||||
|
${options.map((opt, i) => html`
|
||||||
|
<${OptionButton}
|
||||||
|
key=${i}
|
||||||
|
number=${i + 1}
|
||||||
|
label=${opt.label || opt}
|
||||||
|
description=${opt.description}
|
||||||
|
selected=${previewIndex === i}
|
||||||
|
onMouseEnter=${() => setPreviewIndex(i)}
|
||||||
|
onFocus=${() => setPreviewIndex(i)}
|
||||||
|
onClick=${() => handleOptionClick(opt.label || opt)}
|
||||||
|
/>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Pane (right side) — fixed height prevents layout thrashing on hover -->
|
||||||
|
<div class="flex-1 rounded-lg border border-selection/50 bg-bg/60 p-3 h-[400px] overflow-auto">
|
||||||
|
${currentMarkdown
|
||||||
|
? (currentMarkdown.trimStart().startsWith('```')
|
||||||
|
? renderContent(currentMarkdown)
|
||||||
|
: html`<pre class="font-mono text-sm text-fg/90 whitespace-pre leading-relaxed">${currentMarkdown}</pre>`)
|
||||||
|
: html`<p class="text-dim text-sm italic">No preview for this option</p>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Freeform Input -->
|
||||||
|
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
|
||||||
|
<textarea
|
||||||
|
ref=${textareaRef}
|
||||||
|
value=${freeformText}
|
||||||
|
onInput=${(e) => {
|
||||||
|
setFreeformText(e.target.value);
|
||||||
|
e.target.style.height = 'auto';
|
||||||
|
e.target.style.height = e.target.scrollHeight + 'px';
|
||||||
|
}}
|
||||||
|
onKeyDown=${(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleFreeformSubmit(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFocus=${() => setFocused(true)}
|
||||||
|
onBlur=${() => setFocused(false)}
|
||||||
|
placeholder="Type a response..."
|
||||||
|
rows="1"
|
||||||
|
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||||
|
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||||
|
disabled=${sending}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||||
|
disabled=${sending || !freeformText.trim()}
|
||||||
|
>
|
||||||
|
${sending ? 'Sending...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- More Questions Indicator -->
|
||||||
|
${remainingCount > 0 && html`
|
||||||
|
<p class="font-mono text-label text-dim">+ ${remainingCount} more question${remainingCount > 1 ? 's' : ''} after this</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard layout (no markdown previews)
|
||||||
return html`
|
return html`
|
||||||
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
|
<div class="space-y-2.5" onClick=${(e) => e.stopPropagation()}>
|
||||||
|
${error && html`
|
||||||
|
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
|
||||||
|
${error}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
<!-- Question Header Badge -->
|
<!-- Question Header Badge -->
|
||||||
${question.header && html`
|
${question.header && html`
|
||||||
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
|
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
|
||||||
@@ -41,7 +172,7 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
|||||||
|
|
||||||
<!-- Options -->
|
<!-- Options -->
|
||||||
${options.length > 0 && html`
|
${options.length > 0 && html`
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
${options.map((opt, i) => html`
|
${options.map((opt, i) => html`
|
||||||
<${OptionButton}
|
<${OptionButton}
|
||||||
key=${i}
|
key=${i}
|
||||||
@@ -57,6 +188,7 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
|||||||
<!-- Freeform Input -->
|
<!-- Freeform Input -->
|
||||||
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
|
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref=${textareaRef}
|
||||||
value=${freeformText}
|
value=${freeformText}
|
||||||
onInput=${(e) => {
|
onInput=${(e) => {
|
||||||
setFreeformText(e.target.value);
|
setFreeformText(e.target.value);
|
||||||
@@ -75,13 +207,15 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
|||||||
rows="1"
|
rows="1"
|
||||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||||
|
disabled=${sending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
|
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||||
|
disabled=${sending || !freeformText.trim()}
|
||||||
>
|
>
|
||||||
Send
|
${sending ? 'Sending...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChatMessages } from './ChatMessages.js';
|
|||||||
import { QuestionBlock } from './QuestionBlock.js';
|
import { QuestionBlock } from './QuestionBlock.js';
|
||||||
import { SimpleInput } from './SimpleInput.js';
|
import { SimpleInput } from './SimpleInput.js';
|
||||||
|
|
||||||
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
|
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false }) {
|
||||||
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||||
const statusMeta = getStatusMeta(session.status);
|
const statusMeta = getStatusMeta(session.status);
|
||||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||||
@@ -20,23 +20,78 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
||||||
|
|
||||||
const chatPaneRef = useRef(null);
|
const chatPaneRef = useRef(null);
|
||||||
|
const stickyToBottomRef = useRef(true); // Start in "sticky" mode
|
||||||
|
const scrollUpAccumulatorRef = useRef(0); // Track cumulative scroll-up distance
|
||||||
|
const prevConversationLenRef = useRef(0);
|
||||||
|
|
||||||
// Scroll chat pane to bottom when conversation loads or updates
|
// Track user intent via wheel events (only fires from actual user scrolling)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = chatPaneRef.current;
|
const el = chatPaneRef.current;
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
if (!el) return;
|
||||||
|
|
||||||
|
const handleWheel = (e) => {
|
||||||
|
// User scrolling up - accumulate distance before disabling sticky
|
||||||
|
if (e.deltaY < 0) {
|
||||||
|
scrollUpAccumulatorRef.current += Math.abs(e.deltaY);
|
||||||
|
// Only disable sticky mode after scrolling up ~50px (meaningful intent)
|
||||||
|
if (scrollUpAccumulatorRef.current > 50) {
|
||||||
|
stickyToBottomRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User scrolling down - reset accumulator and check if near bottom
|
||||||
|
if (e.deltaY > 0) {
|
||||||
|
scrollUpAccumulatorRef.current = 0;
|
||||||
|
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||||
|
if (distanceFromBottom < 100) {
|
||||||
|
stickyToBottomRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: true });
|
||||||
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
const el = chatPaneRef.current;
|
||||||
|
if (!el || !conversation) return;
|
||||||
|
|
||||||
|
const prevLen = prevConversationLenRef.current;
|
||||||
|
const currLen = conversation.length;
|
||||||
|
const hasNewMessages = currLen > prevLen;
|
||||||
|
const isFirstLoad = prevLen === 0 && currLen > 0;
|
||||||
|
|
||||||
|
// Check if user just submitted (always scroll for their own messages)
|
||||||
|
const lastMsg = conversation[currLen - 1];
|
||||||
|
const userJustSubmitted = hasNewMessages && lastMsg?.role === 'user';
|
||||||
|
|
||||||
|
prevConversationLenRef.current = currLen;
|
||||||
|
|
||||||
|
// Auto-scroll if in sticky mode, first load, or user just submitted
|
||||||
|
if (isFirstLoad || userJustSubmitted || (hasNewMessages && stickyToBottomRef.current)) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [conversation]);
|
}, [conversation]);
|
||||||
|
|
||||||
const handleDismissClick = (e) => {
|
const handleDismissClick = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDismiss(session.session_id);
|
if (onDismiss) onDismiss(session.session_id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container classes differ based on enlarged mode
|
||||||
|
const containerClasses = enlarged
|
||||||
|
? 'glass-panel flex w-full max-w-[90vw] max-h-[90vh] flex-col overflow-hidden rounded-2xl border border-selection/80'
|
||||||
|
: 'glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel"
|
class=${containerClasses}
|
||||||
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
|
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
|
||||||
onClick=${() => onClick(session)}
|
onClick=${enlarged ? undefined : () => onClick && onClick(session)}
|
||||||
>
|
>
|
||||||
<!-- Card Header -->
|
<!-- Card Header -->
|
||||||
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
|
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
|
||||||
@@ -86,11 +141,11 @@ export function SessionCard({ session, onClick, conversation, onFetchConversatio
|
|||||||
|
|
||||||
<!-- Card Content Area (Chat) -->
|
<!-- Card Content Area (Chat) -->
|
||||||
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
|
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
|
||||||
<${ChatMessages} messages=${conversation || []} status=${session.status} />
|
<${ChatMessages} messages=${conversation || []} status=${session.status} limit=${enlarged ? null : 20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Footer (Input or Questions) -->
|
<!-- Card Footer (Input or Questions) -->
|
||||||
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4 ${hasQuestions ? 'max-h-[300px] overflow-y-auto' : ''}">
|
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4">
|
||||||
${hasQuestions ? html`
|
${hasQuestions ? html`
|
||||||
<${QuestionBlock}
|
<${QuestionBlock}
|
||||||
questions=${session.pending_questions}
|
questions=${session.pending_questions}
|
||||||
|
|||||||
@@ -1,23 +1,47 @@
|
|||||||
import { html, useState } from '../lib/preact.js';
|
import { html, useState, useRef } from '../lib/preact.js';
|
||||||
import { getStatusMeta } from '../utils/status.js';
|
import { getStatusMeta } from '../utils/status.js';
|
||||||
|
|
||||||
export function SimpleInput({ sessionId, status, onRespond }) {
|
export function SimpleInput({ sessionId, status, onRespond }) {
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
const meta = getStatusMeta(status);
|
const meta = getStatusMeta(status);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (text.trim()) {
|
if (text.trim() && !sending) {
|
||||||
onRespond(sessionId, text.trim(), true, 0);
|
setSending(true);
|
||||||
setText('');
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onRespond(sessionId, text.trim(), true, 0);
|
||||||
|
setText('');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to send message');
|
||||||
|
console.error('SimpleInput send error:', err);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
// Refocus the textarea after submission
|
||||||
|
// Use setTimeout to ensure React has re-rendered with disabled=false
|
||||||
|
setTimeout(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}>
|
<form onSubmit=${handleSubmit} class="flex flex-col gap-2" onClick=${(e) => e.stopPropagation()}>
|
||||||
|
${error && html`
|
||||||
|
<div class="rounded-lg border border-attention/40 bg-attention/12 px-3 py-1.5 text-sm text-attention">
|
||||||
|
${error}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<div class="flex items-end gap-2.5">
|
||||||
<textarea
|
<textarea
|
||||||
|
ref=${textareaRef}
|
||||||
value=${text}
|
value=${text}
|
||||||
onInput=${(e) => {
|
onInput=${(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
@@ -36,14 +60,17 @@ export function SimpleInput({ sessionId, status, onRespond }) {
|
|||||||
rows="1"
|
rows="1"
|
||||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||||
|
disabled=${sending}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
|
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||||
|
disabled=${sending || !text.trim()}
|
||||||
>
|
>
|
||||||
Send
|
${sending ? 'Sending...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
125
dashboard/components/Toast.js
Normal file
125
dashboard/components/Toast.js
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { html, useState, useEffect, useCallback, useRef } from '../lib/preact.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight toast notification system.
|
||||||
|
* Tracks error counts and surfaces persistent issues.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Singleton state for toast management (shared across components)
|
||||||
|
let toastListeners = [];
|
||||||
|
let toastIdCounter = 0;
|
||||||
|
|
||||||
|
export function showToast(message, type = 'error', duration = 5000) {
|
||||||
|
const id = ++toastIdCounter;
|
||||||
|
const toast = { id, message, type, duration };
|
||||||
|
toastListeners.forEach(listener => listener(toast));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastContainer() {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
const timeoutIds = useRef(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (toast) => {
|
||||||
|
setToasts(prev => [...prev, toast]);
|
||||||
|
if (toast.duration > 0) {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
timeoutIds.current.delete(toast.id);
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||||
|
}, toast.duration);
|
||||||
|
timeoutIds.current.set(toast.id, timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
toastListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
toastListeners = toastListeners.filter(l => l !== listener);
|
||||||
|
// Clear all pending timeouts on unmount
|
||||||
|
timeoutIds.current.forEach(id => clearTimeout(id));
|
||||||
|
timeoutIds.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = useCallback((id) => {
|
||||||
|
// Clear auto-dismiss timeout if exists
|
||||||
|
const timeoutId = timeoutIds.current.get(id);
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutIds.current.delete(id);
|
||||||
|
}
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
|
||||||
|
${toasts.map(toast => html`
|
||||||
|
<div
|
||||||
|
key=${toast.id}
|
||||||
|
class="pointer-events-auto flex items-start gap-3 rounded-xl border px-4 py-3 shadow-lg backdrop-blur-sm animate-fade-in-up ${
|
||||||
|
toast.type === 'error'
|
||||||
|
? 'border-attention/50 bg-attention/15 text-attention'
|
||||||
|
: toast.type === 'success'
|
||||||
|
? 'border-active/50 bg-active/15 text-active'
|
||||||
|
: 'border-starting/50 bg-starting/15 text-starting'
|
||||||
|
}"
|
||||||
|
style=${{ maxWidth: '380px' }}
|
||||||
|
>
|
||||||
|
<div class="flex-1 text-sm font-medium">${toast.message}</div>
|
||||||
|
<button
|
||||||
|
onClick=${() => dismiss(toast.id)}
|
||||||
|
class="shrink-0 rounded p-0.5 opacity-70 transition-opacity hover:opacity-100"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error tracker for surfacing repeated failures.
|
||||||
|
* Tracks errors by key and shows toast after threshold.
|
||||||
|
*/
|
||||||
|
const errorCounts = {};
|
||||||
|
const ERROR_THRESHOLD = 3;
|
||||||
|
const ERROR_WINDOW_MS = 30000; // 30 second window
|
||||||
|
|
||||||
|
export function trackError(key, message, { log = true, threshold = ERROR_THRESHOLD } = {}) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Always log
|
||||||
|
if (log) {
|
||||||
|
console.error(`[${key}]`, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track error count within window
|
||||||
|
if (!errorCounts[key]) {
|
||||||
|
errorCounts[key] = { count: 0, firstAt: now, lastToastAt: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracker = errorCounts[key];
|
||||||
|
|
||||||
|
// Reset if outside window
|
||||||
|
if (now - tracker.firstAt > ERROR_WINDOW_MS) {
|
||||||
|
tracker.count = 0;
|
||||||
|
tracker.firstAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.count++;
|
||||||
|
|
||||||
|
// Surface toast after threshold, but not too frequently
|
||||||
|
if (tracker.count >= threshold && now - tracker.lastToastAt > ERROR_WINDOW_MS) {
|
||||||
|
showToast(`${message} (repeated ${tracker.count}x)`, 'error');
|
||||||
|
tracker.lastToastAt = now;
|
||||||
|
tracker.count = 0; // Reset after showing toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearErrorCount(key) {
|
||||||
|
delete errorCounts[key];
|
||||||
|
}
|
||||||
@@ -74,27 +74,6 @@ export function groupSessionsByProject(sessions) {
|
|||||||
groups.get(key).sessions.push(session);
|
groups.get(key).sessions.push(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = Array.from(groups.values());
|
// Return groups in API order (no status-based reordering)
|
||||||
|
return Array.from(groups.values());
|
||||||
// Sort groups: most urgent status first, then most recent activity
|
|
||||||
result.sort((a, b) => {
|
|
||||||
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
|
||||||
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
|
||||||
if (aWorst !== bWorst) return aWorst - bWorst;
|
|
||||||
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
|
||||||
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
|
||||||
return bRecent - aRecent;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort sessions within each group: urgent first, then most recent
|
|
||||||
for (const group of result) {
|
|
||||||
group.sessions.sort((a, b) => {
|
|
||||||
const aPri = STATUS_PRIORITY[a.status] ?? 99;
|
|
||||||
const bPri = STATUS_PRIORITY[b.status] ?? 99;
|
|
||||||
if (aPri !== bPri) return aPri - bPri;
|
|
||||||
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|||||||
910
plans/agent-spawning.md
Normal file
910
plans/agent-spawning.md
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
# AMC Agent Spawning via Zellij
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashboard. Users click "New Agent" in the page header, select an agent type, and a new Zellij pane opens in a project-named tab. The spawned agent appears in the dashboard alongside existing sessions.
|
||||||
|
|
||||||
|
**Why this matters:** Currently, AMC monitors existing sessions but cannot create new ones. Users must manually open terminal panes and run `claude` or `codex`. This feature enables orchestration workflows where the dashboard becomes a control center for multi-agent coordination.
|
||||||
|
|
||||||
|
**Core insight from research:** Zellij's CLI (`zellij --session <name> action new-pane ...`) works from external processes without requiring `ZELLIJ` environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Workflows
|
||||||
|
|
||||||
|
### Workflow 1: Spawn Agent from Project Tab
|
||||||
|
|
||||||
|
**Trigger:** User is viewing a specific project tab in the dashboard sidebar, clicks "New Agent" in header.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User is on "amc" project tab in dashboard sidebar
|
||||||
|
2. User clicks "+ New Agent" button in page header
|
||||||
|
3. Modal appears with agent type selector: Claude / Codex
|
||||||
|
4. User selects "Claude", clicks "Spawn"
|
||||||
|
5. Server finds or creates Zellij tab named "amc"
|
||||||
|
6. New pane spawns in that tab with `claude --dangerously-skip-permissions`
|
||||||
|
7. Dashboard updates: new session card appears (status: "starting")
|
||||||
|
|
||||||
|
**Key behavior:** Path is automatically determined from the selected project tab.
|
||||||
|
|
||||||
|
### Workflow 2: Spawn Agent from "All Projects" Tab
|
||||||
|
|
||||||
|
**Trigger:** User is on "All Projects" tab, clicks "New Agent" in header.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User is on "All Projects" tab (no specific project selected)
|
||||||
|
2. User clicks "+ New Agent" button in page header
|
||||||
|
3. Modal appears with:
|
||||||
|
- Project dropdown (lists subdirectories of `~/projects/`)
|
||||||
|
- Agent type selector: Claude / Codex
|
||||||
|
4. User selects "mission-control" project, "Codex" agent type, clicks "Spawn"
|
||||||
|
5. Server finds or creates Zellij tab named "mission-control"
|
||||||
|
6. New pane spawns with `codex --dangerously-bypass-approvals-and-sandbox`
|
||||||
|
7. Dashboard updates: new session card appears
|
||||||
|
|
||||||
|
**Key behavior:** User must select a project from the dropdown when on "All Projects".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Spawn Button Location & Context
|
||||||
|
|
||||||
|
- **AC-1:** "New Agent" button is located in the page header, not on session cards
|
||||||
|
- **AC-2:** When on a specific project tab, the spawn modal does not show a project picker
|
||||||
|
- **AC-3:** When on "All Projects" tab, the spawn modal shows a project dropdown
|
||||||
|
|
||||||
|
### Project Selection (All Projects Tab)
|
||||||
|
|
||||||
|
- **AC-4:** The project dropdown lists all immediate subdirectories of `~/projects/`
|
||||||
|
- **AC-5:** Hidden directories (starting with `.`) are excluded from the dropdown
|
||||||
|
- **AC-6:** User must select a project before spawning (no default selection)
|
||||||
|
|
||||||
|
### Agent Type Selection
|
||||||
|
|
||||||
|
- **AC-7:** User can choose between Claude and Codex agent types
|
||||||
|
- **AC-8:** Claude agents spawn with full autonomous permissions enabled
|
||||||
|
- **AC-9:** Codex agents spawn with full autonomous permissions enabled
|
||||||
|
|
||||||
|
### Zellij Tab Targeting
|
||||||
|
|
||||||
|
- **AC-10:** Agents spawn in a Zellij tab named after the project (e.g., "amc" tab for amc project)
|
||||||
|
- **AC-11:** If the project-named tab does not exist, it is created before spawning
|
||||||
|
- **AC-12:** All spawns target the "infra" Zellij session
|
||||||
|
|
||||||
|
### Pane Spawning
|
||||||
|
|
||||||
|
- **AC-13:** The spawned pane's cwd is set to the project directory
|
||||||
|
- **AC-14:** Spawned panes are named `{agent_type}-{project}` (e.g., "claude-amc")
|
||||||
|
- **AC-15:** The spawned agent appears in the dashboard within 5 seconds of spawn
|
||||||
|
|
||||||
|
### Session Discovery
|
||||||
|
|
||||||
|
- **AC-16:** Spawned agent's session data includes correct `zellij_session` and `zellij_pane`
|
||||||
|
- **AC-17:** Dashboard can send responses to spawned agents (existing functionality works)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **AC-18:** Spawning fails gracefully if Zellij binary is not found
|
||||||
|
- **AC-19:** Spawning fails gracefully if target project directory does not exist
|
||||||
|
- **AC-20:** Spawn errors display a toast notification showing the server's error message
|
||||||
|
- **AC-21:** Network errors between dashboard and server show retry option
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
|
||||||
|
- **AC-23:** Server rejects path traversal attempts in project parameter
|
||||||
|
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
|
||||||
|
|
||||||
|
### Spawn Request Lifecycle
|
||||||
|
|
||||||
|
- **AC-25:** The Spawn button is disabled while a spawn request is in progress
|
||||||
|
- **AC-26:** If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
|
||||||
|
- **AC-27:** Server generates a unique `spawn_id` and passes it to the agent via `AMC_SPAWN_ID` env var
|
||||||
|
- **AC-28:** `amc-hook` writes `spawn_id` to session file when present in environment
|
||||||
|
- **AC-29:** Spawn request polls for session file containing the specific `spawn_id` (max 5 second wait)
|
||||||
|
- **AC-30:** Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions
|
||||||
|
|
||||||
|
### Modal Behavior
|
||||||
|
|
||||||
|
- **AC-31:** Spawn modal can be dismissed by clicking outside, pressing Escape, or clicking Cancel
|
||||||
|
- **AC-32:** While fetching the projects list, the project dropdown displays "Loading..." and is disabled
|
||||||
|
|
||||||
|
### Projects List Caching
|
||||||
|
|
||||||
|
- **AC-33:** Projects list is loaded on server start and cached in memory
|
||||||
|
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Dashboard (Preact) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Header [+ New Agent] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────┐ ┌──────────────────────────────────────┐ │
|
||||||
|
│ │ Sidebar │ │ Main Content │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ All Proj. │ │ ┌─────────┐ ┌─────────┐ │ │
|
||||||
|
│ │ > amc │ │ │ Session │ │ Session │ │ │
|
||||||
|
│ │ > gitlore │ │ │ Card │ │ Card │ │ │
|
||||||
|
│ │ │ │ └─────────┘ └─────────┘ │ │
|
||||||
|
│ └────────────┘ └──────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SpawnModal (context-aware) │ │
|
||||||
|
│ │ - If on project tab: just agent type picker │ │
|
||||||
|
│ │ - If on All Projects: project dropdown + agent type │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
POST /api/spawn
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ AMC Server (Python) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SpawnMixin │ │
|
||||||
|
│ │ - list_projects() → ~/projects/* dirs │ │
|
||||||
|
│ │ - validate_spawn() → security checks │ │
|
||||||
|
│ │ - ensure_tab_exists() → create tab if needed │ │
|
||||||
|
│ │ - spawn_agent_pane() → zellij action new-pane │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
subprocess.run(["zellij", ...])
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Zellij Session: "infra" │
|
||||||
|
│ │
|
||||||
|
│ Tab: "amc" Tab: "gitlore" Tab: "work" │
|
||||||
|
│ ┌──────┬──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │claude│claude│ │codex │ │nvim │ │
|
||||||
|
│ │ (1) │ (2) │ │ │ │ │ │
|
||||||
|
│ └──────┴──────┘ └──────┘ └──────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Dashboard → Server:** `POST /api/spawn` with project + agent type
|
||||||
|
2. **Server:** Acquire spawn lock (serializes concurrent requests)
|
||||||
|
3. **Server:** Validate project path is within `~/projects/` (resolve symlinks)
|
||||||
|
4. **Server:** Generate unique `spawn_id` (UUID)
|
||||||
|
5. **Server:** Check Zellij session exists (fail with SESSION_NOT_FOUND if not)
|
||||||
|
6. **Server → Zellij:** `go-to-tab-name --create <project>` (ensures tab exists)
|
||||||
|
7. **Server → Zellij:** `new-pane --cwd <path> -- <agent command>` with `AMC_SPAWN_ID` env var
|
||||||
|
8. **Zellij:** Pane created, agent process starts
|
||||||
|
9. **Agent → Hook:** `amc-hook` fires on `SessionStart`, writes session JSON including `spawn_id` from env
|
||||||
|
10. **Server:** Poll for session file containing matching `spawn_id` (up to 5 seconds)
|
||||||
|
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
|
||||||
|
12. **Server:** Release spawn lock
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
|
||||||
|
**POST /api/spawn**
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "amc",
|
||||||
|
"agent_type": "claude"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (success):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"project": "amc",
|
||||||
|
"agent_type": "claude"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (error):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "Project directory does not exist: /Users/taylor/projects/foo",
|
||||||
|
"code": "PROJECT_NOT_FOUND"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /api/projects**
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": ["amc", "gitlore", "mission-control", "work-ghost"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST /api/projects/refresh**
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"projects": ["amc", "gitlore", "mission-control", "work-ghost"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Architecture
|
||||||
|
|
||||||
|
1. **Server-side spawning:** Dashboard runs in browser, cannot execute shell commands.
|
||||||
|
|
||||||
|
2. **Tab-per-project organization:** Keeps agents for the same project grouped together in Zellij.
|
||||||
|
|
||||||
|
3. **`go-to-tab-name --create`:** Idempotent tab creation - creates if missing, switches if exists.
|
||||||
|
|
||||||
|
4. **Polling for discovery:** Spawned agents write their own session files via hooks. Dashboard picks them up on next poll.
|
||||||
|
|
||||||
|
### Session Discovery Mechanism
|
||||||
|
|
||||||
|
**Claude agents** write session files via the `amc-hook` Claude Code hook:
|
||||||
|
- Hook fires on `SessionStart` event
|
||||||
|
- Writes JSON to `~/.local/share/amc/sessions/{session_id}.json`
|
||||||
|
- Contains: session_id, project, status, zellij_session, zellij_pane, etc.
|
||||||
|
- **Spawn correlation:** If `AMC_SPAWN_ID` env var is set, hook includes it in session JSON
|
||||||
|
|
||||||
|
**Codex agents** are discovered dynamically by `SessionDiscoveryMixin`:
|
||||||
|
- Scans `~/.codex/sessions/` for recently-modified `.jsonl` files
|
||||||
|
- Extracts Zellij pane info via process inspection (`pgrep`, `lsof`)
|
||||||
|
- Creates/updates session JSON in `~/.local/share/amc/sessions/`
|
||||||
|
- **Spawn correlation:** Codex discovery checks for `AMC_SPAWN_ID` in process environment
|
||||||
|
|
||||||
|
**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.
|
||||||
|
|
||||||
|
**Why spawn_id matters:** Without deterministic correlation, polling "any new session file" could return success for unrelated agent activity. The `spawn_id` ensures the server confirms the *specific* agent it spawned is running.
|
||||||
|
|
||||||
|
### Integration with Existing Code
|
||||||
|
|
||||||
|
| New Code | Integrates With | How |
|
||||||
|
|----------|-----------------|-----|
|
||||||
|
| SpawnMixin | HttpMixin | Uses `_send_json()`, `_json_error()` |
|
||||||
|
| SpawnModal | Modal.js | Follows same patterns (escape, scroll lock, animations) |
|
||||||
|
| SpawnModal | api.js | Uses `fetchWithTimeout`, API constants |
|
||||||
|
| Toast calls | Toast.js | Uses existing `showToast(msg, type, duration)` |
|
||||||
|
| PROJECTS_DIR | context.py | Added alongside other path constants |
|
||||||
|
| Session polling | SESSIONS_DIR | Watches same directory as discovery mixins |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Specifications
|
||||||
|
|
||||||
|
### IMP-0: Add Constants to context.py
|
||||||
|
|
||||||
|
**File:** `amc_server/context.py`
|
||||||
|
|
||||||
|
Add after existing path constants:
|
||||||
|
```python
|
||||||
|
# Projects directory for spawning agents
|
||||||
|
PROJECTS_DIR = Path.home() / "projects"
|
||||||
|
|
||||||
|
# Default Zellij session for spawning
|
||||||
|
ZELLIJ_SESSION = "infra"
|
||||||
|
|
||||||
|
# Lock for serializing spawn operations (prevents Zellij race conditions)
|
||||||
|
_spawn_lock = threading.Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30)
|
||||||
|
|
||||||
|
**File:** `amc_server/mixins/spawn.py`
|
||||||
|
|
||||||
|
**Integration notes:**
|
||||||
|
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
|
||||||
|
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
|
||||||
|
- PROJECTS_DIR and ZELLIJ_SESSION come from context.py (centralized constants)
|
||||||
|
- Session file polling watches SESSIONS_DIR for any new .json by mtime
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from amc_server.context import PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION
|
||||||
|
|
||||||
|
# Agent commands (AC-8, AC-9: full autonomous permissions)
|
||||||
|
AGENT_COMMANDS = {
|
||||||
|
"claude": ["claude", "--dangerously-skip-permissions"],
|
||||||
|
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Module-level cache for projects list (AC-29)
|
||||||
|
_projects_cache: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_projects_cache():
|
||||||
|
"""Scan ~/projects/ and cache the list. Called on server start."""
|
||||||
|
global _projects_cache
|
||||||
|
try:
|
||||||
|
projects = []
|
||||||
|
for entry in PROJECTS_DIR.iterdir():
|
||||||
|
if entry.is_dir() and not entry.name.startswith("."):
|
||||||
|
projects.append(entry.name)
|
||||||
|
projects.sort()
|
||||||
|
_projects_cache = projects
|
||||||
|
except OSError:
|
||||||
|
_projects_cache = []
|
||||||
|
|
||||||
|
|
||||||
|
class SpawnMixin:
|
||||||
|
def _handle_spawn(self):
|
||||||
|
"""Handle POST /api/spawn"""
|
||||||
|
# Read JSON body (same pattern as control.py)
|
||||||
|
try:
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(content_length))
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
self._json_error(400, "Invalid JSON body")
|
||||||
|
return
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
self._json_error(400, "Invalid JSON body")
|
||||||
|
return
|
||||||
|
|
||||||
|
project = body.get("project", "").strip()
|
||||||
|
agent_type = body.get("agent_type", "claude").strip()
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
error = self._validate_spawn_params(project, agent_type)
|
||||||
|
if error:
|
||||||
|
self._send_json(400, {"ok": False, "error": error["message"], "code": error["code"]})
|
||||||
|
return
|
||||||
|
|
||||||
|
project_path = PROJECTS_DIR / project
|
||||||
|
|
||||||
|
# Ensure tab exists, then spawn pane, then wait for session file
|
||||||
|
result = self._spawn_agent_in_project_tab(project, project_path, agent_type)
|
||||||
|
|
||||||
|
if result["ok"]:
|
||||||
|
self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type})
|
||||||
|
else:
|
||||||
|
self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})
|
||||||
|
|
||||||
|
def _validate_spawn_params(self, project, agent_type):
|
||||||
|
"""Validate spawn parameters. Returns error dict or None."""
|
||||||
|
if not project:
|
||||||
|
return {"message": "project is required", "code": "MISSING_PROJECT"}
|
||||||
|
|
||||||
|
# Security: no path traversal
|
||||||
|
if "/" in project or "\\" in project or ".." in project:
|
||||||
|
return {"message": "Invalid project name", "code": "INVALID_PROJECT"}
|
||||||
|
|
||||||
|
# Project must exist
|
||||||
|
project_path = PROJECTS_DIR / project
|
||||||
|
if not project_path.is_dir():
|
||||||
|
return {"message": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
|
||||||
|
|
||||||
|
# Agent type must be valid
|
||||||
|
if agent_type not in AGENT_COMMANDS:
|
||||||
|
return {"message": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_zellij_session_exists(self):
|
||||||
|
"""Check if the target Zellij session exists (AC-25)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[ZELLIJ_BIN, "list-sessions"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
return ZELLIJ_SESSION in result.stdout
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _wait_for_session_file(self, timeout=5.0):
|
||||||
|
"""Poll for any new session file in SESSIONS_DIR (AC-26).
|
||||||
|
|
||||||
|
Session files are named {session_id}.json. We don't know the session_id
|
||||||
|
in advance, so we watch for any .json file with mtime after spawn started.
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
# Snapshot existing files to detect new ones
|
||||||
|
existing_files = set()
|
||||||
|
if SESSIONS_DIR.exists():
|
||||||
|
existing_files = {f.name for f in SESSIONS_DIR.glob("*.json")}
|
||||||
|
|
||||||
|
while time.monotonic() - start < timeout:
|
||||||
|
if SESSIONS_DIR.exists():
|
||||||
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
|
# New file that didn't exist before spawn
|
||||||
|
if f.name not in existing_files:
|
||||||
|
return True
|
||||||
|
# Or existing file with very recent mtime (reused session)
|
||||||
|
if f.stat().st_mtime > start:
|
||||||
|
return True
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _spawn_agent_in_project_tab(self, project, project_path, agent_type):
|
||||||
|
"""Ensure project tab exists and spawn agent pane."""
|
||||||
|
try:
|
||||||
|
# Step 0: Check session exists (AC-25)
|
||||||
|
if not self._check_zellij_session_exists():
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Zellij session '{ZELLIJ_SESSION}' not found",
|
||||||
|
"code": "SESSION_NOT_FOUND"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Go to or create the project tab
|
||||||
|
tab_result = subprocess.run(
|
||||||
|
[ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "go-to-tab-name", "--create", project],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if tab_result.returncode != 0:
|
||||||
|
return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}
|
||||||
|
|
||||||
|
# Step 2: Spawn new pane with agent command (AC-14: naming scheme)
|
||||||
|
agent_cmd = AGENT_COMMANDS[agent_type]
|
||||||
|
pane_name = f"{agent_type}-{project}"
|
||||||
|
|
||||||
|
spawn_cmd = [
|
||||||
|
ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "new-pane",
|
||||||
|
"--name", pane_name,
|
||||||
|
"--cwd", str(project_path),
|
||||||
|
"--",
|
||||||
|
*agent_cmd
|
||||||
|
]
|
||||||
|
|
||||||
|
spawn_result = subprocess.run(
|
||||||
|
spawn_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if spawn_result.returncode != 0:
|
||||||
|
return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}
|
||||||
|
|
||||||
|
# Step 3: Wait for session file (AC-26)
|
||||||
|
if not self._wait_for_session_file(timeout=5.0):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Agent spawned but session file not detected within 5 seconds",
|
||||||
|
"code": "SESSION_FILE_TIMEOUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"ok": False, "error": f"zellij not found at {ZELLIJ_BIN}", "code": "ZELLIJ_NOT_FOUND"}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
|
||||||
|
|
||||||
|
def _handle_projects(self):
|
||||||
|
"""Handle GET /api/projects - return cached projects list (AC-29)."""
|
||||||
|
self._send_json(200, {"projects": _projects_cache})
|
||||||
|
|
||||||
|
def _handle_projects_refresh(self):
|
||||||
|
"""Handle POST /api/projects/refresh - refresh cache (AC-30)."""
|
||||||
|
load_projects_cache()
|
||||||
|
self._send_json(200, {"ok": True, "projects": _projects_cache})
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-30)
|
||||||
|
|
||||||
|
**File:** `amc_server/mixins/http.py`
|
||||||
|
|
||||||
|
Add to `do_GET`:
|
||||||
|
```python
|
||||||
|
elif self.path == "/api/projects":
|
||||||
|
self._handle_projects()
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `do_POST`:
|
||||||
|
```python
|
||||||
|
elif self.path == "/api/spawn":
|
||||||
|
self._handle_spawn()
|
||||||
|
elif self.path == "/api/projects/refresh":
|
||||||
|
self._handle_projects_refresh()
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `do_OPTIONS` for CORS preflight on new endpoints:
|
||||||
|
```python
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
# CORS preflight for API endpoints
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2b: Server Startup (fulfills AC-29)
|
||||||
|
|
||||||
|
**File:** `amc_server/server.py`
|
||||||
|
|
||||||
|
Add to server initialization:
|
||||||
|
```python
|
||||||
|
from amc_server.mixins.spawn import load_projects_cache
|
||||||
|
|
||||||
|
# In server startup, before starting HTTP server:
|
||||||
|
load_projects_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2c: API Constants (follows existing pattern)
|
||||||
|
|
||||||
|
**File:** `dashboard/utils/api.js`
|
||||||
|
|
||||||
|
Add to existing exports:
|
||||||
|
```javascript
|
||||||
|
// Spawn API endpoints
|
||||||
|
export const API_SPAWN = '/api/spawn';
|
||||||
|
export const API_PROJECTS = '/api/projects';
|
||||||
|
export const API_PROJECTS_REFRESH = '/api/projects/refresh';
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-3: Handler Integration (fulfills AC-2, AC-3)
|
||||||
|
|
||||||
|
**File:** `amc_server/handler.py`
|
||||||
|
|
||||||
|
Add SpawnMixin to handler inheritance chain:
|
||||||
|
```python
|
||||||
|
from amc_server.mixins.spawn import SpawnMixin
|
||||||
|
|
||||||
|
class AMCHandler(
|
||||||
|
HttpMixin,
|
||||||
|
StateMixin,
|
||||||
|
ConversationMixin,
|
||||||
|
SessionControlMixin,
|
||||||
|
SessionDiscoveryMixin,
|
||||||
|
SessionParsingMixin,
|
||||||
|
SpawnMixin, # Add this
|
||||||
|
BaseHTTPRequestHandler,
|
||||||
|
):
|
||||||
|
"""HTTP handler composed from focused mixins."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-4: SpawnModal Component (fulfills AC-2, AC-3, AC-6, AC-7, AC-20, AC-24, AC-27, AC-28)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/SpawnModal.js`
|
||||||
|
|
||||||
|
**Integration notes:**
|
||||||
|
- Uses `fetchWithTimeout` and API constants from `api.js` (consistent with codebase)
|
||||||
|
- Follows `Modal.js` patterns: escape key, click-outside, body scroll lock, animated close
|
||||||
|
- Uses `html` tagged template (Preact pattern used throughout dashboard)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
|
||||||
|
import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js';
|
||||||
|
|
||||||
|
export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState('');
|
||||||
|
const [agentType, setAgentType] = useState('claude');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const needsProjectPicker = !currentProject;
|
||||||
|
|
||||||
|
// Animated close handler (matches Modal.js pattern)
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (loading) return;
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setClosing(false);
|
||||||
|
onClose();
|
||||||
|
}, 200);
|
||||||
|
}, [loading, onClose]);
|
||||||
|
|
||||||
|
// Body scroll lock (matches Modal.js pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Escape key to close (matches Modal.js pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') handleClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
|
// Fetch projects when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && needsProjectPicker) {
|
||||||
|
setLoadingProjects(true);
|
||||||
|
fetchWithTimeout(API_PROJECTS)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
setSelectedProject('');
|
||||||
|
})
|
||||||
|
.catch(err => setError(err.message))
|
||||||
|
.finally(() => setLoadingProjects(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, needsProjectPicker]);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setAgentType('claude');
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSpawn = async () => {
|
||||||
|
const project = currentProject || selectedProject;
|
||||||
|
if (!project) {
|
||||||
|
setError('Please select a project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(API_SPAWN, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project, agent_type: agentType })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
onSpawn({ success: true, project, agentType });
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Spawn failed');
|
||||||
|
onSpawn({ error: data.error });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
||||||
|
setError(msg);
|
||||||
|
onSpawn({ error: msg });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
|
||||||
|
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="glass-panel w-full max-w-md rounded-2xl p-6 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
|
||||||
|
onClick=${(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 font-display text-lg font-semibold text-bright">New Agent</h2>
|
||||||
|
|
||||||
|
${needsProjectPicker && html`
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span class="mb-1 block text-sm text-dim">Project</span>
|
||||||
|
<select
|
||||||
|
class="w-full rounded-lg border border-selection/50 bg-surface px-3 py-2 text-bright"
|
||||||
|
value=${selectedProject}
|
||||||
|
onChange=${(e) => setSelectedProject(e.target.value)}
|
||||||
|
disabled=${loadingProjects}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
${loadingProjects ? 'Loading...' : 'Select a project...'}
|
||||||
|
</option>
|
||||||
|
${projects.map(p => html`
|
||||||
|
<option key=${p} value=${p}>${p}</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${!needsProjectPicker && html`
|
||||||
|
<p class="mb-4 text-sm text-dim">
|
||||||
|
Project: <span class="font-medium text-bright">${currentProject}</span>
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span class="mb-2 block text-sm text-dim">Agent Type</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
agentType === 'claude'
|
||||||
|
? 'border-active bg-active/20 text-active'
|
||||||
|
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
|
||||||
|
}"
|
||||||
|
onClick=${() => setAgentType('claude')}
|
||||||
|
>
|
||||||
|
Claude
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
agentType === 'codex'
|
||||||
|
? 'border-active bg-active/20 text-active'
|
||||||
|
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
|
||||||
|
}"
|
||||||
|
onClick=${() => setAgentType('codex')}
|
||||||
|
>
|
||||||
|
Codex
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
${error && html`
|
||||||
|
<p class="mb-4 rounded-lg border border-attention/50 bg-attention/10 px-3 py-2 text-sm text-attention">
|
||||||
|
${error}
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-selection/50 px-4 py-2 text-sm text-dim transition-colors hover:border-selection hover:text-bright"
|
||||||
|
onClick=${handleClose}
|
||||||
|
disabled=${loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-active px-4 py-2 text-sm font-medium text-surface transition-opacity disabled:opacity-50"
|
||||||
|
onClick=${handleSpawn}
|
||||||
|
disabled=${loading || (needsProjectPicker && !selectedProject)}
|
||||||
|
>
|
||||||
|
${loading ? 'Spawning...' : 'Spawn'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-5: Header New Agent Button (fulfills AC-1)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/App.js`
|
||||||
|
|
||||||
|
**Integration points:**
|
||||||
|
1. Add import for SpawnModal
|
||||||
|
2. Add state for modal visibility
|
||||||
|
3. Add button to existing inline header (lines 331-380)
|
||||||
|
4. Add SpawnModal component at end of render
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add import at top
|
||||||
|
import { SpawnModal } from './SpawnModal.js';
|
||||||
|
|
||||||
|
// Add state in App component (around line 14)
|
||||||
|
const [spawnModalOpen, setSpawnModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Add button to existing header section (inside the flex container, around line 341)
|
||||||
|
// After the status summary chips div, add:
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20"
|
||||||
|
onClick=${() => setSpawnModalOpen(true)}
|
||||||
|
>
|
||||||
|
+ New Agent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Add modal before closing fragment (after ToastContainer, around line 426)
|
||||||
|
<${SpawnModal}
|
||||||
|
isOpen=${spawnModalOpen}
|
||||||
|
onClose=${() => setSpawnModalOpen(false)}
|
||||||
|
onSpawn=${handleSpawnResult}
|
||||||
|
currentProject=${selectedProject}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-6: Toast Notifications for Spawn Results (fulfills AC-20, AC-21)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/App.js`
|
||||||
|
|
||||||
|
**Integration note:** Uses existing `showToast(message, type, duration)` signature from `Toast.js`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { showToast } from './Toast.js'; // Already imported in App.js
|
||||||
|
|
||||||
|
// Add this callback in App component
|
||||||
|
const handleSpawnResult = useCallback((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
// showToast(message, type, duration) - matches Toast.js signature
|
||||||
|
showToast(`${result.agentType} agent spawned for ${result.project}`, 'success');
|
||||||
|
} else if (result.error) {
|
||||||
|
showToast(result.error, 'error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Slices
|
||||||
|
|
||||||
|
### Slice 1: Server-Side Spawning (Backend Only)
|
||||||
|
|
||||||
|
**Goal:** Spawn agents via curl/API without UI.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Create `SpawnMixin` with `_handle_spawn()`, `_validate_spawn_params()`, `_spawn_agent_in_project_tab()`
|
||||||
|
2. Create `_handle_projects()` for listing `~/projects/` subdirectories
|
||||||
|
3. Add `/api/spawn` (POST) and `/api/projects` (GET) routes to HTTP handler
|
||||||
|
4. Add SpawnMixin to handler inheritance chain
|
||||||
|
5. Write tests for spawn validation and subprocess calls
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
```bash
|
||||||
|
# List projects
|
||||||
|
curl http://localhost:7400/api/projects
|
||||||
|
|
||||||
|
# Spawn claude agent
|
||||||
|
curl -X POST http://localhost:7400/api/spawn \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project":"amc","agent_type":"claude"}'
|
||||||
|
|
||||||
|
# Spawn codex agent
|
||||||
|
curl -X POST http://localhost:7400/api/spawn \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project":"gitlore","agent_type":"codex"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**ACs covered:** AC-4, AC-5, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30
|
||||||
|
|
||||||
|
### Slice 2: Spawn Modal UI
|
||||||
|
|
||||||
|
**Goal:** Complete UI for spawning agents.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Create `SpawnModal` component with context-aware behavior
|
||||||
|
2. Add "+ New Agent" button to page header
|
||||||
|
3. Pass `currentProject` from sidebar selection to modal
|
||||||
|
4. Implement agent type toggle (Claude / Codex)
|
||||||
|
5. Wire up project dropdown (only shown on "All Projects")
|
||||||
|
6. Add loading and error states
|
||||||
|
7. Show toast on spawn result
|
||||||
|
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)
|
||||||
|
|
||||||
|
**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-24, AC-27, AC-28
|
||||||
|
|
||||||
|
### Slice 3: Polish & Edge Cases
|
||||||
|
|
||||||
|
**Goal:** Handle edge cases and improve UX.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Handle case where `~/projects/` doesn't exist or is empty
|
||||||
|
2. Add visual feedback when agent appears in dashboard after spawn
|
||||||
|
3. Test with projects that have special characters in name
|
||||||
|
4. Ensure spawned agents' hooks write correct metadata
|
||||||
|
|
||||||
|
**ACs covered:** AC-15, AC-16, AC-17
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Rate limiting:** Should we limit spawn frequency to prevent accidental spam?
|
||||||
|
|
||||||
|
2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?
|
||||||
|
|
||||||
|
3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?
|
||||||
|
|
||||||
|
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project?
|
||||||
|
|
||||||
|
5. **Spawn limits:** Should we add spawn limits or warnings for resource management?
|
||||||
|
|
||||||
|
6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?
|
||||||
|
|
||||||
|
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents?
|
||||||
382
plans/card-modal-unification.md
Normal file
382
plans/card-modal-unification.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# Card/Modal Unification Plan
|
||||||
|
|
||||||
|
**Status:** Implemented
|
||||||
|
**Date:** 2026-02-26
|
||||||
|
**Author:** Claude + Taylor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
Unify SessionCard and Modal into a single component with an `enlarged` prop, eliminating 165 lines of duplicated code and ensuring feature parity across both views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
### 1.1 What's Broken
|
||||||
|
|
||||||
|
The AMC dashboard displays agent sessions as cards in a grid. Clicking a card opens a "modal" for a larger, focused view. These two views evolved independently, creating:
|
||||||
|
|
||||||
|
| Issue | Impact |
|
||||||
|
|-------|--------|
|
||||||
|
| **Duplicated rendering logic** | Modal.js reimplemented header, chat, input from scratch (227 lines) |
|
||||||
|
| **Feature drift** | Card had context usage display; modal didn't. Modal had timestamps; card didn't. |
|
||||||
|
| **Maintenance burden** | Every card change required parallel modal changes (often forgotten) |
|
||||||
|
| **Inconsistent UX** | Users see different information depending on view |
|
||||||
|
|
||||||
|
### 1.2 Why This Matters
|
||||||
|
|
||||||
|
The modal's purpose is simple: **show an enlarged view with more screen space for content**. It should not be a separate implementation with different features. Users clicking a card expect to see *the same thing, bigger* — not a different interface.
|
||||||
|
|
||||||
|
### 1.3 Root Cause
|
||||||
|
|
||||||
|
The modal was originally built as a separate component because it needed:
|
||||||
|
- Backdrop blur with click-outside-to-close
|
||||||
|
- Escape key handling
|
||||||
|
- Body scroll lock
|
||||||
|
- Entrance/exit animations
|
||||||
|
|
||||||
|
These concerns led developers to copy-paste card internals into the modal rather than compose them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals and Non-Goals
|
||||||
|
|
||||||
|
### 2.1 Goals
|
||||||
|
|
||||||
|
1. **Zero duplicated rendering code** — Single source of truth for how sessions display
|
||||||
|
2. **Automatic feature parity** — Any card change propagates to modal without extra work
|
||||||
|
3. **Preserve modal behaviors** — Backdrop, escape key, animations, scroll lock
|
||||||
|
4. **Add missing features to both views** — Smart scroll, sending state feedback
|
||||||
|
|
||||||
|
### 2.2 Non-Goals
|
||||||
|
|
||||||
|
- Changing the visual design of either view
|
||||||
|
- Adding new features beyond parity + smart scroll + sending state
|
||||||
|
- Refactoring other components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Workflows
|
||||||
|
|
||||||
|
### 3.1 Current User Journey
|
||||||
|
|
||||||
|
```
|
||||||
|
User sees session cards in grid
|
||||||
|
│
|
||||||
|
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
|
||||||
|
│
|
||||||
|
└─► User clicks card
|
||||||
|
│
|
||||||
|
└─► Modal opens with DIFFERENT layout:
|
||||||
|
- Combined status badge (dot inside)
|
||||||
|
- No context usage
|
||||||
|
- All messages with timestamps
|
||||||
|
- Different input implementation
|
||||||
|
- Keyboard hints shown
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Target User Journey
|
||||||
|
|
||||||
|
```
|
||||||
|
User sees session cards in grid
|
||||||
|
│
|
||||||
|
├─► Card shows: status, agent, cwd, context usage, last 20 messages, input
|
||||||
|
│
|
||||||
|
└─► User clicks card
|
||||||
|
│
|
||||||
|
└─► Modal opens with SAME card, just bigger:
|
||||||
|
- Identical header layout
|
||||||
|
- Context usage visible
|
||||||
|
- All messages (not limited to 20)
|
||||||
|
- Same input components
|
||||||
|
- Same everything, more space
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 User Benefits
|
||||||
|
|
||||||
|
| Benefit | Rationale |
|
||||||
|
|---------|-----------|
|
||||||
|
| **Cognitive consistency** | Same information architecture in both views reduces learning curve |
|
||||||
|
| **Trust** | No features "hiding" in one view or the other |
|
||||||
|
| **Predictability** | Click = zoom, not "different interface" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Design Decisions
|
||||||
|
|
||||||
|
### 4.1 Architecture: Shared Component with Prop
|
||||||
|
|
||||||
|
**Decision:** Add `enlarged` prop to SessionCard. Modal renders `<SessionCard enlarged={true} />`.
|
||||||
|
|
||||||
|
**Alternatives Considered:**
|
||||||
|
|
||||||
|
| Alternative | Rejected Because |
|
||||||
|
|-------------|------------------|
|
||||||
|
| Modal wraps Card with CSS transform | Breaks layout, accessibility issues, can't change message limit |
|
||||||
|
| Higher-order component | Unnecessary complexity for single boolean difference |
|
||||||
|
| Render props pattern | Overkill, harder to read |
|
||||||
|
| Separate "CardContent" extracted | Still requires prop to control limit, might as well be on SessionCard |
|
||||||
|
|
||||||
|
**Rationale:** A single boolean prop is the simplest solution that achieves all goals. The `enlarged` prop controls exactly two things: container sizing and message limit. Everything else is identical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Message Limit: Card 20, Enlarged All
|
||||||
|
|
||||||
|
**Decision:** Card shows last 20 messages. Enlarged view shows all.
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Cards in a grid need bounded height for visual consistency
|
||||||
|
- 20 messages is enough context without overwhelming the card
|
||||||
|
- Enlarged view exists specifically to see more — no artificial limit makes sense
|
||||||
|
- Implementation: `limit` prop on ChatMessages (20 default, null for unlimited)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Header Layout: Keep Card's Multi-Row Style
|
||||||
|
|
||||||
|
**Decision:** Use the card's multi-row header layout for both views.
|
||||||
|
|
||||||
|
**Modal had:** Single row with combined status badge (dot inside badge)
|
||||||
|
**Card had:** Multi-row with separate dot, status badge, agent badge, cwd badge, context usage
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Card layout shows more information (context usage was missing from modal)
|
||||||
|
- Multi-row handles overflow gracefully with `flex-wrap`
|
||||||
|
- Consistent with the "modal = bigger card" philosophy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 Spacing: Keep Tighter (Card Style)
|
||||||
|
|
||||||
|
**Decision:** Use card's tighter spacing (`px-4 py-3`, `space-y-2.5`) for both views.
|
||||||
|
|
||||||
|
**Modal had:** Roomier spacing (`px-5 py-4`, `space-y-4`)
|
||||||
|
|
||||||
|
**Rationale:**
|
||||||
|
- Tighter spacing is more information-dense
|
||||||
|
- Enlarged view gains space from larger container, not wider margins
|
||||||
|
- Consistent visual rhythm between views
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Empty State Text: "No messages to show"
|
||||||
|
|
||||||
|
**Decision:** Standardize on "No messages to show" (neither original).
|
||||||
|
|
||||||
|
**Card had:** "No messages yet"
|
||||||
|
**Modal had:** "No conversation messages"
|
||||||
|
|
||||||
|
**Rationale:** "No messages to show" is neutral and accurate — doesn't imply timing ("yet") or specific terminology ("conversation").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Implementation Details
|
||||||
|
|
||||||
|
### 5.1 SessionCard.js Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
BEFORE: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss })
|
||||||
|
AFTER: SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss, enlarged = false })
|
||||||
|
```
|
||||||
|
|
||||||
|
**New behaviors controlled by `enlarged`:**
|
||||||
|
|
||||||
|
| Aspect | `enlarged=false` (card) | `enlarged=true` (modal) |
|
||||||
|
|--------|-------------------------|-------------------------|
|
||||||
|
| Container classes | `h-[850px] w-[600px] cursor-pointer hover:...` | `max-w-5xl max-h-[90vh]` |
|
||||||
|
| Click handler | `onClick(session)` | `undefined` (no-op) |
|
||||||
|
| Message limit | 20 | null (all) |
|
||||||
|
|
||||||
|
**New feature: Smart scroll tracking**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Track if user is at bottom
|
||||||
|
const wasAtBottomRef = useRef(true);
|
||||||
|
|
||||||
|
// On scroll, update tracking
|
||||||
|
const handleScroll = () => {
|
||||||
|
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||||
|
};
|
||||||
|
|
||||||
|
// On new messages, only scroll if user was at bottom
|
||||||
|
if (hasNewMessages && wasAtBottomRef.current) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Users reading history shouldn't be yanked to bottom when new messages arrive. Only auto-scroll if they were already at the bottom (watching live updates).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Modal.js Changes
|
||||||
|
|
||||||
|
**Before:** 227 lines reimplementing header, chat, input, scroll, state management
|
||||||
|
|
||||||
|
**After:** 62 lines — backdrop wrapper only
|
||||||
|
|
||||||
|
```js
|
||||||
|
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
|
||||||
|
// Closing animation state
|
||||||
|
// Body scroll lock
|
||||||
|
// Escape key handler
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="backdrop...">
|
||||||
|
<${SessionCard}
|
||||||
|
session=${session}
|
||||||
|
conversation=${conversations[session.session_id] || []}
|
||||||
|
onFetchConversation=${onFetchConversation}
|
||||||
|
onRespond=${onRespond}
|
||||||
|
onDismiss=${onDismiss}
|
||||||
|
onClick=${() => {}}
|
||||||
|
enlarged=${true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Preserved behaviors:**
|
||||||
|
- Backdrop blur (`bg-[#02050d]/84 backdrop-blur-sm`)
|
||||||
|
- Click outside to close
|
||||||
|
- Escape key handler
|
||||||
|
- Body scroll lock (`document.body.style.overflow = 'hidden'`)
|
||||||
|
- Entrance/exit animations (CSS classes)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 ChatMessages.js Changes
|
||||||
|
|
||||||
|
```
|
||||||
|
BEFORE: ChatMessages({ messages, status })
|
||||||
|
AFTER: ChatMessages({ messages, status, limit = 20 })
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic change:**
|
||||||
|
```js
|
||||||
|
// Before: always slice to 20
|
||||||
|
const displayMessages = allDisplayMessages.slice(-20);
|
||||||
|
|
||||||
|
// After: respect limit prop
|
||||||
|
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 SimpleInput.js / QuestionBlock.js Changes
|
||||||
|
|
||||||
|
**New feature: Sending state feedback**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
if (sending) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await onRespond(...);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// In render:
|
||||||
|
<button disabled=${sending}>
|
||||||
|
${sending ? 'Sending...' : 'Send'}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale:** Users need feedback that their message is being sent. Without this, they might click multiple times or think the UI is broken.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 App.js Changes
|
||||||
|
|
||||||
|
**Removed (unused after refactor):**
|
||||||
|
- `conversationLoading` state — was only passed to Modal
|
||||||
|
- `refreshConversation` callback — was only used by Modal's custom send handler
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `respondToSession` now refreshes conversation immediately after successful send
|
||||||
|
- Modal receives same props as SessionCard (onRespond, onFetchConversation, onDismiss)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
App.js
|
||||||
|
│
|
||||||
|
├─► SessionCard (in grid)
|
||||||
|
│ ├─► ChatMessages (limit=20)
|
||||||
|
│ │ └─► MessageBubble
|
||||||
|
│ ├─► QuestionBlock (with sending state)
|
||||||
|
│ │ └─► OptionButton
|
||||||
|
│ └─► SimpleInput (with sending state)
|
||||||
|
│
|
||||||
|
└─► Modal (backdrop wrapper)
|
||||||
|
└─► SessionCard (enlarged=true)
|
||||||
|
├─► ChatMessages (limit=null)
|
||||||
|
│ └─► MessageBubble
|
||||||
|
├─► QuestionBlock (with sending state)
|
||||||
|
│ └─► OptionButton
|
||||||
|
└─► SimpleInput (with sending state)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key insight:** Modal no longer has its own rendering tree. It delegates entirely to SessionCard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Metrics
|
||||||
|
|
||||||
|
| Metric | Before | After | Change |
|
||||||
|
|--------|--------|-------|--------|
|
||||||
|
| Modal.js lines | 227 | 62 | -73% |
|
||||||
|
| Total duplicated code | ~180 lines | 0 | -100% |
|
||||||
|
| Features requiring dual maintenance | All | None | -100% |
|
||||||
|
| Prop surface area (Modal) | 6 custom | 6 same as card | Aligned |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Verification Checklist
|
||||||
|
|
||||||
|
- [x] Card displays: status dot, status badge, agent badge, cwd, context usage, messages, input
|
||||||
|
- [x] Modal displays: identical to card, just larger
|
||||||
|
- [x] Card limits to 20 messages
|
||||||
|
- [x] Modal shows all messages
|
||||||
|
- [x] Smart scroll works in both views
|
||||||
|
- [x] "Sending..." feedback works in both views
|
||||||
|
- [x] Escape closes modal
|
||||||
|
- [x] Click outside closes modal
|
||||||
|
- [x] Entrance/exit animations work
|
||||||
|
- [x] Body scroll locked when modal open
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future Considerations
|
||||||
|
|
||||||
|
### 9.1 Potential Enhancements
|
||||||
|
|
||||||
|
| Enhancement | Rationale | Blocked By |
|
||||||
|
|-------------|-----------|------------|
|
||||||
|
| Keyboard navigation in card grid | Accessibility | None |
|
||||||
|
| Resize modal dynamically | User preference | None |
|
||||||
|
| Pin modal to side (split view) | Power user workflow | Design decision needed |
|
||||||
|
|
||||||
|
### 9.2 Maintenance Notes
|
||||||
|
|
||||||
|
- **Any SessionCard change** automatically applies to modal view
|
||||||
|
- **To add modal-only behavior**: Check `enlarged` prop (but avoid this — keep views identical)
|
||||||
|
- **To change message limit**: Modify the `limit` prop value in SessionCard's ChatMessages call
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Lessons Learned
|
||||||
|
|
||||||
|
1. **Composition > Duplication** — When two UIs show the same data, compose them from shared components
|
||||||
|
2. **Props for variations** — A single boolean prop is often sufficient for "same thing, different context"
|
||||||
|
3. **Identify the actual differences** — Modal needed only: backdrop, escape key, scroll lock, animations. Everything else was false complexity.
|
||||||
|
4. **Feature drift is inevitable** — Duplicated code guarantees divergence over time. Only shared code stays in sync.
|
||||||
357
tests/test_conversation_mtime.py
Normal file
357
tests/test_conversation_mtime.py
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
"""Tests for conversation_mtime_ns feature in state.py.
|
||||||
|
|
||||||
|
This feature enables real-time dashboard updates by tracking the conversation
|
||||||
|
file's modification time, which changes on every write (tool call, message, etc.),
|
||||||
|
rather than relying solely on hook events which only fire at specific moments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from amc_server.mixins.state import StateMixin
|
||||||
|
from amc_server.mixins.parsing import SessionParsingMixin
|
||||||
|
from amc_server.mixins.discovery import SessionDiscoveryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedMixin(StateMixin, SessionParsingMixin, SessionDiscoveryMixin):
|
||||||
|
"""Combined mixin for testing - mirrors AMCHandler's inheritance."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetConversationMtime(unittest.TestCase):
|
||||||
|
"""Tests for _get_conversation_mtime method."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.handler = CombinedMixin()
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_claude_session_with_existing_file(self):
|
||||||
|
"""When conversation file exists, returns its mtime_ns."""
|
||||||
|
# Create a temp conversation file
|
||||||
|
conv_file = Path(self.temp_dir) / "test-session.jsonl"
|
||||||
|
conv_file.write_text('{"type": "user"}\n')
|
||||||
|
expected_mtime = conv_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_get_claude_conversation_file", return_value=conv_file
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertEqual(result, expected_mtime)
|
||||||
|
|
||||||
|
def test_claude_session_file_not_found(self):
|
||||||
|
"""When _get_claude_conversation_file returns None, returns None."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "nonexistent",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_get_claude_conversation_file", return_value=None
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_claude_session_oserror_on_stat(self):
|
||||||
|
"""When stat() raises OSError, returns None gracefully."""
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.stat.side_effect = OSError("Permission denied")
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_get_claude_conversation_file", return_value=mock_file
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_claude_session_missing_project_dir(self):
|
||||||
|
"""When project_dir is empty, _get_claude_conversation_file returns None."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"project_dir": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Real method will return None for empty project_dir
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_claude_session_missing_session_id(self):
|
||||||
|
"""When session_id is empty, returns None."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
# _get_claude_conversation_file needs both session_id and project_dir
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_get_claude_conversation_file", return_value=None
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_codex_session_with_transcript_path(self):
|
||||||
|
"""When transcript_path is provided and exists, returns its mtime_ns."""
|
||||||
|
transcript_file = Path(self.temp_dir) / "codex-transcript.jsonl"
|
||||||
|
transcript_file.write_text('{"type": "response_item"}\n')
|
||||||
|
expected_mtime = transcript_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "codex",
|
||||||
|
"session_id": "codex-123",
|
||||||
|
"transcript_path": str(transcript_file),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
self.assertEqual(result, expected_mtime)
|
||||||
|
|
||||||
|
def test_codex_session_transcript_path_missing_file(self):
|
||||||
|
"""When transcript_path points to nonexistent file, falls back to discovery."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "codex",
|
||||||
|
"session_id": "codex-123",
|
||||||
|
"transcript_path": "/nonexistent/path.jsonl",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock the discovery fallback
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_find_codex_transcript_file", return_value=None
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_codex_session_discovery_fallback(self):
|
||||||
|
"""When transcript_path not provided, uses _find_codex_transcript_file."""
|
||||||
|
transcript_file = Path(self.temp_dir) / "discovered-transcript.jsonl"
|
||||||
|
transcript_file.write_text('{"type": "response_item"}\n')
|
||||||
|
expected_mtime = transcript_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "codex",
|
||||||
|
"session_id": "codex-456",
|
||||||
|
# No transcript_path
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_find_codex_transcript_file", return_value=transcript_file
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertEqual(result, expected_mtime)
|
||||||
|
|
||||||
|
def test_codex_session_discovery_returns_none(self):
|
||||||
|
"""When discovery finds nothing, returns None."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "codex",
|
||||||
|
"session_id": "codex-789",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_find_codex_transcript_file", return_value=None
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_codex_session_oserror_on_transcript_stat(self):
|
||||||
|
"""When stat() on discovered transcript raises OSError, returns None."""
|
||||||
|
mock_file = MagicMock()
|
||||||
|
mock_file.stat.side_effect = OSError("I/O error")
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "codex",
|
||||||
|
"session_id": "codex-err",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_find_codex_transcript_file", return_value=mock_file
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_unknown_agent_returns_none(self):
|
||||||
|
"""When agent is neither 'claude' nor 'codex', returns None."""
|
||||||
|
session_data = {
|
||||||
|
"agent": "unknown_agent",
|
||||||
|
"session_id": "test-123",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_missing_agent_returns_none(self):
|
||||||
|
"""When agent key is missing, returns None."""
|
||||||
|
session_data = {
|
||||||
|
"session_id": "test-123",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
|
def test_mtime_changes_on_file_modification(self):
|
||||||
|
"""Verify mtime actually changes when file is modified."""
|
||||||
|
conv_file = Path(self.temp_dir) / "changing-file.jsonl"
|
||||||
|
conv_file.write_text('{"type": "user"}\n')
|
||||||
|
mtime_1 = conv_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
# Small delay to ensure filesystem mtime granularity is captured
|
||||||
|
time.sleep(0.01)
|
||||||
|
conv_file.write_text('{"type": "user"}\n{"type": "assistant"}\n')
|
||||||
|
mtime_2 = conv_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"agent": "claude",
|
||||||
|
"session_id": "test-session",
|
||||||
|
"project_dir": "/some/project",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
self.handler, "_get_claude_conversation_file", return_value=conv_file
|
||||||
|
):
|
||||||
|
result = self.handler._get_conversation_mtime(session_data)
|
||||||
|
|
||||||
|
self.assertEqual(result, mtime_2)
|
||||||
|
self.assertNotEqual(mtime_1, mtime_2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectSessionsIntegration(unittest.TestCase):
|
||||||
|
"""Integration tests verifying conversation_mtime_ns is included in session data."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.temp_dir = tempfile.mkdtemp()
|
||||||
|
self.sessions_dir = Path(self.temp_dir) / "sessions"
|
||||||
|
self.sessions_dir.mkdir()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_collect_sessions_includes_mtime_when_available(self):
|
||||||
|
"""_collect_sessions adds conversation_mtime_ns when file exists."""
|
||||||
|
handler = CombinedMixin()
|
||||||
|
|
||||||
|
# Create a session file
|
||||||
|
session_file = self.sessions_dir / "test-session.json"
|
||||||
|
session_data = {
|
||||||
|
"session_id": "test-session",
|
||||||
|
"agent": "claude",
|
||||||
|
"project_dir": "/test/project",
|
||||||
|
"status": "active",
|
||||||
|
"last_event_at": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
session_file.write_text(json.dumps(session_data))
|
||||||
|
|
||||||
|
# Create a conversation file
|
||||||
|
conv_file = Path(self.temp_dir) / "conversation.jsonl"
|
||||||
|
conv_file.write_text('{"type": "user"}\n')
|
||||||
|
expected_mtime = conv_file.stat().st_mtime_ns
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
|
||||||
|
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
|
||||||
|
patch.object(handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
|
||||||
|
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(handler, "_get_claude_conversation_file", return_value=conv_file):
|
||||||
|
|
||||||
|
sessions = handler._collect_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(len(sessions), 1)
|
||||||
|
self.assertEqual(sessions[0]["session_id"], "test-session")
|
||||||
|
self.assertEqual(sessions[0]["conversation_mtime_ns"], expected_mtime)
|
||||||
|
|
||||||
|
def test_collect_sessions_omits_mtime_when_file_missing(self):
|
||||||
|
"""_collect_sessions does not add conversation_mtime_ns when file doesn't exist."""
|
||||||
|
handler = CombinedMixin()
|
||||||
|
|
||||||
|
session_file = self.sessions_dir / "no-conv-session.json"
|
||||||
|
session_data = {
|
||||||
|
"session_id": "no-conv-session",
|
||||||
|
"agent": "claude",
|
||||||
|
"project_dir": "/test/project",
|
||||||
|
"status": "active",
|
||||||
|
"last_event_at": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
session_file.write_text(json.dumps(session_data))
|
||||||
|
|
||||||
|
with patch("amc_server.mixins.state.SESSIONS_DIR", self.sessions_dir), \
|
||||||
|
patch("amc_server.mixins.state.EVENTS_DIR", Path(self.temp_dir) / "events"), \
|
||||||
|
patch.object(handler, "_discover_active_codex_sessions"), \
|
||||||
|
patch.object(handler, "_get_active_zellij_sessions", return_value=None), \
|
||||||
|
patch.object(handler, "_get_context_usage_for_session", return_value=None), \
|
||||||
|
patch.object(handler, "_get_claude_conversation_file", return_value=None):
|
||||||
|
|
||||||
|
sessions = handler._collect_sessions()
|
||||||
|
|
||||||
|
self.assertEqual(len(sessions), 1)
|
||||||
|
self.assertNotIn("conversation_mtime_ns", sessions[0])
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardChangeDetection(unittest.TestCase):
|
||||||
|
"""Tests verifying the dashboard uses mtime for change detection."""
|
||||||
|
|
||||||
|
def test_mtime_triggers_state_hash_change(self):
|
||||||
|
"""When conversation_mtime_ns changes, payload hash should change."""
|
||||||
|
# This is implicitly tested by the SSE mechanism:
|
||||||
|
# state.py builds payload with conversation_mtime_ns
|
||||||
|
# _serve_stream hashes payload and sends on change
|
||||||
|
|
||||||
|
# Simulate two state payloads with different mtimes
|
||||||
|
payload_1 = {
|
||||||
|
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
|
||||||
|
"server_time": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
payload_2 = {
|
||||||
|
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 2000}],
|
||||||
|
"server_time": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
hash_1 = hashlib.sha1(json.dumps(payload_1).encode()).hexdigest()
|
||||||
|
hash_2 = hashlib.sha1(json.dumps(payload_2).encode()).hexdigest()
|
||||||
|
|
||||||
|
self.assertNotEqual(hash_1, hash_2)
|
||||||
|
|
||||||
|
def test_same_mtime_same_hash(self):
|
||||||
|
"""When mtime hasn't changed, hash should be stable."""
|
||||||
|
payload = {
|
||||||
|
"sessions": [{"session_id": "s1", "conversation_mtime_ns": 1000}],
|
||||||
|
"server_time": "2024-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
hash_1 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
|
||||||
|
hash_2 = hashlib.sha1(json.dumps(payload).encode()).hexdigest()
|
||||||
|
|
||||||
|
self.assertEqual(hash_1, hash_2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user