Compare commits

..

9 Commits

Author SHA1 Message Date
teernisse
2926645b10 docs: add implementation plans for upcoming features
Planning documents for future AMC features:

PLAN-slash-autocomplete.md:
- Slash-command autocomplete for SimpleInput
- Skills API endpoint, SlashMenu dropdown, keyboard navigation
- 8 implementation steps with file locations and dependencies

plans/agent-spawning.md:
- Agent spawning acceptance criteria documentation
- Spawn command integration, status tracking, error handling
- Written as testable acceptance criteria (AC-1 through AC-10)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:25:09 -05:00
teernisse
6e566cfe82 chore: add .gitignore for bv cache directory
Ignore .bv/ directory which contains local cache and configuration
for the bv (beads viewer) graph-aware triage tool.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:25:02 -05:00
teernisse
117784f8ef chore: initialize beads project tracking
Add .beads/ directory for dependency-aware issue tracking with the
beads (br) CLI. This provides lightweight task management for the
AMC project.

Files:
- config.yaml: Project configuration (prefix, priorities, types)
- issues.jsonl: Issue database in append-only JSONL format
- metadata.json: Project metadata and statistics
- .gitignore: Ignore database files, locks, and temporaries

Current issues track slash-command autocomplete feature planning
(fetchSkills API, SlashMenu component, hook integration, etc.)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:57 -05:00
teernisse
de994bb837 feat(dashboard): add markdown preview for AskUserQuestion options
Implement side-by-side preview layout when AskUserQuestion options
include markdown content, matching the Claude Code tool spec.

Hook changes (bin/amc-hook):
- Extract optional 'markdown' field from question options
- Only include when present (maintains backward compatibility)

QuestionBlock layout:
- Detect hasMarkdownPreviews via options.some(opt => opt.markdown)
- Standard layout: vertical option list (unchanged)
- Preview layout: 40% options left, 60% preview pane right
- Fixed 400px preview height prevents layout thrashing on hover
- Track previewIndex state, update on mouseEnter/focus

Content rendering (smart detection):
- Code fences (starts with ```): renderContent() for syntax highlighting
- Everything else: raw <pre> to preserve ASCII diagrams exactly
- "No preview for this option" when hovered option lacks markdown

OptionButton enhancements:
- Accept selected, onMouseEnter, onFocus props
- Visual selected state: brighter border/bg with subtle shadow
- Compact padding (py-2 vs py-3.5) for preview layout density
- Graceful undefined handling for standard layout usage

Bug fixes during development:
- Layout thrashing: Fixed height (not max-height) on preview pane
- ASCII corruption: Detect code fences vs plain text for render path
- Horizontal overflow: Use overflow-auto (not just overflow-y-auto)
- Data pipeline: Hook was stripping markdown field (root cause)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:35 -05:00
teernisse
b9c1bd6ff1 feat(dashboard): improve real-time updates and scroll behavior
Major UX improvements to conversation display and state management.

Scroll behavior (SessionCard.js):
- Replace scroll-position tracking with wheel-event intent detection
- Accumulate scroll-up distance before disabling sticky mode (50px threshold)
- Re-enable sticky on scroll-down when near bottom (100px threshold)
- Always scroll to bottom on first load or user's own message submission
- Use requestAnimationFrame for smooth scroll positioning

Optimistic updates (App.js):
- Immediately show user messages before API confirmation
- Remove optimistic message on send failure
- Eliminates perceived latency when sending responses

Error tracking integration (App.js):
- Wire up trackError/clearErrorCount for API operations
- Track: state fetch, conversation fetch, respond, dismiss, SSE init/parse
- Clear error counts on successful operations
- Clear SSE event cache on reconnect to force refresh

Conversation management (App.js):
- Use mtime_ns (preferred) or last_event_at for change detection
- Clean up conversation cache when sessions are dismissed
- Add modalSessionRef for stable reference across renders

Message stability (ChatMessages.js):
- Prefer server-assigned message IDs for React keys
- Fallback to role+timestamp+index for legacy messages

Input UX (SimpleInput.js):
- Auto-refocus textarea after successful submission
- Use setTimeout to ensure React has re-rendered first

Sorting simplification (status.js):
- Remove status-based group/session reordering
- Return groups in API order (server handles sorting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:21 -05:00
teernisse
3dc10aa060 feat(dashboard): add toast notification system with error tracking
Add lightweight toast notification infrastructure that surfaces
repeated errors to users while avoiding alert fatigue.

Components:
- ToastContainer: Renders toast notifications with auto-dismiss
- showToast(): Singleton function to display messages from anywhere
- trackError(): Counts errors by key, surfaces toast after threshold

Error tracking strategy:
- Errors logged immediately (console.error)
- Toast shown only after 3+ occurrences within 30-second window
- Prevents spam from transient network issues
- Reset counter on success (clearErrorCount)

Toast styling:
- Fixed position bottom-right with z-index 100
- Type-aware colors (error=red, success=green, info=yellow)
- Manual dismiss button with auto-dismiss after 5 seconds
- Backdrop blur and slide-up animation

This prepares the dashboard to gracefully handle API failures
without overwhelming users with error popups for transient issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:24:06 -05:00
teernisse
0d15787c7a test(server): add unit tests for conversation mtime tracking
Comprehensive test coverage for _get_conversation_mtime() and its
integration with _collect_sessions().

Test cases:
- Claude sessions: file exists, file missing, OSError on stat,
  missing project_dir, missing session_id
- Codex sessions: transcript_path exists, transcript_path missing,
  discovery fallback, discovery returns None, OSError on stat
- Edge cases: unknown agent type, missing agent key
- Integration: mtime included when file exists, omitted when missing
- Change detection: mtime changes trigger payload hash changes

Uses mock patching to isolate file system interactions and test
error handling paths without requiring actual conversation files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:23:55 -05:00
teernisse
dcbaf12f07 feat(server): add conversation mtime tracking for real-time updates
Add conversation_mtime_ns field to session state that tracks the actual
modification time of conversation files. This enables more responsive
dashboard updates by detecting changes that occur between hook events
(e.g., during streaming tool execution).

Changes:
- state.py: Add _get_conversation_mtime() to stat conversation files
  and include mtime_ns in session payloads when available
- conversation.py: Add stable message IDs (claude-{session}-{n} format)
  for React key stability and message deduplication
- control.py: Fix FIFO eviction for dismissed_codex_ids - set.pop()
  removes arbitrary element, now uses dict with insertion-order iteration
- context.py: Update dismissed_codex_ids type from set to dict

The mtime approach complements existing last_event_at tracking:
- last_event_at: Changes on hook events (session boundaries)
- conversation_mtime_ns: Changes on every file write (real-time)

Dashboard can now detect mid-session conversation updates without
waiting for the next hook event.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:23:42 -05:00
teernisse
fa1ad4b22b unify card/modal 2026-02-26 11:39:39 -05:00
23 changed files with 2780 additions and 316 deletions

11
.beads/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Database
*.db
*.db-shm
*.db-wal
# Lock files
*.lock
# Temporary
last-touched
*.tmp

4
.beads/config.yaml Normal file
View File

@@ -0,0 +1,4 @@
# Beads Project Configuration
# issue_prefix: bd
# default_priority: 2
# default_type: task

21
.beads/issues.jsonl Normal file
View 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
View File

@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# bv (beads viewer) local config and caches
.bv/

509
PLAN-slash-autocomplete.md Normal file
View 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

View File

@@ -62,7 +62,8 @@ _codex_transcript_cache = {}
_CODEX_CACHE_MAX = 200
# 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
# Serialize state collection because it mutates session files/caches.

View File

@@ -16,10 +16,11 @@ class SessionControlMixin:
safe_id = os.path.basename(session_id)
session_file = SESSIONS_DIR / f"{safe_id}.json"
# 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:
_dismissed_codex_ids.pop()
_dismissed_codex_ids.add(safe_id)
oldest_key = next(iter(_dismissed_codex_ids))
del _dismissed_codex_ids[oldest_key]
_dismissed_codex_ids[safe_id] = True
session_file.unlink(missing_ok=True)
self._send_json(200, {"ok": True})

View File

@@ -39,6 +39,7 @@ class ConversationMixin:
def _parse_claude_conversation(self, session_id, project_dir):
"""Parse Claude Code JSONL conversation format."""
messages = []
msg_id = 0
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)
if content and isinstance(content, str):
messages.append({
"id": f"claude-{session_id[:8]}-{msg_id}",
"role": "user",
"content": content,
"timestamp": entry.get("timestamp", ""),
})
msg_id += 1
elif msg_type == "assistant":
# Assistant messages have structured content
@@ -90,6 +93,7 @@ class ConversationMixin:
text_parts.append(part)
if text_parts or tool_calls or thinking_parts:
msg = {
"id": f"claude-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
"timestamp": entry.get("timestamp", ""),
@@ -99,6 +103,7 @@ class ConversationMixin:
if thinking_parts:
msg["thinking"] = "\n\n".join(thinking_parts)
messages.append(msg)
msg_id += 1
except json.JSONDecodeError:
continue
@@ -117,6 +122,7 @@ class ConversationMixin:
"""
messages = []
pending_tool_calls = [] # Accumulate tool calls to attach to next assistant message
msg_id = 0
conv_file = self._find_codex_transcript_file(session_id)
@@ -161,19 +167,23 @@ class ConversationMixin:
# Flush any pending tool calls first
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,
"timestamp": timestamp,
})
msg_id += 1
pending_tool_calls = []
# Add thinking as assistant message
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"thinking": "\n".join(thinking_text),
"timestamp": timestamp,
})
msg_id += 1
continue
# Handle message (user/assistant text)
@@ -208,19 +218,24 @@ class ConversationMixin:
# Flush any pending tool calls before user message
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,
"timestamp": timestamp,
})
msg_id += 1
pending_tool_calls = []
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "user",
"content": "\n".join(text_parts),
"timestamp": timestamp,
})
msg_id += 1
elif role == "assistant":
msg = {
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else "",
"timestamp": timestamp,
@@ -231,6 +246,7 @@ class ConversationMixin:
pending_tool_calls = []
if text_parts or msg.get("tool_calls"):
messages.append(msg)
msg_id += 1
except json.JSONDecodeError:
continue
@@ -238,6 +254,7 @@ class ConversationMixin:
# Flush any remaining pending tool calls
if pending_tool_calls:
messages.append({
"id": f"codex-{session_id[:8]}-{msg_id}",
"role": "assistant",
"content": "",
"tool_calls": pending_tool_calls,

View File

@@ -3,6 +3,7 @@ import json
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from amc_server.context import (
EVENTS_DIR,
@@ -119,6 +120,11 @@ class StateMixin:
if 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)
except (json.JSONDecodeError, OSError):
continue
@@ -166,6 +172,38 @@ class StateMixin:
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):
"""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")}

View File

@@ -82,10 +82,14 @@ def _extract_questions(hook):
"options": [],
}
for opt in q.get("options", []):
entry["options"].append({
opt_entry = {
"label": opt.get("label", ""),
"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)
return result

View File

@@ -5,19 +5,20 @@ import { Sidebar } from './Sidebar.js';
import { SessionCard } from './SessionCard.js';
import { Modal } from './Modal.js';
import { EmptyState } from './EmptyState.js';
import { ToastContainer, trackError, clearErrorCount } from './Toast.js';
let optimisticMsgId = 0;
export function App() {
const [sessions, setSessions] = useState([]);
const [modalSession, setModalSession] = useState(null);
const [conversations, setConversations] = useState({});
const [conversationLoading, setConversationLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedProject, setSelectedProject] = useState(null);
const [sseConnected, setSseConnected] = useState(false);
// Silent conversation refresh (no loading state, used for background polling)
// Defined early so fetchState can reference it
// Background conversation refresh with error tracking
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
try {
let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -26,53 +27,73 @@ export function App() {
if (agent) params.set('agent', agent);
if (params.toString()) url += '?' + params.toString();
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();
setConversations(prev => ({
...prev,
[sessionId]: data.messages || []
}));
clearErrorCount(`conversation-${sessionId}`); // Clear on success
} 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
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
const applyStateData = useCallback((data) => {
const newSessions = data.sessions || [];
const newSessionIds = new Set(newSessions.map(s => s.session_id));
setSessions(newSessions);
setError(null);
// Update modalSession if it's still open (to get latest pending_questions, etc.)
let modalId = null;
setModalSession(prev => {
if (!prev) return null;
modalId = prev.session_id;
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
return updatedSession || prev;
const modalId = modalSessionRef.current;
if (modalId) {
const updatedSession = newSessions.find(s => s.session_id === modalId);
if (updatedSession) {
setModalSession(updatedSession);
}
}
// 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
// (compare last_event_at to avoid flooding the API)
// Refresh conversations for sessions that have actually changed
// 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 nextEventMap = {};
for (const session of newSessions) {
const id = session.session_id;
const newEventAt = session.last_event_at || '';
nextEventMap[id] = newEventAt;
// Prefer mtime (changes on every write) over last_event_at (only on hook events)
const newKey = session.conversation_mtime_ns || session.last_event_at || '';
nextEventMap[id] = newKey;
// Only refresh if:
// 1. Session is active/attention AND
// 2. last_event_at has actually changed OR it's the currently open modal
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');
}
const oldKey = prevEventMap[id] || '';
if (newKey !== oldKey) {
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
}
}
lastEventAtRef.current = nextEventMap;
@@ -89,22 +110,19 @@ export function App() {
}
const data = await response.json();
applyStateData(data);
clearErrorCount('state-fetch');
} catch (err) {
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);
setLoading(false);
}
}, [applyStateData]);
// Fetch conversation for a session
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => {
// Fetch conversation for a session (explicit fetch, e.g., on modal open)
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => {
// Skip if already fetched and not forcing refresh
if (!force && conversations[sessionId]) return;
if (showLoading) {
setConversationLoading(true);
}
if (!force && conversationsRef.current[sessionId]) return;
try {
let url = API_CONVERSATION + encodeURIComponent(sessionId);
@@ -114,7 +132,7 @@ export function App() {
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url);
if (!response.ok) {
console.warn('Failed to fetch conversation for', sessionId);
trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`);
return;
}
const data = await response.json();
@@ -122,22 +140,33 @@ export function App() {
...prev,
[sessionId]: data.messages || []
}));
clearErrorCount(`conversation-${sessionId}`);
} catch (err) {
console.error('Error fetching conversation:', err);
} finally {
if (showLoading) {
setConversationLoading(false);
}
trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`);
}
}, [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 payload = { text };
if (isFreeform) {
payload.freeform = true;
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 {
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
method: 'POST',
@@ -145,14 +174,22 @@ export function App() {
body: JSON.stringify(payload)
});
const data = await res.json();
if (data.ok) {
// Trigger refresh
fetchState();
if (!data.ok) {
throw new Error(data.error || 'Failed to send response');
}
clearErrorCount(`respond-${sessionId}`);
// SSE will push state update when Claude processes the message,
// which triggers conversation refresh via applyStateData
} 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
const dismissSession = useCallback(async (sessionId) => {
@@ -164,9 +201,11 @@ export function App() {
if (data.ok) {
// Trigger refresh
fetchState();
} else {
trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`);
}
} catch (err) {
console.error('Error dismissing session:', err);
trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`);
}
}, [fetchState]);
@@ -182,7 +221,7 @@ export function App() {
try {
eventSource = new EventSource(API_STREAM);
} catch (err) {
console.error('Failed to initialize EventSource:', err);
trackError('sse-init', `Failed to initialize EventSource: ${err.message}`);
setSseConnected(false);
reconnectTimer = setTimeout(connect, 2000);
return;
@@ -192,6 +231,9 @@ export function App() {
if (stopped) return;
setSseConnected(true);
setError(null);
// Clear event cache on reconnect to force refresh of all conversations
// (handles updates missed during disconnect)
lastEventAtRef.current = {};
});
eventSource.addEventListener('state', (event) => {
@@ -199,8 +241,9 @@ export function App() {
try {
const data = JSON.parse(event.data);
applyStateData(data);
clearErrorCount('sse-parse');
} 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
const handleCardClick = useCallback(async (session) => {
modalSessionRef.current = session.session_id;
setModalSession(session);
// Fetch conversation if not already cached
if (!conversations[session.session_id]) {
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true);
if (!conversationsRef.current[session.session_id]) {
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]);
const handleCloseModal = useCallback(() => {
modalSessionRef.current = null;
setModalSession(null);
}, []);
@@ -383,10 +417,12 @@ export function App() {
<${Modal}
session=${modalSession}
conversations=${conversations}
conversationLoading=${conversationLoading}
onClose=${handleCloseModal}
onSendMessage=${respondToSession}
onRefreshConversation=${refreshConversation}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${ToastContainer} />
`;
}

View File

@@ -2,26 +2,33 @@ import { html } from '../lib/preact.js';
import { getUserMessageBg } from '../utils/status.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);
if (!messages || messages.length === 0) {
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">
No messages yet
No messages to show
</div>
`;
}
const allDisplayMessages = filterDisplayMessages(messages);
const displayMessages = allDisplayMessages.slice(-20);
const displayMessages = limit ? allDisplayMessages.slice(-limit) : allDisplayMessages;
const offset = allDisplayMessages.length - displayMessages.length;
return html`
<div class="space-y-2.5">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
key=${getMessageKey(msg, offset + i)}
msg=${msg}
userBg=${userBgClass}
compact=${true}

View File

@@ -1,24 +1,12 @@
import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js';
import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
import { formatDuration, formatTime } from '../utils/formatting.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
import { SessionCard } from './SessionCard.js';
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
export function Modal({ session, conversations, onClose, onRespond, onFetchConversation, onDismiss }) {
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 state when session changes
// Reset closing state when session changes
useEffect(() => {
setClosing(false);
prevConversationLenRef.current = 0;
}, [session?.session_id]);
// Animated close handler
@@ -30,40 +18,6 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
}, 200);
}, [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
useEffect(() => {
if (!session) return;
@@ -71,9 +25,9 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
return () => {
document.body.style.overflow = '';
};
}, [!!session]);
}, [session?.session_id]);
// Handle keyboard events
// Handle escape key
useEffect(() => {
if (!session) return;
const handleKeyDown = (e) => {
@@ -81,146 +35,26 @@ export function Modal({ session, conversations, conversationLoading, onClose, on
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [!!session, handleClose]);
}, [session?.session_id, handleClose]);
if (!session) return null;
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
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);
const conversation = conversations[session.session_id] || [];
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 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'}"
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
onClick=${(e) => e.stopPropagation()}
>
<!-- Modal Header -->
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
<div class="flex-1 min-w-0">
<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 class=${closing ? 'modal-panel-out' : 'modal-panel-in'} onClick=${(e) => e.stopPropagation()}>
<${SessionCard}
session=${session}
conversation=${conversation}
onFetchConversation=${onFetchConversation}
onRespond=${onRespond}
onDismiss=${onDismiss}
enlarged=${true}
/>
</div>
</div>
`;

View File

@@ -1,17 +1,23 @@
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`
<button
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">
<span class="font-mono text-starting">${number}.</span>
<span class="font-medium text-bright">${label}</span>
<div class="flex items-baseline gap-2">
<span class="font-mono text-sm text-starting">${number}.</span>
<span class="text-sm font-medium text-bright">${label}</span>
</div>
${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>
`;

View File

@@ -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 { OptionButton } from './OptionButton.js';
import { renderContent } from '../lib/markdown.js';
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const [freeformText, setFreeformText] = useState('');
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);
if (!questions || questions.length === 0) return null;
@@ -13,22 +18,148 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
const question = questions[0];
const remainingCount = questions.length - 1;
const options = question.options || [];
// Check if any option has markdown preview content
const hasMarkdownPreviews = options.some(opt => opt.markdown);
const handleOptionClick = (optionLabel) => {
onRespond(sessionId, optionLabel, false, options.length);
};
const handleFreeformSubmit = (e) => {
e.preventDefault();
e.stopPropagation();
if (freeformText.trim()) {
onRespond(sessionId, freeformText.trim(), true, options.length);
setFreeformText('');
const handleOptionClick = async (optionLabel) => {
if (sending) return;
setSending(true);
setError(null);
try {
await onRespond(sessionId, optionLabel, false, options.length);
} catch (err) {
setError('Failed to send response');
console.error('QuestionBlock option error:', err);
} 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`
<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 && html`
<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.length > 0 && html`
<div class="space-y-2">
<div class="space-y-1.5">
${options.map((opt, i) => html`
<${OptionButton}
key=${i}
@@ -57,6 +188,7 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
<!-- Freeform Input -->
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
<textarea
ref=${textareaRef}
value=${freeformText}
onInput=${(e) => {
setFreeformText(e.target.value);
@@ -75,13 +207,15 @@ export function QuestionBlock({ questions, sessionId, status, onRespond }) {
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"
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()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</form>

View File

@@ -5,7 +5,7 @@ import { ChatMessages } from './ChatMessages.js';
import { QuestionBlock } from './QuestionBlock.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 statusMeta = getStatusMeta(session.status);
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]);
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(() => {
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]);
const handleDismissClick = (e) => {
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`
<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 }}
onClick=${() => onClick(session)}
onClick=${enlarged ? undefined : () => onClick && onClick(session)}
>
<!-- Card Header -->
<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) -->
<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>
<!-- 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`
<${QuestionBlock}
questions=${session.pending_questions}

View File

@@ -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';
export function SimpleInput({ sessionId, status, onRespond }) {
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(null);
const textareaRef = useRef(null);
const meta = getStatusMeta(status);
const handleSubmit = (e) => {
const handleSubmit = async (e) => {
e.preventDefault();
e.stopPropagation();
if (text.trim()) {
onRespond(sessionId, text.trim(), true, 0);
setText('');
if (text.trim() && !sending) {
setSending(true);
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`
<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
ref=${textareaRef}
value=${text}
onInput=${(e) => {
setText(e.target.value);
@@ -36,14 +60,17 @@ export function SimpleInput({ sessionId, status, onRespond }) {
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"
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 || !text.trim()}
>
Send
${sending ? 'Sending...' : 'Send'}
</button>
</div>
</form>
`;
}

View 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];
}

View File

@@ -74,27 +74,6 @@ export function groupSessionsByProject(sessions) {
groups.get(key).sessions.push(session);
}
const result = 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;
// Return groups in API order (no status-based reordering)
return Array.from(groups.values());
}

910
plans/agent-spawning.md Normal file
View 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?

View 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.

View 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()