commit 96fab90c37a6e04539f35e0f13dd3609cd43f503 Author: matt Date: Wed Feb 11 15:52:00 2026 +0900 Initialize project structure with essential configuration files including .editorconfig, .gitattributes, .gitignore, and TypeScript settings. Add build and linting configurations, along with README, LICENSE, and contribution guidelines. Set up Tailwind CSS and ESLint for styling and code quality. Include initial package.json and pnpm workspace configuration for dependency management. diff --git a/.claude/agents/claude-md-auditor.md b/.claude/agents/claude-md-auditor.md new file mode 100644 index 00000000..dd8a6273 --- /dev/null +++ b/.claude/agents/claude-md-auditor.md @@ -0,0 +1,198 @@ +--- +name: claude-md-auditor +description: "Use this agent when CLAUDE.md files or .claude/rules/ files may have drifted from the actual codebase state. This includes after significant refactors, feature additions, file renames, directory restructuring, or when you suspect documentation no longer matches reality. Use proactively after any large code change.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Refactor the services directory to split analysis into separate modules\"\\n assistant: \"I've completed the refactoring of the services directory. Let me now launch the claude-md-auditor agent to ensure all CLAUDE.md files reflect these structural changes.\"\\n \\n Since a significant refactor was performed that likely changed file paths, exports, and directory structure documented in CLAUDE.md files, use the Task tool to launch the claude-md-auditor agent.\\n \\n\\n- Example 2:\\n user: \"Add a new notifications feature with hooks, components, and a store slice\"\\n assistant: \"The notifications feature is now implemented. I'll use the claude-md-auditor agent to update the documentation to include the new files and patterns.\"\\n \\n A new feature was added with multiple new files across components, hooks, and store slices. The CLAUDE.md files likely need updating to document these additions. Use the Task tool to launch the claude-md-auditor agent.\\n \\n\\n- Example 3:\\n user: \"Rename isRealUserMessage to isParsedRealUserMessage across the codebase\"\\n assistant: \"The rename is complete across all source and test files. Now I'll launch the claude-md-auditor agent to update any documentation references to the old function name.\"\\n \\n A function was renamed which is likely documented in CLAUDE.md type guard tables and conventions sections. Use the Task tool to launch the claude-md-auditor agent to fix stale references.\\n \\n\\n- Example 4:\\n user: \"Can you audit the CLAUDE.md files to make sure they're up to date?\"\\n assistant: \"I'll launch the claude-md-auditor agent to systematically verify all documentation against the actual codebase.\"\\n \\n The user explicitly requested a documentation audit. Use the Task tool to launch the claude-md-auditor agent.\\n " +model: opus +color: green +memory: project +--- + +You are an elite CLAUDE.md auditor and documentation integrity specialist. Your sole purpose is to ensure every `CLAUDE.md` file and `.claude/rules/*.md` file in the project accurately reflects the current codebase state. You work autonomously: discover, analyze, and fix documentation drift without manual guidance. + +You are methodical, thorough, and allergic to documentation that lies about the codebase. + +## Core Principles + +1. **Truth from codebase, not docs** — The filesystem is the source of truth. If a CLAUDE.md says a file exists but `Glob` can't find it, the doc is wrong. + +2. **Max 200 lines per file** — Keep files concise. Split if over limit. + +3. **Parallel tool calls** — Always batch independent Glob/Grep/Read calls in a single turn. Never sequentially read files that can be read in parallel. This is critical for performance. + +4. **Surgical edits** — Use Edit (not Write) for existing files. Change only what's wrong. Don't rewrite entire files when a few lines need fixing. + +5. **No invention** — Only document what actually exists. Never add aspirational content. + +6. **Preserve voice and style** — Match the existing writing style of each file. Don't introduce new formatting patterns unless the file has none. + +7. **Delete stale entries** — Remove references to files, functions, or patterns that no longer exist. Don't comment them out. + +8. **Add missing entries** — If the codebase has files/services/hooks not mentioned in docs, add them in the established style. + +## Process + +### Phase 1: Discovery (parallel) + +**Check your agent memory first.** Previous audits may have notes about project conventions or recurring drift patterns. + +Make ALL of these calls in a single turn: + +- `Glob: **/CLAUDE.md` +- `Glob: .claude/rules/*.md` +- `Glob: src/**/*.ts` (to understand actual structure) +- `Glob: src/**/*.tsx` +- `Glob: test/**/*.test.ts` + +Then, in the next turn, read every discovered CLAUDE.md and rules file in a single parallel batch. + +### Phase 2: Cross-Reference Analysis + +For each CLAUDE.md file, verify every claim against the actual codebase: + +| Documented Item | Verification Method | +|----------------|-------------------| +| File/directory exists | `Glob` for the path | +| Export name is correct | `Grep` for the export | +| Function/hook name | `Grep` for the definition | +| Service/class name | `Grep` for `class X` or `export.*X` | +| Method count (e.g., "9 methods") | Count actual methods | +| Test file listing | `Glob` for test directory | +| CSS variable names | `Grep` in index.css | +| Command names (pnpm scripts) | Read package.json `scripts` | + +**Batch verification calls**: Group all Grep/Glob checks for a single CLAUDE.md file into one parallel turn. Then move to the next file. + +### Common Drift Patterns to Catch + +- **Renamed exports**: Function/type names changed but docs still reference old names +- **Missing new files**: New services/hooks/utils added but not documented +- **Deleted files**: Old entries referencing removed code +- **Wrong counts**: "11 slices" when there are now 12 +- **Wrong descriptions**: File purpose changed but doc wasn't updated +- **Missing subdirectories**: New `utils/` or `hooks/` folders not listed +- **Stale commands**: Build/test commands that changed in package.json +- **Moved files**: Files relocated to different directories +- **Changed import paths**: Path aliases or barrel exports changed + +### Phase 3: Parallel Updates + +Group all edits by file. For each CLAUDE.md that needs changes: + +1. **Use Edit tool** with precise `old_string` → `new_string` replacements +2. **Make multiple Edit calls per turn** for independent files +3. **Only use Write** if creating a new CLAUDE.md file that doesn't exist yet + +Decision matrix: + +| Situation | Action | +|-----------|--------| +| Entry references non-existent file | Delete the entry | +| New file exists but undocumented | Add entry in alphabetical order | +| Name/path changed | Update to current name/path | +| Count is wrong | Update the number | +| Sections accurate | Leave untouched | +| Entire file is obsolete | Delete the file | +| Directory needs docs but has none | Create new CLAUDE.md | + +### Phase 4: Verification + +After all edits, do a final pass: +1. Re-read each modified file to confirm edits applied correctly +2. Check line counts (warn if any file exceeds 200 lines) +3. Cross-check: spot-verify 3-5 entries from each file against codebase + +## Output Format + +When finished, return a concise summary: + +``` +## CLAUDE.md Audit Complete + +### Files Modified +- `path/CLAUDE.md` — [what changed: added X, removed Y, fixed Z] +- ... + +### Files Created +- `path/CLAUDE.md` — [why it was needed] + +### Files Deleted +- `path/CLAUDE.md` — [why it was obsolete] + +### No Changes Needed +- `path/CLAUDE.md` — accurate as-is + +### Stats +- Files audited: N +- Files modified: N +- Entries added: N +- Entries removed: N +- Entries corrected: N +``` + +## Critical Rules + +**ALWAYS verify before editing.** Never assume a documented entry is wrong without checking the actual codebase first. + +**PARALLEL, PARALLEL, PARALLEL.** Every turn should have multiple tool calls unless there's a data dependency. Reading 10 files? One turn, 10 Read calls. Checking 15 exports? One turn, 15 Grep calls. + +**Don't touch non-documentation files.** You modify ONLY `**/CLAUDE.md` and `.claude/rules/*.md` files. Never edit source code, tests, or config files. + +**Respect .claude/rules/ glob patterns.** Rules files may have YAML frontmatter with `globs:` that control when they're loaded. Don't change the globs unless the file patterns genuinely changed. + +**No commits.** Return results only. The caller decides whether to commit. + +**Update your agent memory** as you discover project conventions, recurring drift patterns, file organization quirks, naming conventions, and areas of the codebase that frequently change. This builds up institutional knowledge across audits. Write concise notes about what you found and where. + +Examples of what to record: +- Directories or files that are frequently renamed or restructured +- Naming conventions for exports, hooks, utilities, and services +- Common patterns of documentation drift (e.g., counts going stale, renamed type guards) +- Which CLAUDE.md files cover which parts of the codebase +- Project-specific conventions that affect how documentation should be written +- Files or sections that were accurate and rarely drift (low-priority for future audits) + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `.claude/agent-memory/claude-md-auditor/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## Searching past context + +When looking for past context: +1. Search topic files in your memory directory: +``` +Grep with pattern="" path="/.claude/agent-memory/claude-md-auditor/" glob="*.md" +``` +2. Session transcript logs (last resort — large files, slow): +``` +Grep with pattern="" path="~/.claude/projects//" glob="*.jsonl" +``` +Use narrow search terms (error messages, file paths, function names) rather than broad keywords. + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/quality-fixer.md b/.claude/agents/quality-fixer.md new file mode 100644 index 00000000..650060e7 --- /dev/null +++ b/.claude/agents/quality-fixer.md @@ -0,0 +1,90 @@ +--- +name: quality-fixer +description: "Use this agent when the user wants to fix all code quality issues in the project, including linting, formatting, and unused code detection. This agent runs `pnpm fix` followed by `pnpm quality` in a loop, delegating each iteration to a subagent, until all issues are resolved.\\n\\nExamples:\\n\\n- User: \"Fix all the quality issues\"\\n Assistant: \"I'll launch the quality-fixer agent to iteratively fix all linting, formatting, and quality issues.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- User: \"Run quality checks and fix everything\"\\n Assistant: \"Let me use the quality-fixer agent to handle that.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- User: \"Make sure the code passes all checks\"\\n Assistant: \"I'll use the quality-fixer agent to ensure all quality checks pass.\"\\n (Uses Task tool to launch quality-fixer agent)\\n\\n- After completing a large refactor or feature implementation:\\n Assistant: \"Now that the changes are complete, let me launch the quality-fixer agent to ensure everything passes quality checks.\"\\n (Uses Task tool to launch quality-fixer agent)" +model: opus +color: red +--- + +You are an elite code quality engineer specializing in automated code quality remediation. Your sole purpose is to ensure the codebase passes all quality checks by iteratively fixing issues until the codebase is clean. + +## Project Context +- This is an Electron + React + TypeScript project using pnpm +- Quality commands: + - `pnpm fix` = runs `pnpm lint:fix && pnpm format` (auto-fixes lint and formatting) + - `pnpm quality` = runs `pnpm check && pnpm format:check && npx knip` (type checking, format verification, unused code detection) +- Path aliases: `@main/*`, `@renderer/*`, `@shared/*`, `@preload/*` + +## Core Process + +You operate in a **loop** where each iteration is delegated to a **subagent** via the Task tool. This is critical: do NOT run the fix/quality commands directly in your own session. Every iteration MUST be dispatched as a subagent. + +### Loop Structure + +**Iteration N (each via a Task tool subagent):** + +1. **Subagent prompt must instruct the subagent to:** + a. Run `pnpm fix` and capture the full output + b. Run `pnpm quality` and capture the full output + c. If `pnpm quality` succeeds (exit code 0, no errors), report SUCCESS + d. If `pnpm quality` fails, analyze the error output carefully and fix all reported issues: + - **TypeScript errors** (`pnpm check`): Fix type errors, missing imports, incorrect types + - **Format issues** (`pnpm format:check`): These should be auto-fixed by `pnpm fix`, but if persistent, manually fix formatting + - **Knip issues** (`npx knip`): Remove unused exports, unused dependencies, unused files, unused types + e. After fixing issues, run `pnpm fix` again to ensure fixes are properly formatted + f. Report back: what was fixed, what errors remain (if any), and whether quality passed + +2. **After receiving the subagent's report:** + - If the subagent reports SUCCESS (all quality checks pass), you are DONE. Report the final status. + - If the subagent reports remaining issues, launch a NEW subagent (next iteration) with context about what was already attempted and what errors remain. + +### Subagent Prompt Template + +When launching each subagent via the Task tool, provide a detailed prompt like this: + +``` +You are fixing code quality issues in this project. This is iteration {N} of the quality fix loop. + +{If iteration > 1: "Previous iteration found these remaining issues: {paste remaining errors}"} + +Steps: +1. Run `pnpm fix` to auto-fix lint and formatting issues. Show the output. +2. Run `pnpm quality` to check for remaining issues. Show the full output. +3. If quality passes with no errors, report "SUCCESS: All quality checks pass." +4. If quality fails, carefully analyze EVERY error and fix them: + - For TypeScript errors: fix the type issues in the relevant files + - For knip (unused code) errors: remove unused exports, imports, dependencies, or files + - For format errors: fix formatting manually if pnpm fix didn't catch it +5. After making fixes, run `pnpm fix` one more time to ensure your changes are properly formatted. +6. Report what you fixed and any remaining errors you could not resolve. + +IMPORTANT: +- Fix ALL issues, not just some of them +- When removing unused exports, check if they're used elsewhere before removing +- For knip unused dependency warnings, remove them from package.json +- For knip unused file warnings, verify the file is truly unused before deleting +- Use path aliases (@main/*, @renderer/*, @shared/*, @preload/*) for any new imports +``` + +### Safety Rules + +1. **Maximum 5 iterations**. If after 5 loops quality still doesn't pass, stop and report the remaining issues to the user with a clear summary of what was fixed and what remains. +2. **Never delete files without verification** — when knip reports unused files, the subagent should verify they're truly unused. +3. **Never remove exports that are used** — when knip reports unused exports, verify they're not imported elsewhere. +4. **Preserve functionality** — fixes should only address quality issues, never change application behavior. +5. **Each subagent gets full context** — always pass remaining errors from the previous iteration to the next subagent so it doesn't repeat failed approaches. + +### Reporting + +After the loop completes (either success or max iterations), provide a summary: +- Total iterations run +- Issues found and fixed (categorized by type: lint, format, types, unused code) +- Final status: PASS or FAIL with remaining issues +- Files modified + +**Update your agent memory** as you discover common quality issues, recurring lint violations, frequently flagged unused exports, and knip patterns in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Common TypeScript errors that recur (e.g., specific type mismatches) +- Files or exports frequently flagged by knip +- Lint rules that frequently need fixing +- Patterns that tend to cause quality check failures diff --git a/.claude/commands/ccc/chatgroup-architecture.md b/.claude/commands/ccc/chatgroup-architecture.md new file mode 100644 index 00000000..3a26b0d4 --- /dev/null +++ b/.claude/commands/ccc/chatgroup-architecture.md @@ -0,0 +1,389 @@ +--- +name: ccc:chatgroup-architecture +description: ChatGroup architecture — how conversation data flows from raw JSONL to rendered chat groups. Use when working on UserGroup, AIGroup, SystemGroup, display items, tool linking, chunks, or the rendering hierarchy. +--- + +# ChatGroup Architecture + +How conversation data flows from raw JSONL messages to rendered chat groups. + +## Core Design Principle + +Chat groups are **independent items in a flat chronological list**, not paired turns. +There is no UserTurn/AITurn pairing — each group stands alone. + +```typescript +// src/renderer/types/groups.ts +export type ChatItem = + | { type: 'user'; group: UserGroup } + | { type: 'system'; group: SystemGroup } + | { type: 'ai'; group: AIGroup } + | { type: 'compact'; group: CompactGroup }; + +export interface SessionConversation { + sessionId: string; + items: ChatItem[]; // Flat chronological list + totalUserGroups: number; + totalSystemGroups: number; + totalAIGroups: number; + totalCompactGroups: number; +} +``` + +## Pipeline Overview + +``` +Raw JSONL messages + → MessageClassifier (classify into user/system/ai/hardNoise) + → ChunkBuilder (buffer AI messages, flush on user/system boundary) + → ChunkFactory (build EnhancedAIChunk with SemanticSteps) + → groupTransformer (chunks → flat ChatItem[] conversation) + → aiGroupEnhancer (AIGroup → EnhancedAIGroup with displayItems, linkedTools, lastOutput) + → React components render +``` + +Primary source files: + +- `src/main/services/parsing/MessageClassifier.ts` +- `src/main/services/analysis/ChunkBuilder.ts` +- `src/main/services/analysis/ChunkFactory.ts` +- `src/renderer/utils/groupTransformer.ts` +- `src/renderer/utils/aiGroupEnhancer.ts` +- `src/renderer/utils/displayItemBuilder.ts` +- `src/renderer/types/groups.ts` +- `src/main/types/chunks.ts` + +## Data Models + +### UserGroup + +```typescript +// src/renderer/types/groups.ts +interface UserGroup { + id: string; + message: ParsedMessage; + timestamp: Date; + content: UserGroupContent; + index: number; // Ordering index within session +} + +interface UserGroupContent { + text?: string; // Plain text (commands removed) + rawText?: string; // Original text + commands: CommandInfo[]; // Extracted /commands + images: ImageData[]; // Attached images + fileReferences: FileReference[]; // @file.ts mentions +} +``` + +Renders right-aligned blue bubble. Contains markdown text, slash commands, images, and file references. + +### AIGroup + +```typescript +interface AIGroup { + id: string; + turnIndex: number; // 0-based (for turn navigation) + startTime: Date; + endTime: Date; + durationMs: number; + steps: SemanticStep[]; // Core semantic steps + tokens: AIGroupTokens; + summary: AIGroupSummary; // For collapsed view + status: AIGroupStatus; // 'complete' | 'interrupted' | 'error' | 'in_progress' + processes: Process[]; // Subagent processes + chunkId: string; + metrics: SessionMetrics; + responses: ParsedMessage[]; // All assistant + internal messages + isOngoing?: boolean; // True for last group in ongoing session +} +``` + +### EnhancedAIGroup + +The renderer enhances `AIGroup` before rendering: + +```typescript +interface EnhancedAIGroup extends AIGroup { + lastOutput: AIGroupLastOutput | null; // Always-visible output + displayItems: AIGroupDisplayItem[]; // Flattened chronological items + linkedTools: Map; // Tool call/result pairs + itemsSummary: string; // "2 thinking, 4 tool calls, 3 subagents" + mainModel: ModelInfo | null; + subagentModels: ModelInfo[]; + claudeMdStats: ClaudeMdStats | null; +} +``` + +Enhancement happens in `src/renderer/utils/aiGroupEnhancer.ts`: + +1. `findLastOutput` — extracts the final visible output (text, tool result, interruption, plan exit, ongoing) +2. `linkToolCallsToResults` — pairs tool calls with their results into `LinkedToolItem` +3. `buildDisplayItems` — flattens steps into chronological `AIGroupDisplayItem[]` +4. `buildSummary` — generates human-readable summary string +5. `extractMainModel` / `extractSubagentModels` — extracts model info + +### AIGroupDisplayItem + +```typescript +type AIGroupDisplayItem = + | { type: 'thinking'; content: string; timestamp: Date; tokenCount?: number } + | { type: 'tool'; tool: LinkedToolItem } + | { type: 'subagent'; subagent: Process } + | { type: 'output'; content: string; timestamp: Date; tokenCount?: number } + | { type: 'slash'; slash: SlashItem } + | { type: 'teammate_message'; teammateMessage: TeammateMessage }; +``` + +Display items are sorted chronologically in `src/renderer/utils/displayItemBuilder.ts`. + +### LinkedToolItem + +```typescript +interface LinkedToolItem { + id: string; + name: string; + input: Record; + callTokens?: number; + result?: { + content: string | unknown[]; + isError: boolean; + toolUseResult?: ToolUseResultData; + tokenCount?: number; + }; + inputPreview: string; // First 100 chars + outputPreview?: string; // First 200 chars + startTime: Date; + endTime?: Date; + durationMs?: number; + isOrphaned: boolean; // No result received + sourceModel?: string; + skillInstructions?: string; // For Skill tool calls + skillInstructionsTokenCount?: number; +} +``` + +### AIGroupLastOutput + +Always-visible output below the AI group header: + +```typescript +interface AIGroupLastOutput { + type: 'text' | 'tool_result' | 'interruption' | 'ongoing' | 'plan_exit'; + text?: string; + toolName?: string; + toolResult?: string; + isError?: boolean; + interruptionMessage?: string; + planContent?: string; + planPreamble?: string; + timestamp: Date; +} +``` + +### SystemGroup + +```typescript +interface SystemGroup { + id: string; + message: ParsedMessage; + timestamp: Date; + commandOutput: string; + commandName?: string; +} +``` + +Renders left-aligned with neutral gray styling. Monospace pre with ANSI escape codes cleaned. + +### CompactGroup + +```typescript +interface CompactGroup { + id: string; + timestamp: Date; + message: ParsedMessage; + tokenDelta?: CompactionTokenDelta; + startingPhaseNumber?: number; +} +``` + +Visual boundary for context compaction events. + +## Backend: Chunk Building + +### Message Classification + +`src/main/services/parsing/MessageClassifier.ts` classifies raw messages into 4 categories: + +| Category | Description | Result | +|----------|-------------|--------| +| `user` | Genuine user input | Creates UserChunk, renders right | +| `system` | Command output | Creates SystemChunk, renders left | +| `ai` | Assistant responses | Buffered into AIChunk, renders left | +| `hardNoise` | System metadata, caveats, reminders | Filtered out entirely | + +### Chunk Building + +`src/main/services/analysis/ChunkBuilder.ts` buffers AI messages and flushes on boundaries: + +``` +for each classified message: + hardNoise → skip + compact → flush AI buffer, emit CompactChunk + user → flush AI buffer, emit UserChunk + system → flush AI buffer, emit SystemChunk + ai → add to AI buffer +``` + +AI buffer is flushed when a non-AI message arrives, producing one `AIChunk` per contiguous run of assistant messages. + +### Semantic Step Extraction + +`src/main/services/analysis/ChunkFactory.ts` enriches AI chunks: + +1. Build tool executions from message content blocks +2. Collect sidechain messages within the time range +3. Link subagent processes to the chunk +4. Extract semantic steps (`SemanticStep[]`) +5. Fill timeline gaps +6. Calculate step context accumulation +7. Build step groups + +```typescript +type SemanticStepType = 'thinking' | 'tool_call' | 'tool_result' | 'subagent' | 'output' | 'interruption'; + +interface SemanticStep { + id: string; + type: SemanticStepType; + startTime: Date; + endTime?: Date; + durationMs: number; + content: { /* type-specific fields */ }; + tokens?: { input: number; output: number; cached?: number }; + context: 'main' | 'subagent'; +} +``` + +## Rendering + +### Component Hierarchy + +``` +ChatHistory + └─ ChatHistoryItem (router) + ├─ UserChatGroup → right-aligned blue bubble + ├─ SystemChatGroup → left-aligned gray block + ├─ AIChatGroup → left-aligned, collapsible + │ ├─ Header (model, summary, tokens, duration, timestamp) + │ ├─ DisplayItemList (when expanded) + │ │ ├─ ThinkingItem + │ │ ├─ LinkedToolItem (Read, Edit, Write, Skill, etc.) + │ │ ├─ SubagentItem (with nested trace) + │ │ ├─ TextItem + │ │ ├─ SlashItem + │ │ └─ TeammateMessageItem + │ └─ LastOutputDisplay (always visible) + └─ CompactBoundary +``` + +Primary render files: + +- `src/renderer/components/chat/ChatHistory.tsx` +- `src/renderer/components/chat/ChatHistoryItem.tsx` +- `src/renderer/components/chat/UserChatGroup.tsx` +- `src/renderer/components/chat/SystemChatGroup.tsx` +- `src/renderer/components/chat/AIChatGroup.tsx` +- `src/renderer/components/chat/DisplayItemList.tsx` +- `src/renderer/components/chat/LastOutputDisplay.tsx` + +### AI Group: Collapsed vs Expanded + +**Collapsed** (default): +- Header: Bot icon, "Claude", model badge, items summary, chevron +- Right side: context badge, token usage, duration, timestamp +- Last output (always visible below header) + +**Expanded**: +- All collapsed content, plus: +- `DisplayItemList` with chronologically ordered items +- Each display item can be individually expanded (nested expansion) + +### Last Output Rendering + +Always visible regardless of expansion state. Renders based on `type`: + +| Type | Rendering | +|------|-----------| +| `text` | Markdown in code-bg rounded block | +| `tool_result` | Tool name + pre-formatted result | +| `interruption` | Warning banner with AlertTriangle | +| `plan_exit` | Plan preamble + plan content in special block | +| `ongoing` | Ongoing session banner (last AI group only) | + +## Per-Tab UI State Isolation + +Each tab maintains **completely independent** expansion state via `tabUISlice.ts`: + +```typescript +interface TabUIState { + expandedAIGroupIds: Set; + expandedDisplayItemIds: Map>; + expandedSubagentTraceIds: Set; + showContextPanel: boolean; + selectedContextPhase: number | null; + savedScrollTop?: number; +} +``` + +Accessed via `useTabUI()` hook which reads `tabId` from `TabUIContext`: + +```typescript +// src/renderer/hooks/useTabUI.tsx +const { isAIGroupExpanded, toggleAIGroupExpansion, expandAIGroup, + getExpandedDisplayItemIds, toggleDisplayItemExpansion, + isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); +``` + +Auto-expansion triggers: +- Error deep linking (contains highlighted error tool) +- Search results (contains search match) + +## Store Integration + +### Session Detail Slice + +`src/renderer/store/slices/sessionDetailSlice.ts` manages the fetch pipeline: + +``` +fetchSessionDetail(projectId, sessionId, tabId?) + → IPC: getSessionDetail (returns chunks + processes) + → transformChunksToConversation (chunks → ChatItem[]) + → processSessionClaudeMd (compute CLAUDE.md stats) + → processSessionContextWithPhases (compute context stats) + → store in global state + per-tab tabSessionData +``` + +Key state: + +| Field | Purpose | +|-------|---------| +| `sessionDetail` | Raw session data from main process | +| `conversation` | Transformed `SessionConversation` | +| `conversationLoading` | True during fetch (causes ChatHistory unmount) | +| `tabSessionData` | Per-tab copies of session data | +| `sessionClaudeMdStats` | CLAUDE.md injection stats per AI group | +| `sessionContextStats` | Context stats per AI group | +| `sessionPhaseInfo` | Phase boundary info | + +### Real-Time Updates + +`refreshSessionInPlace` re-fetches and transforms without setting `conversationLoading: true`, avoiding ChatHistory unmount/remount flicker. + +## Invariants + +1. Chat items are always flat and chronological — no nesting at the conversation level. +2. AI groups are self-contained — all semantic steps, tool links, and display items are computed per group. +3. Display items within an AI group are chronologically sorted. +4. Per-tab UI state is fully isolated — expanding a group in one tab doesn't affect another. +5. Last output is always visible regardless of AI group expansion state. +6. `conversationLoading: true` unmounts ChatHistory — avoid setting it unnecessarily for existing tabs. diff --git a/.claude/commands/ccc/design-system.md b/.claude/commands/ccc/design-system.md new file mode 100644 index 00000000..0f326def --- /dev/null +++ b/.claude/commands/ccc/design-system.md @@ -0,0 +1,356 @@ +--- +name: ccc:design-system +description: Design system and visual language — theming, CSS variables, Tailwind config, component styling patterns, icon usage, animations, and z-index layers. Use when creating or modifying UI components, working with the dark/light theme, or debugging visual issues. +--- + +# Design System & Visual Language + +How the theming, color palette, component patterns, and styling conventions work. + +## Theme Architecture + +Two themes (dark/light) driven by CSS custom properties in `src/renderer/index.css`. +Toggled via `useTheme()` hook which adds/removes `light` class on `document.documentElement`. + +Flash prevention: a script in `index.html` applies the cached theme before React loads. + +### Theme Hook + +```typescript +// src/renderer/hooks/useTheme.ts +const { theme, resolvedTheme, isDark, isLight } = useTheme(); +// theme: 'dark' | 'light' | 'system' +// resolvedTheme: 'dark' | 'light' (after system resolution) +``` + +## Styling Convention + +**Colors**: Always via CSS variables (theme-aware). Use inline `style` or Tailwind classes mapped to variables. +**Layout/spacing**: Tailwind utility classes. +**Icons**: `lucide-react` with `size-*` Tailwind classes. + +```tsx +// Preferred: inline style for theme-aware colors, Tailwind for layout +
+ +
+ +// Also valid: Tailwind classes that reference CSS variables +
+
+``` + +### TypeScript Constants + +`src/renderer/constants/cssVariables.ts` centralizes CSS variable strings: + +```typescript +import { COLOR_TEXT_MUTED, CARD_BG, CARD_BORDER_STYLE } from '@renderer/constants/cssVariables'; + +Muted text +
Card
+``` + +Constants cover: text colors, surfaces, borders, code blocks, diff, cards, tags, prose. + +## CSS Variable Reference + +All defined in `src/renderer/index.css` under `:root` (dark) and `:root.light`. + +### Surfaces + +| Variable | Dark | Light | Usage | +|----------|------|-------|-------| +| `--color-surface` | `#141416` | `#f9f9f7` | Main background | +| `--color-surface-raised` | `#27272a` | `#f0efed` | Elevated surfaces | +| `--color-surface-overlay` | `#27272a` | `#e8e7e4` | Overlays/modals | +| `--color-surface-sidebar` | `#0f0f11` | `#f1f0ee` | Sidebar background | + +### Text + +| Variable | Dark | Light | Usage | +|----------|------|-------|-------| +| `--color-text` | `#fafafa` | `#1c1b19` | Primary text | +| `--color-text-secondary` | `#a1a1aa` | `#4d4b46` | Secondary text | +| `--color-text-muted` | `#71717a` | `#6d6b65` | Muted text | + +### Borders + +| Variable | Dark | Light | +|----------|------|-------| +| `--color-border` | `rgba(255,255,255,0.05)` | `#d5d3cf` | +| `--color-border-subtle` | `rgba(255,255,255,0.05)` | `#e3e1dd` | +| `--color-border-emphasis` | `rgba(255,255,255,0.1)` | `#a8a5a0` | + +### Chat Bubbles + +**User bubble** (right-aligned): +| Variable | Dark | Light | +|----------|------|-------| +| `--chat-user-bg` | `#27272a` | `#eae9e6` | +| `--chat-user-text` | `#a1a1aa` | `#5a5955` | +| `--chat-user-border` | `rgba(255,255,255,0.08)` | `#d5d3cf` | +| `--chat-user-shadow` | `0 1px 0 0 rgba(255,255,255,0.03)` | `0 1px 2px 0 rgba(0,0,0,0.04)` | +| `--chat-user-tag-bg` | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.05)` | +| `--chat-user-tag-text` | `#e4e4e7` | `#3a3935` | +| `--chat-user-tag-border` | `rgba(255,255,255,0.12)` | `rgba(0,0,0,0.08)` | + +**AI message**: +| Variable | Dark | Light | +|----------|------|-------| +| `--chat-ai-border` | `rgba(255,255,255,0.05)` | `#d5d3cf` | +| `--chat-ai-icon` | `#71717a` | `#6d6b65` | + +**System bubble**: +| Variable | Dark | Light | +|----------|------|-------| +| `--chat-system-bg` | `rgba(39,39,42,0.5)` | `#eae9e6` | +| `--chat-system-text` | `#d4d4d8` | `#3a3935` | + +### Code & Syntax + +| Variable | Dark | Light | +|----------|------|-------| +| `--code-bg` | `#1c1c1e` | `#f0efed` | +| `--code-header-bg` | `#1c1c1e` | `#eae9e6` | +| `--code-border` | `rgba(255,255,255,0.1)` | `#d5d3cf` | +| `--code-line-number` | `#52525b` | `#a8a5a0` | +| `--code-filename` | `#60a5fa` | `#2563eb` | +| `--inline-code-bg` | `rgba(255,255,255,0.08)` | `rgba(0,0,0,0.05)` | +| `--inline-code-text` | `#e4e4e7` | `#3a3935` | + +Syntax highlighting: `--syntax-string`, `--syntax-comment`, `--syntax-number`, `--syntax-keyword`, `--syntax-type`, `--syntax-operator`, `--syntax-function`. Dark uses vibrant colors; light uses GitHub-inspired palette. + +### Semantic Blocks + +**Thinking**: Purple tones (`--thinking-bg`, `--thinking-border`, `--thinking-text`) +**Tool call**: Amber tones (`--tool-call-bg`, `--tool-call-border`, `--tool-call-text`) +**Tool result success**: Green tones (`--tool-result-success-bg/border/text`) +**Tool result error**: Red tones (`--tool-result-error-bg/border/text`) +**Output**: Gray tones (`--output-bg`, `--output-border`, `--output-text`) +**Interruption**: Red (`--interruption-bg/border/text`) +**Warning**: Amber (`--warning-bg/border/text`) +**Plan exit**: Green (`--plan-exit-bg/header-bg/border/text`) + +### Diff Viewer + +| Variable | Dark | Light | +|----------|------|-------| +| `--diff-added-bg` | `rgba(34,197,94,0.15)` | `rgba(34,197,94,0.1)` | +| `--diff-added-text` | `#4ade80` | `#166534` | +| `--diff-removed-bg` | `rgba(239,68,68,0.15)` | `rgba(239,68,68,0.1)` | +| `--diff-removed-text` | `#f87171` | `#991b1b` | + +### Cards (Subagents) + +| Variable | Dark | Light | +|----------|------|-------| +| `--card-bg` | `#121212` | `#f9f9f7` | +| `--card-border` | `#27272a` | `#d5d3cf` | +| `--card-header-bg` | `#18181b` | `#f0efed` | +| `--card-header-hover` | `#1f1f23` | `#eae9e6` | +| `--card-icon-muted` | `#52525b` | `#a8a5a0` | +| `--card-separator` | `#3f3f46` | `#d5d3cf` | + +### Badges + +Status badges: `--badge-error-bg/text`, `--badge-warning-bg/text`, `--badge-success-bg/text`, `--badge-info-bg/text`, `--badge-neutral-bg/text`. +Tags: `--tag-bg`, `--tag-text`, `--tag-border`. + +### Search Highlights + +| Variable | Dark | Light | +|----------|------|-------| +| `--highlight-bg` | `rgba(202,138,4,0.7)` | `#facc15` | +| `--highlight-bg-inactive` | `rgba(113,63,18,0.5)` | `#fef08a` | +| `--highlight-ring` | `#facc15` | `#ca8a04` | + +### Scrollbar + +Custom scrollbar styling via `--scrollbar-thumb`, `--scrollbar-thumb-hover`, `--scrollbar-thumb-active`. + +## Tailwind Config + +`tailwind.config.js` maps CSS variables to Tailwind classes: + +```javascript +colors: { + surface: { + DEFAULT: 'var(--color-surface)', + raised: 'var(--color-surface-raised)', + overlay: 'var(--color-surface-overlay)', + sidebar: 'var(--color-surface-sidebar)', + code: 'var(--code-bg)', + }, + border: { + DEFAULT: 'var(--color-border)', + subtle: 'var(--color-border-subtle)', + emphasis: 'var(--color-border-emphasis)', + }, + text: { + DEFAULT: 'var(--color-text)', + secondary: 'var(--color-text-secondary)', + muted: 'var(--color-text-muted)', + }, + semantic: { + success: '#22c55e', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6', + }, + // Backward-compatible alias + 'claude-dark': { bg: 'var(--color-surface)', surface: 'var(--color-surface-raised)', ... }, +} +``` + +Plugin: `@tailwindcss/typography` for markdown prose. + +## Icon Library + +`lucide-react` across 55+ components. + +**Sizes**: `size-3` (tiny), `size-3.5` (small), `size-4` (standard), `size-5` (medium), `size-10` (empty states) + +**Pattern**: `className` for size, `style` for color: +```tsx + + +``` + +**Common icons**: `Bot` (AI), `User` (user), `ChevronRight`/`ChevronDown` (expansion), `Check`/`Copy` (clipboard), `Info` (tooltips), `Loader2` (loading), `Clock` (duration), `Terminal` (traces), `CheckCircle2` (completed). + +## Team Colors + +`src/renderer/constants/teamColors.ts` defines 8 color sets for teammate visualization: + +```typescript +import { getTeamColorSet, TeamColorSet } from '@renderer/constants/teamColors'; + +const colors = getTeamColorSet('blue'); +// colors.border: '#3b82f6' — border accent +// colors.badge: 'rgba(59, 130, 246, 0.15)' — badge background +// colors.text: '#60a5fa' — text color +``` + +Available colors: blue, green, red, yellow, purple, cyan, orange, pink. + +## Component Patterns + +### User Chat Bubble + +Right-aligned with rounded corners, subtle shadow, copy overlay on hover: + +```tsx +
+
+
+ {text} +
+
+
+``` + +### AI Group Header + +Collapsible with model badge, summary, metrics: + +```tsx +
+ + Claude + {model.name} + {itemsSummary} + +
+``` + +### Subagent Card + +Linear-style card with nested expansion: + +```tsx +
+
+ + {/* colored dot + badge + description + metrics */} +
+ {isExpanded &&
{/* content */}
} +
+``` + +### Copy Button (Overlay) + +Gradient-fade overlay that appears on group hover: + +```tsx +
+
+ +
+``` + +### Popover via Portal + +Token usage and context badges use portaled popovers to escape stacking context: + +```tsx +{showPopover && createPortal( +
+ {/* content */} +
, + document.body +)} +``` + +## Animations + +### CSS Keyframes (in `index.css`) + +- `shimmer` — skeleton loading shimmer effect +- `skeleton-fade-in` — staggered fade-in for skeleton cards +- `splash-slide` — splash screen loading bar + +### Tailwind Utilities + +| Class | Usage | +|-------|-------| +| `animate-spin` | Loading spinners (`Loader2`) | +| `animate-ping` | Pulsing dots (ongoing state) | +| `transition-transform` | Chevron rotation | +| `transition-colors` | Hover color changes | +| `transition-opacity` | Copy button fade-in | +| `transition-all duration-300` | Card highlight transitions | +| `duration-[3000ms]` | Highlight ring fade-out | + +## Z-Index Layers + +| Z-Index | Usage | +|---------|-------| +| `z-10` | Copy button overlays, dropdown backdrops | +| `z-20` | Dropdown menus, search bar | +| `z-30` | Pane split drop zones | +| `z-40` | Pane view overlays | +| `z-50` | Context menus, command palette, settings selects | +| `99999` | Portaled popovers (token usage, context badge, metrics pill) | + +## Light Theme Notes + +The light theme uses **warm neutrals** (not pure white/gray): +- Backgrounds: `#f9f9f7`, `#f0efed`, `#eae9e6` (warm off-white) +- Borders: `#d5d3cf`, `#e3e1dd` (warm gray) +- Text: `#1c1b19`, `#4d4b46`, `#6d6b65` (warm dark) +- Syntax highlighting: GitHub-inspired palette + +Body transition: `background-color 0.2s ease, color 0.2s ease` for smooth theme switching. diff --git a/.claude/commands/ccc/explain-visible-context.md b/.claude/commands/ccc/explain-visible-context.md new file mode 100644 index 00000000..35df5c4b --- /dev/null +++ b/.claude/commands/ccc/explain-visible-context.md @@ -0,0 +1,104 @@ +--- +name: ccc:explain-visible-context +description: Explains what "Visible Context" is — the 6 trackable token categories, what falls outside tracking, how it's displayed, and why it matters. Use when someone asks about visible context, token attribution, or context window usage. +--- + +Present the following explanation directly to the user. Output the full content below as your response — do not summarize, ask follow-up questions, or treat this as background context. + +# Visible Context + +## What It Is + +"Visible Context" is the portion of Claude's context window that we can identify and measure. Every time Claude processes a turn, its context window fills with various pieces of information — your messages, file contents, tool outputs, thinking, and more. Visible Context tracks what we **can** attribute to a known source, so you can see where your tokens are going. + +## What We Track (6 Categories) + +### CLAUDE.md Files + +Memory files that Claude loads automatically at the start of every session and after each compaction. These include: + +- **Global** CLAUDE.md (`~/.claude/CLAUDE.md`) — your personal instructions across all projects +- **Project** CLAUDE.md (`.claude/CLAUDE.md` or `CLAUDE.md` at project root) — project-specific instructions +- **Directory** CLAUDE.md — instructions scoped to subdirectories (e.g., `src/renderer/CLAUDE.md`) + +These are injected repeatedly (once per compaction phase), so their token cost accumulates. A 500-token CLAUDE.md file injected across 3 compaction phases costs ~1,500 tokens total. + +### @-Mentioned Files + +Files you reference with `@path/to/file` in your messages. When you mention a file, Claude Code injects the full file contents into the context. Large files consume significant tokens — a 1,000-line source file could use 5,000+ tokens per mention. + +### Tool Outputs + +Results returned from tool executions: file reads (`Read`), command output (`Bash`), search results (`Grep`, `Glob`), and others. Every tool result stays in the context window until compaction. A `Bash` command that prints 500 lines of output or a `Read` of a large file both count here. + +### Thinking + Text Output + +Claude's own output that consumes context: + +- **Extended thinking** — Claude's internal reasoning (when thinking mode is active). This can be substantial for complex tasks. +- **Text output** — Claude's visible responses to you. Longer explanations and code blocks use more tokens. + +### Task Coordination + +Messages and operations from Claude Code's team/orchestration features: + +- `SendMessage` — messages between teammates +- `TaskCreate`, `TaskUpdate`, `TaskList`, `TaskGet` — task management +- `TeamCreate`, `TeamDelete` — team lifecycle + +Each coordination message adds to the context window of the receiving agent. + +### User Messages + +Your actual prompt text for each turn. This includes the raw text you type, but not the system-injected metadata around it. + +## What We Don't Track + +Visible Context does **not** cover everything in Claude's context window. The following are present but not attributable by our tracking: + +- **Claude Code's system prompt** — the base instructions that tell Claude how to behave, use tools, format output, etc. +- **Tool descriptions** — the schema and documentation for each built-in tool (Read, Write, Edit, Bash, Grep, Glob, etc.) +- **MCP tool descriptions** — schemas for any MCP (Model Context Protocol) servers you have connected +- **Custom agent definitions** — instructions from `.claude/agents/` configurations +- **Skill descriptions** — the short descriptions of available skills that Claude sees so it knows what's available (visible via `/context` in Claude Code) +- **Internal system reminders** — `` injections that Claude Code adds for session state, git status, available skills, etc. +- **Conversation structure overhead** — the message formatting, role markers, and protocol framing around each message + +These untracked items form a "base cost" that's always present. You can see what Claude Code injects via the `/context` command in Claude Code itself. + +## How It's Displayed + +### Per-Turn Popover (Context Badge) + +Each AI group in the chat shows a small badge. Hovering reveals what was injected at that specific turn — which CLAUDE.md files, which @-mentioned files, which tool outputs contributed tokens. + +### Token Usage Popover + +The token count next to each AI group has an info icon. Hovering shows the standard input/output/cache breakdown, plus an expandable "Visible Context" section showing the percentage of total tokens attributable to each tracked category. + +### Session Context Panel + +A dedicated panel (toggle via the context badge or header button) that shows the full session-wide view: + +- All tracked injections grouped by category +- Token estimates per injection +- Phase filtering (if compaction events split the session into phases) +- Total visible context as a percentage of total session tokens + +## Compaction Phases + +When Claude's context window fills up, Claude Code compacts the conversation — summarizing older messages to free space. Each compaction creates a new "phase." Visible Context tracks injections per phase because: + +- CLAUDE.md files are re-injected after each compaction +- Previous tool outputs and file contents are summarized away +- The phase selector lets you see what's in context **right now** (current phase) vs. what was present earlier + +## Why Visible Context Matters + +Understanding where tokens go helps you: + +- **Spot expensive injections** — a massive CLAUDE.md file or a frequently-mentioned large file could be using 20%+ of your context +- **Optimize CLAUDE.md** — keep memory files concise; every token is repeated across phases +- **Be strategic with @-mentions** — mentioning a 2,000-line file costs real context space +- **Understand compaction impact** — see how much context resets after compaction +- **Debug unexpected behavior** — if Claude seems to "forget" something, check whether it was compacted away diff --git a/.claude/commands/ccc/markdown-search-logic.md b/.claude/commands/ccc/markdown-search-logic.md new file mode 100644 index 00000000..f0d959a4 --- /dev/null +++ b/.claude/commands/ccc/markdown-search-logic.md @@ -0,0 +1,179 @@ +--- +name: ccc:markdown-search +description: Markdown search logic — how in-session and cross-session search works. Use when working on SearchBar, search highlighting, searchHighlightUtils, markdownTextSearch, or SessionSearcher. +--- + +# Markdown Search Logic + +How in-session and cross-session markdown search works end-to-end. + +## Scope + +Current in-session search intentionally covers: + +- User message markdown text +- AI `lastOutput` text markdown + +Current in-session search intentionally excludes: + +- System items +- Tool result text blocks +- Thinking/subagent/internal display items + +Primary source files: + +- `src/renderer/components/search/SearchBar.tsx` +- `src/renderer/store/slices/conversationSlice.ts` +- `src/renderer/components/chat/ChatHistory.tsx` +- `src/renderer/components/chat/searchHighlightUtils.ts` +- `src/shared/utils/markdownTextSearch.ts` +- `src/main/services/discovery/SessionSearcher.ts` + +## Core Data Model + +`SearchMatch` (renderer store) in `src/renderer/store/types.ts`: + +- `itemId`: chat group id (`user-*`, `ai-*`) +- `itemType`: `user | ai` +- `matchIndexInItem`: 0-based index inside one searchable item +- `globalIndex`: 0-based index across all matches +- `displayItemId`: optional (`lastOutput` for AI output) + +Important distinction: + +- `matchIndexInItem` is local to one item. +- `currentSearchIndex` is global position in the search result list. + +## Pipeline Overview + +### 1) Query input and initial match generation + +`SearchBar` updates the query with tab-scoped conversation data: + +- `setSearchQuery(query, conversation)` in `src/renderer/components/search/SearchBar.tsx` + +`setSearchQuery` in `src/renderer/store/slices/conversationSlice.ts`: + +- Scans conversation items +- Uses `findMarkdownSearchMatches` (shared parser logic) per searchable item +- Builds initial `searchMatches`, `searchResultCount`, `currentSearchIndex` + +### 2) Rendering highlights + +Search highlighting is rendered in markdown component trees through: + +- `createSearchContext(...)` in `src/renderer/components/chat/searchHighlightUtils.ts` +- `highlightSearchInChildren(...)` in `src/renderer/components/chat/searchHighlightUtils.ts` + +Each rendered highlight mark includes: + +- `data-search-item-id` +- `data-search-match-index` +- `data-search-result` (`current` or `match`) + +### 3) Canonicalization to rendered DOM (critical) + +`ChatHistory` collects rendered `` elements in DOM order and calls: + +- `syncSearchMatchesWithRendered(renderedMatches)` in `src/renderer/store/slices/conversationSlice.ts` + +Why this exists: + +- Real UI navigation must match visible marks exactly. +- Parser results can temporarily differ during render timing. +- DOM order is the final source of truth for nth navigation. + +Safety guard: + +- `ChatHistory` delays syncing when a transient empty mark snapshot appears, to avoid wiping results mid-render. + +### 4) Next/prev navigation and scrolling + +`nextSearchResult` / `previousSearchResult` in `src/renderer/store/slices/conversationSlice.ts`: + +- Move `currentSearchIndex` with wrap-around + +`ChatHistory` scroll effect: + +- First tries exact selector: + - `mark[data-search-item-id="..."][data-search-match-index="..."]` +- If missing, falls back to the global nth rendered mark (same `currentSearchIndex`) +- Final fallback walks text nodes under `[data-search-content]` roots + +## Shared Markdown Search Engine + +`src/shared/utils/markdownTextSearch.ts` is used by both renderer and main process: + +- `findMarkdownSearchMatches` +- `countMarkdownSearchMatches` +- `extractMarkdownPlainText` + +Design principle: + +- Search parser mirrors markdown render behavior (remark + gfm + HAST traversal) +- Matching is segment-based (no cross-node match) + +## Cross-Session Search (Command Palette / IPC) + +Main process search path: + +- IPC handler: `src/main/ipc/search.ts` +- Engine: `src/main/services/discovery/SessionSearcher.ts` + +`SessionSearcher` also uses shared markdown search utils, and returns: + +- `groupId` +- `itemType` +- `matchIndexInItem` +- `matchStartOffset` + +These are passed into tab navigation context so opening a search result can jump to the exact in-session match. + +## Invariants to Keep + +When changing markdown/search code, keep these invariants: + +1. Parser and renderer must agree on searchable text boundaries. +2. `matchIndexInItem` semantics must stay stable per item. +3. `currentSearchIndex` must represent the global nth visible match. +4. `searchResultCount` must reflect actual rendered match count after canonicalization. +5. Search source scope must be explicit (no accidental inclusion of hidden/internal text). + +## If You Add New Searchable Markdown Surfaces + +If you make a new markdown surface searchable: + +1. Ensure it uses search context + `highlightSearchInChildren`. +2. Ensure emitted marks include `data-search-item-id` and `data-search-match-index`. +3. Ensure the content is included in `setSearchQuery` source scanning. +4. Ensure parser collection logic in `src/shared/utils/markdownTextSearch.ts` still mirrors render behavior. +5. Add/adjust alignment tests. + +## Debug Playbook + +Enable debug logs: + +- `localStorage.setItem('search-debug', '1')` + +Useful logs: + +- `[search] query` / `[search] sample` from `setSearchQuery` +- `[search] sync-rendered` from DOM canonicalization +- `[search] next` / `[search] prev` navigation logs + +Quick checks when behavior is off: + +1. Compare `searchResultCount` vs number of rendered marks. +2. Verify `currentSearchIndex` increments exactly once per click. +3. Check whether exact mark selector exists for current match. +4. Confirm the active tab conversation is the same one used for `setSearchQuery`. +5. Confirm virtualization is disabled during active search. + +## Tests + +Main tests relevant to this logic: + +- `test/shared/utils/markdownTextSearch.test.ts` +- `test/shared/utils/markdownSearchRendererAlignment.test.ts` + +The alignment test ensures parser match indexes and rendered mark indexes stay identical across representative markdown cases. diff --git a/.claude/commands/ccc/navigation-scroll.md b/.claude/commands/ccc/navigation-scroll.md new file mode 100644 index 00000000..5eb3563b --- /dev/null +++ b/.claude/commands/ccc/navigation-scroll.md @@ -0,0 +1,232 @@ +--- +name: ccc:navigation-scroll +description: Navigation and scroll orchestration — tab navigation, error highlights, search scrolling, auto-scroll coordination, and common bug patterns. Use when working on useTabNavigationController, scroll restore, or navigation requests. +--- + +# Navigation & Scroll Orchestration + +How tab navigation (error highlights, search scrolling, auto-scroll) works end-to-end. + +## Architecture + +### Navigation Request Model (Nonce-Based) + +```typescript +// src/renderer/types/tabs.ts +interface TabNavigationRequest { + id: string; // crypto.randomUUID() — fresh nonce per click + kind: 'error' | 'search' | 'autoBottom'; + highlight: 'red' | 'yellow' | 'none'; + payload: ErrorNavigationPayload | SearchNavigationPayload | {}; + source: 'notification' | 'triggerPreview' | 'commandPalette' | 'sessionOpen'; +} + +// Stored on Tab: +interface Tab { + pendingNavigation?: TabNavigationRequest; // Set by enqueue, cleared by consume + lastConsumedNavigationId?: string; // Tracks last processed request +} +``` + +### Store Actions (tabSlice.ts) + +| Action | Purpose | +|--------|---------| +| `enqueueTabNavigation(tabId, request)` | Set `pendingNavigation` on a tab | +| `consumeTabNavigation(tabId, requestId)` | Clear `pendingNavigation`, record `lastConsumedNavigationId` | + +### Navigation Sources + +| Source | Slice | Creates | +|--------|-------|---------| +| Notification click / test trigger | `notificationSlice.navigateToError()` | `ErrorNavigationRequest` (red) | +| CommandPalette search result | `tabSlice.navigateToSession()` | `SearchNavigationRequest` (yellow) | + +### Controller Hook: `useTabNavigationController` + +**Location:** `src/renderer/hooks/useTabNavigationController.ts` + +Phase state machine: +``` +idle → pending → expanding → scrolling → highlighting → complete → idle +``` + +Key behaviors: +- **Active-tab-only:** Ignores `!isActiveTab` to prevent cross-tab races +- **Nonce dedup:** `activeRequestIdRef.current === pendingNavigation.id` prevents reprocessing +- **Failure debounce:** 500ms cooldown after failed navigation (`lastFailureAtRef`) +- **Abort support:** New navigation aborts in-progress one via `AbortController` +- **Highlight-first:** Highlight is set BEFORE scroll (best-effort scroll, guaranteed highlight) + +### Scroll Precedence (ChatHistory.tsx) + +Three scroll systems compete — navigation wins: + +| System | Guard | Priority | +|--------|-------|----------| +| Navigation scroll | Controller's `executeNavigation` | Highest | +| Scroll restore (tab switch) | `!shouldDisableAutoScroll` | Medium | +| Auto-scroll to bottom | `disabled: shouldDisableAutoScroll` | Lowest | + +`shouldDisableAutoScroll` is `true` during ANY navigation phase or when `pendingNavigation` exists. + +## Key Files + +| File | Role | +|------|------| +| `src/renderer/hooks/useTabNavigationController.ts` | Unified navigation controller | +| `src/renderer/hooks/navigation/utils.ts` | Shared helpers (scroll calc, element lookup, visibility) | +| `src/renderer/components/chat/ChatHistory.tsx` | Scroll restore + auto-scroll coordination | +| `src/renderer/store/slices/tabSlice.ts` | `enqueueTabNavigation`, `consumeTabNavigation`, `navigateToSession` | +| `src/renderer/store/slices/notificationSlice.ts` | `navigateToError` | +| `src/renderer/store/slices/sessionDetailSlice.ts` | `fetchSessionDetail` (sets `conversationLoading`) | +| `src/renderer/types/tabs.ts` | `TabNavigationRequest` types + factory helpers | + +## Common Bug Patterns + +### 1. Scroll Restore Overrides Navigation + +**Symptom:** Scrolls to target, then snaps back to top/previous position. + +**Root cause:** The scroll restore effect fires after `consumeTabNavigation` clears `pendingNavigation`. If the guard only checks `!pendingNavigation`, it triggers while navigation highlight is still active. + +**Fix pattern:** Guard scroll restore with `!shouldDisableAutoScroll` instead of `!pendingNavigation`. The controller's `shouldDisableAutoScroll` covers the FULL lifecycle (pending → complete), not just while `pendingNavigation` exists. + +**Additional:** Save scroll position when `shouldDisableAutoScroll` transitions true→false (navigation completed) to prevent stale `savedScrollTop` from being restored later. + +```typescript +// ChatHistory.tsx — scroll restore effect +useEffect(() => { + const wasDisabled = prevShouldDisableRef.current; + prevShouldDisableRef.current = shouldDisableAutoScroll; + // Navigation just completed — save current position, skip restore + if (wasDisabled && !shouldDisableAutoScroll && scrollContainerRef.current) { + saveScrollPosition(scrollContainerRef.current.scrollTop); + return; + } + if (isThisTabActive && savedScrollTop !== undefined && !conversationLoading && !shouldDisableAutoScroll) { + // ... restore logic + } +}, [isThisTabActive, savedScrollTop, conversationLoading, shouldDisableAutoScroll, saveScrollPosition]); +``` + +### 2. Redundant `fetchSessionDetail` Unmounts ChatHistory + +**Symptom:** Navigation doesn't scroll at all, or session "reloads" unnecessarily. + +**Root cause:** `navigateToSession` or `navigateToError` calls `fetchSessionDetail` even when the session is already loaded in an existing tab. This sets `conversationLoading: true`, causing ChatHistory to unmount (show loading spinner) and remount — losing scroll container and controller state. + +**Fix pattern:** Only call `fetchSessionDetail` for NEW tabs. For existing tabs, `setActiveTab` already handles the fetch when `sessionChanged` is true. + +```typescript +// tabSlice.ts — navigateToSession +if (existingTab) { + state.setActiveTab(existingTab.id); + // NO fetchSessionDetail — setActiveTab handles it +} else { + state.openTab({ ... }); + void state.fetchSessionDetail(projectId, sessionId); // Only for new tabs +} +``` + +### 3. Highlight Not Showing (Strict Post-Scroll Gates) + +**Symptom:** Scrolls to correct location but no red/yellow highlight ring appears. + +**Root cause:** `executeErrorNavigation` / `executeSearchNavigation` returns `false` after scroll due to strict gates: +- `userInterrupted` — any accidental wheel/touch event during smooth scroll +- `isElementVisibleInContainer` — element partially off-screen after centering (tall elements) +- Element not found within 600ms timeout + +When `success = false`, `executeNavigation` clears all highlight state (`setHighlightedGroupId(null)`). + +**Fix pattern:** Set highlight BEFORE scroll attempt. Make scroll best-effort. Always return `true` once target group is found. + +```typescript +// In executeErrorNavigation: +// 1. Find target group +// 2. Expand group +// 3. SET HIGHLIGHT HERE (before scroll) +setHighlightedGroupId(targetGroupId); +setIsSearchHighlight(false); +if (toolUseId) setCurrentToolUseId(toolUseId); +// 4. Best-effort scroll (don't gate highlight on scroll outcome) +// 5. Return true (highlight already visible) +``` + +### 4. Test Trigger Shows No Highlight + +**Symptom:** "Test trigger" creates an error with a timestamp that doesn't match any AI group. Navigation scrolls but nothing is highlighted. + +**Root cause:** Same as #3. The error timestamp doesn't match, so `findAIGroupByTimestamp` falls back to closest/last group. But post-scroll gates prevent the highlight from being applied. + +**Fix:** Same as #3 — highlight-first pattern. + +### 5. `conversationLoading` Race During Tab Switch + +**Symptom:** Navigation queued on tab, but when switching to that tab, loading state causes ChatHistory unmount. Navigation controller state is lost. + +**Root cause:** `fetchSessionDetail` immediately sets `conversationLoading: true`. ChatHistory returns ``, unmounting the controller hook. + +**Recovery:** The controller is designed to survive remount — when ChatHistory remounts with `pendingNavigation` still set and `conversationLoading: false`, the detection effect starts fresh navigation. BUT scroll restore can race with it (see #1). + +## Debugging Checklist + +When navigation isn't working: + +1. **Check `pendingNavigation` exists on the tab** — is `enqueueTabNavigation` called? +2. **Check `isActiveTab` is true** — controller ignores inactive tabs +3. **Check `conversationLoading`** — if true, controller waits in `pending` phase +4. **Check `conversation` exists** — if null, controller waits +5. **Check timestamp matching** — does `findAIGroupByTimestamp` find the right group? +6. **Check element refs** — are `aiGroupRefs` / `chatItemRefs` populated? +7. **Check `shouldDisableAutoScroll`** — is scroll restore racing with navigation? +8. **Check for double `fetchSessionDetail`** — is ChatHistory unmounting unnecessarily? +9. **Check `phase` progression** — is it stuck in `pending` or failing at `scrolling`? + +## Navigation Helper Functions (navigation/utils.ts) + +| Function | Purpose | +|----------|---------| +| `findAIGroupByTimestamp(items, timestamp)` | Find AI group containing/closest to timestamp | +| `findChatItemByTimestamp(items, timestamp)` | Find any chat item by timestamp | +| `findAIGroupBySubagentId(items, subagentId)` | Find AI group by subagent ID | +| `calculateCenteredScrollTop(element, container, offset)` | Calculate scroll position to center element | +| `waitForElementStability(element, timeout, stableFrames)` | Wait for element size to stop changing | +| `waitForScrollEnd(container, timeout)` | Wait for smooth scroll to finish | +| `isElementVisibleInContainer(element, container, offset)` | Check if element is in viewport | +| `findCurrentSearchResultInContainer(container)` | Find `[data-search-result="current"]` element | + +## Factory Helpers (tabs.ts) + +```typescript +createErrorNavigationRequest({ errorId, errorTimestamp, toolUseId, lineNumber }) +createSearchNavigationRequest({ query, messageTimestamp, matchedText }) +isErrorPayload(request) // type guard +isSearchPayload(request) // type guard +``` + +## Tests + +Related test files: +- `test/renderer/store/tabSlice.test.ts` — `enqueueTabNavigation` / `consumeTabNavigation` +- `test/renderer/store/notificationSlice.test.ts` — `navigateToError` behavior +- `test/renderer/hooks/navigationUtils.test.ts` — Navigation utility functions +- `test/renderer/hooks/useSearchContextNavigation.test.ts` — Search result finding + +Test patterns: +```typescript +// Mock crypto for predictable nonces +vi.stubGlobal('crypto', { randomUUID: () => `test-uuid-${++counter}` }); + +// Verify navigation request shape +expect(tab.pendingNavigation?.kind).toBe('error'); +expect(tab.pendingNavigation?.highlight).toBe('red'); + +// Verify nonce uniqueness on repeated clicks +store.getState().navigateToError(error); +const firstId = store.getState().openTabs[0].pendingNavigation?.id; +store.getState().navigateToError(error); +const secondId = store.getState().openTabs[0].pendingNavigation?.id; +expect(firstId).not.toBe(secondId); +``` diff --git a/.claude/rules/react.md b/.claude/rules/react.md new file mode 100644 index 00000000..ba893c65 --- /dev/null +++ b/.claude/rules/react.md @@ -0,0 +1,50 @@ +--- +globs: ["src/renderer/**/*.tsx"] +--- + +# React Conventions + +## Component Structure +- Components in `src/renderer/components/` organized by feature +- One component per file, PascalCase naming +- Colocate related hooks and utilities + +## State Management (Zustand) +```typescript +// Slices pattern +projects: Project[] +selectedProjectId: string | null +projectsLoading: boolean +projectsError: string | null +``` + +Each domain slice includes: +- Data array or object +- Selected/active item ID +- Loading state +- Error state + +## Hooks +- Custom hooks in `src/renderer/hooks/` +- Prefix with `use`: `useAutoScrollBottom`, `useTheme` +- Keep hooks focused and composable + +## Component Organization +``` +components/ +├── chat/ # Chat display, items, viewers, SessionContextPanel +├── common/ # Shared components (badges, token display) +├── dashboard/ # Dashboard views +├── layout/ # Layout components (headers, shells) +├── notifications/ # Notification panels and badges +├── search/ # Search UI and results +├── settings/ # Settings pages and controls +│ ├── components/ # Reusable setting controls +│ ├── hooks/ # Settings-specific hooks +│ ├── sections/ # Setting sections +│ └── NotificationTriggerSettings/ # Trigger config UI +└── sidebar/ # Sidebar navigation +``` + +## Contexts +- `contexts/TabUIContext.tsx` - Per-tab UI state isolation diff --git a/.claude/rules/tailwind.md b/.claude/rules/tailwind.md new file mode 100644 index 00000000..387a353c --- /dev/null +++ b/.claude/rules/tailwind.md @@ -0,0 +1,61 @@ +--- +globs: ["**/*.css", "src/renderer/**/*.tsx"] +--- + +# Tailwind CSS Conventions + +## Theme Architecture +Uses CSS custom properties for theme-aware colors defined in `src/renderer/index.css`. + +### Core Surface Colors +```css +--color-surface: #141416 /* Main background */ +--color-surface-raised: #27272a /* Elevated surfaces */ +--color-surface-overlay: #27272a /* Overlays/modals */ +--color-surface-sidebar: #0f0f11 /* Sidebar background */ +``` + +### Border Colors +```css +--color-border: rgba(255, 255, 255, 0.05) +--color-border-subtle: rgba(255, 255, 255, 0.05) +--color-border-emphasis: rgba(255, 255, 255, 0.1) +``` + +### Text Colors +```css +--color-text: #fafafa /* Primary text */ +--color-text-secondary: #a1a1aa /* Secondary text */ +--color-text-muted: #71717a /* Muted text */ +``` + +## Tailwind Usage +Use theme-aware classes that reference CSS variables: +```tsx +// Preferred - uses CSS variables for theme support +
+
+ +// Also available via claude-dark namespace +
+``` + +## Additional CSS Variable Categories +- Chat bubbles: `--chat-user-*`, `--chat-ai-*`, `--chat-system-*` +- Code blocks: `--code-*`, `--syntax-*`, `--inline-code-*` +- Diff viewer: `--diff-added-*`, `--diff-removed-*` +- Tool blocks: `--tool-call-*`, `--tool-result-*` +- Tool items: `--tool-item-name`, `--tool-item-summary`, `--tool-item-muted`, `--tool-item-hover-bg` +- Badges: `--badge-*`, `--tag-*` +- Search: `--highlight-*` +- Scrollbar: `--scrollbar-thumb`, `--scrollbar-thumb-hover`, `--scrollbar-thumb-active` +- Prose/Markdown: `--prose-heading`, `--prose-body`, `--prose-link`, `--prose-code-*`, `--prose-pre-*` +- Thinking blocks: `--thinking-bg`, `--thinking-border`, `--thinking-text`, `--thinking-content-*` +- Output blocks: `--output-bg`, `--output-border`, `--output-text`, `--output-content-border` +- Cards/Subagents: `--card-bg`, `--card-border`, `--card-header-*`, `--card-icon-muted`, `--card-separator` +- Highlights: `--skill-highlight-*`, `--path-highlight-*` +- UI elements: `--interruption-*`, `--warning-*`, `--plan-exit-*`, `--error-highlight-*`, `--kbd-*`, `--context-btn-*` + +## Dark/Light Theme +Both themes supported via `:root` and `:root.light` in index.css. +Toggle via `useTheme` hook which adds/removes `light` class on root. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 00000000..ed92f71b --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,76 @@ +--- +globs: ["test/**/*", "**/*.test.ts", "**/*.spec.ts"] +--- + +# Testing Conventions + +## Test Framework +Uses Vitest with `happy-dom` environment. Config in `vitest.config.ts`. + +## Test Commands +```bash +pnpm test # Run all vitest tests +pnpm test:watch # Watch mode +pnpm test:coverage # Coverage report +pnpm test:coverage:critical # Critical path coverage +pnpm test:chunks # Chunk building tests +pnpm test:semantic # Semantic step extraction +pnpm test:noise # Noise filtering tests +pnpm test:task-filtering # Task tool filtering +``` + +## Test Structure +``` +test/ +├── main/ +│ ├── ipc/ # IPC handler tests +│ │ ├── configValidation.test.ts +│ │ └── guards.test.ts +│ ├── services/ # Service tests +│ │ ├── analysis/ (ChunkBuilder) +│ │ ├── discovery/ (ProjectPathResolver, SessionSearcher) +│ │ ├── infrastructure/ (FileWatcher) +│ │ └── parsing/ (MessageClassifier, SessionParser) +│ └── utils/ # Main process utilities +│ ├── jsonl.test.ts +│ ├── pathDecoder.test.ts +│ ├── pathValidation.test.ts +│ ├── regexValidation.test.ts +│ └── tokenizer.test.ts +├── renderer/ +│ ├── hooks/ # Hook tests +│ │ ├── navigationUtils.test.ts +│ │ ├── useAutoScrollBottom.test.ts +│ │ ├── useSearchContextNavigation.test.ts +│ │ └── useVisibleAIGroup.test.ts +│ ├── store/ # Zustand store slices +│ │ ├── notificationSlice.test.ts +│ │ ├── paneSlice.test.ts +│ │ ├── pathResolution.test.ts +│ │ ├── sessionSlice.test.ts +│ │ ├── tabSlice.test.ts +│ │ └── tabUISlice.test.ts +│ └── utils/ # Renderer utilities +│ ├── claudeMdTracker.test.ts +│ ├── dateGrouping.test.ts +│ ├── formatters.test.ts +│ └── pathUtils.test.ts +├── shared/ +│ └── utils/ # Shared utilities +│ ├── markdownSearchRendererAlignment.test.ts +│ ├── markdownTextSearch.test.ts +│ ├── modelParser.test.ts +│ └── tokenFormatting.test.ts +├── mocks/ # Test fixtures and mocks +└── setup.ts # Test setup/config +``` + +## Files to Test After Changes +- `services/analysis/ChunkBuilder.ts` - Chunk building logic +- `services/parsing/SessionParser.ts` - JSONL parsing +- `services/parsing/MessageClassifier.ts` - Message classification +- Store slices in `src/renderer/store/slices/` +- Utility functions in `*/utils/` + +## Test Data +Test fixtures use real JSONL session data from `~/.claude/projects/`. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..bf6eae03 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,38 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // empty' | { read f; for p in pnpm-lock.yaml .env dist/ dist-electron/ node_modules/; do if [ -n \"$f\" ] && echo \"$f\" | grep -q \"$p\"; then echo \"Blocked: $f matches protected pattern '$p'\" >&2; exit 2; fi; done; exit 0; }" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // .tool_input.notebook_path // empty' | { read file_path; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx)$'; then pnpm eslint --fix \"$file_path\" 2>/dev/null || true; fi; if [ -n \"$file_path\" ] && echo \"$file_path\" | grep -qE '\\.(ts|tsx|js|jsx|json|css)$'; then pnpm prettier --write \"$file_path\" 2>/dev/null || true; fi; }", + "timeout": 30 + } + ] + } + ], + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "echo '[Post-compaction context reminder]\n- Package manager: pnpm only (not npm/yarn)\n- Path aliases: @main/*, @renderer/*, @shared/*, @preload/*\n- Electron 3-process: main/ (Node), preload/ (bridge), renderer/ (React), shared/ (cross-process)\n- isMeta: false = real user message, true = internal/system message\n- Chunk types: UserChunk, AIChunk, SystemChunk, CompactChunk\n- State: Zustand slices pattern (data, selectedId, loading, error)\n- Styling: Tailwind with CSS variables (bg-surface, text-text, border-border)\n- Naming: PascalCase services/components, camelCase utils, UPPER_SNAKE constants\n- Barrel exports: import from domain index.ts\n- New IPC: channel in preload/constants → handler in main/ipc → method in preload/index.ts\n- PostToolUse hook auto-runs eslint+prettier on edited files\n- PreToolUse hook blocks edits to pnpm-lock.yaml, .env, dist/, node_modules/'" + } + ] + } + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d77093c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*.{ts,tsx,js,jsx,json,md,yml,yaml}] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..5cc3daba --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-electron/ +out/ +release/ +coverage/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# TypeScript +*.tsbuildinfo + +.pnpm-store/ +package-lock.json +notification_example/ +temp/ +.claude/*.local.json +.claude/agent-memory/* + + +eslint-fix/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..209e3ef4 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..316035f4 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,25 @@ +# Build outputs +dist/ +dist-electron/ +build/ +out/ + +# Dependencies +node_modules/ +pnpm-lock.yaml + +# Generated files +*.min.js +*.min.css + +# Config files that shouldn't be formatted +*.config.js +*.config.ts + +# IDE +.idea/ +.vscode/ + +# Misc +.DS_Store +*.log diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..c96ec48e --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "jsxSingleQuote": false, + "bracketSameLine": false, + "plugins": ["prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": ["*.json", "*.jsonc"], + "options": { + "trailingComma": "none" + } + }, + { + "files": "*.md", + "options": { + "proseWrap": "preserve" + } + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0e741060 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on Keep a Changelog and this project follows Semantic Versioning. + +## [Unreleased] + +### Added +- Strict IPC input validation guards for project/session/subagent/search limits. +- `get-waterfall-data` IPC endpoint implementation. +- Cross-platform path normalization in renderer path resolvers. +- `onTodoChange` preload API event bridge. +- CI workflow for macOS/Windows (typecheck, lint, test, build). +- Release workflow for signed package builds. +- Open-source governance docs (`LICENSE`, `CONTRIBUTING`, `CODE_OF_CONDUCT`, `SECURITY`). + +### Changed +- `readMentionedFile` preload API signature now requires `projectRoot`. +- Notification update event contract standardized to `{ total, unreadCount }`. +- Session pagination uses cached displayable-content detection for performance. +- File watcher error detection optimized for append-only updates. + +### Fixed +- Lint violations in navigation and markdown/subagent UI components. +- Test mock drift causing runtime errors in test output. +- Multiple Windows path handling edge cases. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..ba799cf4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,158 @@ +# Claude Code Context + +Electron app that visualizes Claude Code session execution + +## Tech Stack +Electron 28.x, React 18.x, TypeScript 5.x, Tailwind CSS 3.x, Zustand 4.x + +## Commands +Always use pnpm (not npm/yarn) for this project. + +- `pnpm install` - Install dependencies +- `pnpm dev` - Dev server with hot reload +- `pnpm build` - Production build +- `pnpm typecheck` - Type checking +- `pnpm lint:fix` - Lint and auto-fix +- `pnpm format` - Format code +- `pnpm test` - Run all vitest tests +- `pnpm test:watch` - Watch mode +- `pnpm test:coverage` - Coverage report +- `pnpm test:coverage:critical` - Critical path coverage +- `pnpm test:chunks` - Chunk building tests +- `pnpm test:semantic` - Semantic step extraction tests +- `pnpm test:noise` - Noise filtering tests +- `pnpm test:task-filtering` - Task tool filtering tests + +## Path Aliases +Use path aliases for imports: +- `@main/*` → `src/main/*` +- `@renderer/*` → `src/renderer/*` +- `@shared/*` → `src/shared/*` +- `@preload/*` → `src/preload/*` + +## Data Sources +~/.claude/projects/{encoded-path}/*.jsonl - Session files +~/.claude/todos/{sessionId}.json - Todo data + +Path encoding: `/Users/name/project` → `-Users-name-project` + +## Critical Concepts + +### isMeta Flag +- `isMeta: false` = Real user message (creates new chunks) +- `isMeta: true` = Internal message (tool results, system-generated) + +### Chunk Structure +Independent chunk types for timeline visualization: +- **UserChunk**: Single user message with metrics +- **AIChunk**: All assistant responses with tool executions and spawned subagents +- **SystemChunk**: Command output/system messages +- **CompactChunk**: System metadata/structural messages + +Each chunk has: timestamp, duration, metrics (tokens, cost, tools) + +### Task/Subagent Filtering +Task tool_use blocks are filtered when subagent exists +Keep orphaned Task calls (no matching subagent) for visibility. + +### Agent Teams +Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a team. +- **Process.team?** `{ teamName, memberName, memberColor }` — enriched by SubagentResolver from Task call inputs and `teammate_spawned` tool results +- **Teammate messages** arrive as `content` in user messages (isMeta: false). Detected by `isParsedTeammateMessage()` — excluded from UserChunks, rendered as `TeammateMessageItem` cards +- **Session ongoing detection** treats `SendMessage` shutdown_response (approve: true) and its tool_result as ending events, not ongoing activity +- **Display summary** counts distinct teammates (by name) separately from regular subagents +- **Team tools**: TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamDelete — have readable summaries in `toolSummaryHelpers.ts` + +### Visible Context Tracking +Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field): + +| Category | Type | Source | +|----------|------|--------| +| `claude-md` | `ClaudeMdContextInjection` | CLAUDE.md files (global, project, directory) | +| `mentioned-file` | `MentionedFileInjection` | User @-mentioned files | +| `tool-output` | `ToolOutputInjection` | Tool execution results (Read, Bash, etc.) | +| `thinking-text` | `ThinkingTextInjection` | Extended thinking + text output tokens | +| `team-coordination` | `TeamCoordinationInjection` | Team tools (SendMessage, TaskCreate, etc.) | +| `user-message` | `UserMessageInjection` | User prompt text per turn | + +- **Types**: `src/renderer/types/contextInjection.ts` — `ContextInjection` union, `ContextStats`, `TokensByCategory` +- **Tracker**: `src/renderer/utils/contextTracker.ts` — `computeContextStats()`, `processSessionContextWithPhases()` +- **Context Phases**: Compaction events reset accumulated injections, tracked via `ContextPhaseInfo` +- **Display surfaces**: `ContextBadge` (per-turn popover), `TokenUsageDisplay` (hover breakdown), `SessionContextPanel` (full panel) + +## Error Handling +- Main: try/catch, console.error, return safe defaults +- Renderer: error state in Zustand store +- IPC: parameter validation, graceful degradation + +## Performance +- LRU Cache: Avoid re-parsing large JSONL files +- Streaming JSONL: Line-by-line processing +- Virtual Scrolling: For large session/message lists +- Debounced File Watching: 100ms debounce + +## Troubleshooting + +### Build Issues +```bash +rm -rf dist dist-electron node_modules +pnpm install +pnpm build +``` + +### Type Errors +```bash +pnpm typecheck +``` + +### Test Failures +Check for changes in message parsing or chunk building logic. + +## TypeScript Conventions + +### Naming +| Category | Convention | Example | +|----------|------------|---------| +| Services/Components | PascalCase | `ProjectScanner.ts` | +| Utilities | camelCase | `pathDecoder.ts` | +| Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` | +| Type Guards | isXxx | `isRealUserMessage()` | +| Builders | buildXxx | `buildChunks()` | +| Getters | getXxx | `getResponses()` | + +### Type Guards +```typescript +// Message type guards (src/main/types/messages.ts) +isParsedRealUserMessage(msg) // isMeta: false, string content +isParsedInternalUserMessage(msg) // isMeta: true, array content +isAssistantMessage(msg) // type: "assistant" + +// Chunk type guards +isUserChunk(chunk) // type: "user" +isAIChunk(chunk) // type: "ai" +isSystemChunk(chunk) // type: "system" +isCompactChunk(chunk) // type: "compact" + +// Context injection type guards (component-scoped in ContextBadge.tsx, not exported) +isClaudeMdInjection(inj) // category: "claude-md" +isMentionedFileInjection(inj) // category: "mentioned-file" +isToolOutputInjection(inj) // category: "tool-output" +isThinkingTextInjection(inj) // category: "thinking-text" +isTeamCoordinationInjection(inj) // category: "team-coordination" +isUserMessageInjection(inj) // category: "user-message" +``` + +### Barrel Exports +`src/main/services/` and its domain subdirectories have barrel exports via index.ts: +```typescript +// Preferred +import { ChunkBuilder, ProjectScanner } from './services'; +// Also valid +import { ChunkBuilder } from './services/analysis'; +``` +Note: renderer utils/hooks/types do NOT have barrel exports — import directly from files. + +### Import Order +1. External packages +2. Path aliases (@main, @renderer, @shared) +3. Relative imports diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..81ecb971 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,19 @@ +# Code of Conduct + +This project follows the Contributor Covenant Code of Conduct. + +## Our Standards +- Be respectful and constructive. +- Assume good intent and discuss ideas, not people. +- Give actionable feedback and accept feedback gracefully. + +## Unacceptable Behavior +- Harassment, discrimination, or personal attacks. +- Trolling, insulting language, or sustained disruption. +- Publishing private information without explicit permission. + +## Enforcement +Project maintainers are responsible for clarifying and enforcing this code of conduct and may take corrective action for unacceptable behavior. + +## Reporting +Please report incidents privately to the maintainers through the security/contact channel listed in `SECURITY.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cb132152 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing + +Thanks for contributing to Claude Code Context. + +## Prerequisites +- Node.js 20+ +- pnpm 10+ +- macOS or Windows + +## Setup +```bash +pnpm install +pnpm dev +``` + +## Quality Gates +Before opening a PR, run: +```bash +pnpm typecheck +pnpm lint +pnpm test +pnpm build +``` + +## Pull Request Guidelines +- Keep changes focused and small. +- Add/adjust tests for behavior changes. +- Update docs when changing public behavior or setup. +- Use clear PR titles and include a short validation checklist. + +## Commit Style +- Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`). +- Include rationale in commit body for non-trivial changes. + +## Reporting Bugs +Please include: +- OS version +- app version / commit hash +- repro steps +- expected vs actual behavior +- logs/screenshots when possible diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..578aca4e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Claude Code Context contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2f67f067 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Claude Code Context + +Desktop app for exploring Claude Code session context usage. + +It helps you inspect session timelines, search across sessions, debug context injections (`CLAUDE.md`, mentioned files, tool outputs), and configure notification triggers. + +## Features +- Repository/worktree-aware project grouping +- Session search with context snippets +- Structured conversation/chunk parsing from Claude JSONL logs +- Context usage inspection (CLAUDE.md + mentioned files + tool output) +- Native notifications with configurable trigger rules +- Real-time updates from Claude session/todo file changes + +## Tech Stack +- Electron + electron-vite +- React + TypeScript + Zustand +- Tailwind CSS +- Vitest + ESLint + +## Requirements +- Node.js 20+ +- pnpm 10+ +- macOS or Windows + +## Getting Started +```bash +pnpm install +pnpm dev +``` + +## Data Source +The app reads Claude local data from: +- `~/.claude/projects/` +- `~/.claude/todos/` + +## Scripts +```bash +pnpm dev # Run app in development +pnpm typecheck # TypeScript checks +pnpm lint # ESLint (no auto-fix) +pnpm test # Unit tests +pnpm build # Electron/Vite production build +pnpm check # Full local quality gate +pnpm dist:mac # Package macOS app (electron-builder) +pnpm dist:win # Package Windows app (electron-builder) +pnpm dist # Package both targets +``` + +## Packaging and Release +- Packaging is configured with `electron-builder.yml`. +- CI workflow (`.github/workflows/ci.yml`) runs typecheck/lint/test/build on macOS + Windows. +- Release workflow (`.github/workflows/release.yml`) builds distributables on tags (`v*`). +- Code signing/notarization uses GitHub secrets: + - `CSC_LINK`, `CSC_KEY_PASSWORD` + - `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID` (macOS notarization) + +## Security Notes +- IPC handlers validate IDs/inputs and apply strict path containment checks. +- File reads for context injection are constrained to project root and `~/.claude`. +- Sensitive credential path patterns are blocked. + +## Contributing +See: +- `CONTRIBUTING.md` +- `CODE_OF_CONDUCT.md` +- `SECURITY.md` + +## License +MIT (`LICENSE`) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..ce3dc57e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Supported Versions +Only the latest release is supported with security fixes. + +## Reporting a Vulnerability +Please report vulnerabilities privately and do not open public issues for undisclosed security problems. + +Include: +- affected version/commit +- vulnerability description +- impact assessment +- reproduction steps or proof of concept + +If you do not have a private contact path yet, open a minimal GitHub issue asking for a secure reporting channel without disclosing technical details. + +## Disclosure Process +- We will acknowledge reports as quickly as possible. +- We will validate, triage severity, and prepare a fix. +- We will coordinate a release and publish advisories when appropriate. diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 00000000..cf2aae91 --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,41 @@ +appId: com.claudecode.context +productName: Claude Code Context + +directories: + output: release + +files: + - out/renderer/** + - dist-electron/** + - package.json + +asar: true + +extraMetadata: + main: dist-electron/main/index.js + +mac: + category: public.app-category.developer-tools + target: + - dmg + - zip + hardenedRuntime: true + gatekeeperAssess: false + icon: resources/icons/mac/icon.icns + +dmg: + sign: false + +win: + target: + - nsis + icon: resources/icons/win/icon.ico + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + +publish: + - provider: github + releaseType: draft diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 00000000..990ddc3a --- /dev/null +++ b/electron.vite.config.ts @@ -0,0 +1,62 @@ +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import react from '@vitejs/plugin-react' +import { resolve } from 'path' + +export default defineConfig({ + main: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + '@main': resolve(__dirname, 'src/main'), + '@shared': resolve(__dirname, 'src/shared') + } + }, + build: { + outDir: 'dist-electron/main', + rollupOptions: { + input: { + index: resolve(__dirname, 'src/main/index.ts') + } + } + } + }, + preload: { + plugins: [externalizeDepsPlugin()], + resolve: { + alias: { + '@preload': resolve(__dirname, 'src/preload'), + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main') + } + }, + build: { + outDir: 'dist-electron/preload', + rollupOptions: { + input: { + index: resolve(__dirname, 'src/preload/index.ts') + }, + output: { + format: 'cjs', + entryFileNames: '[name].js' + } + } + } + }, + renderer: { + resolve: { + alias: { + '@renderer': resolve(__dirname, 'src/renderer'), + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main') + } + }, + plugins: [react()], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, 'src/renderer/index.html') + } + } + } + } +}) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..d5938fba --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,591 @@ +import { defineConfig, globalIgnores } from 'eslint/config'; +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactPlugin from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import jsxA11y from 'eslint-plugin-jsx-a11y'; +import tailwindcss from 'eslint-plugin-tailwindcss'; +import sonarjs from 'eslint-plugin-sonarjs'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import importPlugin from 'eslint-plugin-import'; +import security from 'eslint-plugin-security'; +import boundaries from 'eslint-plugin-boundaries'; +import eslintComments from '@eslint-community/eslint-plugin-eslint-comments'; +import eslintConfigPrettier from 'eslint-config-prettier/flat'; +import globals from 'globals'; + +export default defineConfig([ + // Global ignores + globalIgnores([ + 'dist/**', + 'dist-electron/**', + 'build/**', + 'node_modules/**', + '*.config.js', + '*.config.cjs', + '*.config.ts', + 'out/**', + ]), + + // Base ESLint recommended rules + js.configs.recommended, + + // TypeScript-ESLint recommended with type checking + stylistic + // Using recommended (not strict) for a balanced approach + ...tseslint.configs.recommendedTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + + // SonarJS - Code quality and bug detection rules + sonarjs.configs.recommended, + + // Security - Catch common security mistakes in AI-generated code + security.configs.recommended, + + // TypeScript parser options for type-aware linting + { + name: 'typescript-parser-options', + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + + // Import plugin configuration - Main/Preload (uses tsconfig.node.json) + { + name: 'import-plugin-main', + files: ['src/main/**/*.ts', 'src/preload/**/*.ts'], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.node.json', + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, + + // Import plugin configuration - Renderer (uses tsconfig.json) + { + name: 'import-plugin-renderer', + files: ['src/renderer/**/*.{ts,tsx}'], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, + + // Module boundaries - Enforce Electron three-process architecture + { + name: 'module-boundaries', + files: ['src/**/*.{js,jsx,ts,tsx}'], + plugins: { + boundaries: boundaries, + }, + settings: { + 'boundaries/elements': [ + { type: 'main', pattern: 'src/main/**', mode: 'folder' }, + { type: 'preload', pattern: 'src/preload/**', mode: 'folder' }, + { type: 'renderer', pattern: 'src/renderer/**', mode: 'folder' }, + { type: 'shared', pattern: 'src/shared/**', mode: 'folder' }, + ], + 'boundaries/ignore': ['**/*.test.ts', '**/*.spec.ts'], + }, + rules: { + // Enforce strict module boundaries for Electron architecture + 'boundaries/element-types': [ + 'error', + { + default: 'disallow', + rules: [ + // Renderer can only import from renderer and shared + { from: 'renderer', allow: ['renderer', 'shared'] }, + // Main process can only import from main and shared + { from: 'main', allow: ['main', 'shared'] }, + // Preload can only import from preload and shared + { from: 'preload', allow: ['preload', 'shared'] }, + // Shared can import from shared and main (for type re-exports) + { from: 'shared', allow: ['shared', 'main'] }, + ], + }, + ], + // Prevent importing private modules + 'boundaries/no-private': 'error', + }, + }, + + // ESLint Comments + { + name: 'eslint-comments', + files: ['src/**/*.{js,jsx,ts,tsx}'], + plugins: { + '@eslint-community/eslint-comments': eslintComments, + }, + rules: { + // Prevents blanket-disabling rules + '@eslint-community/eslint-comments/no-unlimited-disable': 'error', + // Require description for disable comments + '@eslint-community/eslint-comments/require-description': [ + 'error', + { ignore: [] }, + ], + // Re-enable rules after disabling + '@eslint-community/eslint-comments/disable-enable-pair': 'error', + // No duplicate disable comments + '@eslint-community/eslint-comments/no-duplicate-disable': 'error', + // Unused disable comments + '@eslint-community/eslint-comments/no-unused-disable': 'error', + }, + }, + + // Import sorting for all JS/TS files + { + name: 'import-sorting', + files: ['src/**/*.{js,jsx,ts,tsx}'], + plugins: { + 'simple-import-sort': simpleImportSort, + }, + rules: { + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Side effect imports (e.g., import './styles.css') + ['^\\u0000'], + // Node.js builtins (fs, path, etc.) + ['^node:'], + // React and related packages + ['^react', '^react-dom'], + // External packages from node_modules + ['^@?\\w'], + // Internal aliases (@/ paths) + ['^@/'], + // Parent imports (../) + ['^\\.\\.(?!/?$)', '^\\.\\./?$'], + // Same-folder imports (./) + ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], + // Type imports + ['^.+\\u0000$'], + ], + }, + ], + 'simple-import-sort/exports': 'error', + }, + }, + + // Main process (Electron Node.js) + { + name: 'electron-main', + files: ['src/main/**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + rules: { + // Allow console in main process for logging + 'no-console': 'off', + }, + }, + + // Preload script (Electron bridge) + { + name: 'electron-preload', + files: ['src/preload/**/*.ts'], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + }, + }, + }, + + // Renderer process (React + A11y + Tailwind) + { + name: 'renderer-react', + files: ['src/renderer/**/*.{ts,tsx}'], + languageOptions: { + globals: { + ...globals.browser, + }, + }, + plugins: { + react: reactPlugin, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + 'jsx-a11y': jsxA11y, + tailwindcss: tailwindcss, + }, + settings: { + react: { + version: 'detect', + }, + tailwindcss: { + // Tailwind config path (relative to cwd) + config: 'tailwind.config.js', + // Allow custom classnames (e.g., from CSS modules) + callees: ['classnames', 'clsx', 'cn'], + }, + }, + rules: { + // React recommended rules + ...reactPlugin.configs.recommended.rules, + // JSX runtime (React 17+) - no need to import React + ...reactPlugin.configs['jsx-runtime'].rules, + // React Hooks rules + ...reactHooks.configs.recommended.rules, + // Accessibility rules (recommended) + ...jsxA11y.configs.recommended.rules, + // Tailwind CSS rules + ...tailwindcss.configs.recommended.rules, + + // React Refresh for HMR + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + // Disable prop-types since we use TypeScript + 'react/prop-types': 'off', + + // A11y rule adjustments for this project + // Allow click handlers on divs when keyboard handlers also present + 'jsx-a11y/click-events-have-key-events': 'warn', + 'jsx-a11y/no-static-element-interactions': 'warn', + // Allow autofocus for search inputs in desktop apps + 'jsx-a11y/no-autofocus': 'off', + + // Tailwind CSS rule adjustments + // Warn on class order (Prettier plugin handles sorting) + 'tailwindcss/classnames-order': 'off', // Prettier plugin handles this + // Warn on conflicting classes + 'tailwindcss/no-contradicting-classname': 'error', + // Warn on custom classnames that don't exist + 'tailwindcss/no-custom-classname': 'warn', + + // === React-Specific Rules === + // Consistent component definition + 'react/function-component-definition': [ + 'error', + { + namedComponents: 'arrow-function', + unnamedComponents: 'arrow-function', + }, + ], + + // Strengthen exhaustive-deps + 'react-hooks/exhaustive-deps': 'error', + + // Prevent prop spreading + 'react/jsx-props-no-spreading': [ + 'warn', + { + exceptions: ['input', 'button', 'Input', 'Button', 'textarea', 'select'], + }, + ], + + // Ensure key props + 'react/jsx-key': [ + 'error', + { + checkFragmentShorthand: true, + checkKeyMustBeforeSpread: true, + }, + ], + + // Prevent unnecessary fragments + 'react/jsx-no-useless-fragment': 'warn', + + // Self-closing components for consistency + 'react/self-closing-comp': [ + 'error', + { + component: true, + html: true, + }, + ], + }, + }, + + // Test files + { + name: 'test-files', + files: ['test/**/*.ts', '**/*.test.ts', '**/*.spec.ts'], + languageOptions: { + globals: { + ...globals.node, + }, + parserOptions: { + projectService: false, + project: './tsconfig.test.json', + }, + }, + rules: { + // Relax TypeScript strict rules for tests + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/unbound-method': 'off', + + // Relax function/export rules for tests + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + + // Relax naming conventions for tests (allow describe, it, expect patterns) + '@typescript-eslint/naming-convention': 'off', + + // Allow magic numbers in tests + 'sonarjs/no-hardcoded-ip': 'off', + + // Allow floating promises in tests (common with async test helpers) + '@typescript-eslint/no-floating-promises': 'off', + }, + }, + + // Custom rule overrides for all TypeScript files + { + name: 'custom-rules', + files: ['src/**/*.{ts,tsx}'], + rules: { + // === Core JavaScript rules === + 'prefer-const': 'error', + 'no-var': 'error', + eqeqeq: ['error', 'always', { null: 'ignore' }], + + // === TypeScript Import/Export rules === + '@typescript-eslint/consistent-type-imports': [ + 'error', + { + prefer: 'type-imports', + fixStyle: 'inline-type-imports', + }, + ], + '@typescript-eslint/consistent-type-exports': [ + 'error', + { fixMixedExportsWithInlineTypeSpecifier: true }, + ], + + // === Unused variables === + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + + // === Relaxed strict rules for practical use === + // Allow empty functions (useful for callbacks and stubs) + '@typescript-eslint/no-empty-function': 'off', + + // Allow numbers/booleans in template literals (common pattern) + '@typescript-eslint/restrict-template-expressions': [ + 'error', + { + allowNumber: true, + allowBoolean: true, + allowNullish: false, + }, + ], + + // Allow async functions without await (IPC handlers often need this) + '@typescript-eslint/require-await': 'off', + + // Allow floating promises in event handlers (common in Electron) + '@typescript-eslint/no-floating-promises': [ + 'error', + { + ignoreVoid: true, + ignoreIIFE: true, + }, + ], + + // Allow promises in places that don't expect them (event handlers) + '@typescript-eslint/no-misused-promises': [ + 'error', + { + checksVoidReturn: { + attributes: false, + arguments: false, + }, + }, + ], + + // Allow void expression in arrow functions shorthand + '@typescript-eslint/no-confusing-void-expression': [ + 'error', + { + ignoreArrowShorthand: true, + ignoreVoidOperator: true, + }, + ], + + // Prefer nullish coalescing but don't error on logical or + '@typescript-eslint/prefer-nullish-coalescing': 'off', + + // Allow inferrable types (style preference) + '@typescript-eslint/no-inferrable-types': 'off', + + // === Anti-Hallucination Rules === + // Explicit return types + '@typescript-eslint/explicit-function-return-type': [ + 'warn', + { + allowExpressions: true, + allowTypedFunctionExpressions: true, + allowHigherOrderFunctions: true, + allowDirectConstAssertionInArrowFunctions: true, + }, + ], + + // Explicit types for exported functions (minimum requirement) + '@typescript-eslint/explicit-module-boundary-types': 'warn', + + // Prevent variable shadowing + '@typescript-eslint/no-shadow': 'error', + + // === Naming Conventions === + '@typescript-eslint/naming-convention': [ + 'warn', + // Imports can be camelCase or PascalCase (React, ReactDOM, App, etc.) + { + selector: 'import', + format: ['camelCase', 'PascalCase'], + }, + // Default: variables and parameters in camelCase + { + selector: 'default', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + // Static readonly class properties can be UPPER_CASE + { + selector: 'classProperty', + modifiers: ['static', 'readonly'], + format: ['camelCase', 'UPPER_CASE'], + }, + // Variables: camelCase or UPPER_CASE for constants + { + selector: 'variable', + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allow', + }, + // Functions: camelCase (includes type guards like isXxx, builders like buildXxx) + { + selector: 'function', + format: ['camelCase', 'PascalCase'], + }, + // Parameters: camelCase, allow leading underscore for unused + { + selector: 'parameter', + format: ['camelCase'], + leadingUnderscore: 'allow', + }, + // Types and interfaces in PascalCase + { + selector: 'typeLike', + format: ['PascalCase'], + }, + // Interfaces should NOT start with I (modern convention) + { + selector: 'interface', + format: ['PascalCase'], + custom: { regex: '^I[A-Z]', match: false }, + }, + // Enum members in PascalCase or UPPER_CASE + { + selector: 'enumMember', + format: ['PascalCase', 'UPPER_CASE'], + }, + // Object literal properties: allow any format (for API compatibility) + { + selector: 'objectLiteralProperty', + format: null, + }, + // Type properties: allow any format (for type definitions matching APIs) + { + selector: 'typeProperty', + format: null, + }, + ], + + // === Import Restrictions === + // Note: boundaries/element-types handles main/renderer separation + 'no-restricted-imports': [ + 'error', + { + patterns: [ + // Prevent deep relative imports - use @/ aliases + { + group: ['../**/..'], + message: 'Avoid deep relative imports, use @/ aliases', + }, + ], + }, + ], + + // === Mutation Prevention === + 'no-param-reassign': [ + 'error', + { + props: true, + ignorePropertyModificationsFor: ['draft', 'acc', 'ctx', 'state', 'req', 'res'], + }, + ], + + // === SonarJS rule adjustments === + // Cognitive complexity - warn instead of error for gradual adoption + 'sonarjs/cognitive-complexity': 'off', + // Allow some duplication in similar but not identical code + 'sonarjs/no-duplicate-string': 'off', + // Relax for Electron IPC patterns (many similar switch cases) + 'sonarjs/no-small-switch': 'off', + // Allow nested ternaries in JSX (common React pattern) + 'sonarjs/no-nested-conditional': 'off', + + // === Security rule adjustments (Code Protection) === + // These catch common security mistakes + 'security/detect-eval-with-expression': 'error', + // Disabled: This is a desktop file reader app - file system access is expected + 'security/detect-non-literal-fs-filename': 'off', + // Disabled: Dynamic patterns are intentional in this app + 'security/detect-non-literal-regexp': 'off', + // Disabled: Often false positives with typed code + 'security/detect-object-injection': 'off', + 'security/detect-child-process': 'warn', + 'security/detect-non-literal-require': 'warn', + 'security/detect-possible-timing-attacks': 'warn', + }, + }, + + // === IMPORTANT: eslint-config-prettier MUST be LAST === + // This disables all ESLint rules that conflict with Prettier + // Prettier handles formatting, ESLint handles code quality + eslintConfigPrettier, +]); diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..1f16c2dd --- /dev/null +++ b/knip.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://unpkg.com/knip@next/schema.json", + "entry": [ + "src/main/index.ts", + "src/preload/index.ts", + "src/renderer/main.tsx", + "electron.vite.config.ts" + ], + "project": ["src/**/*.{ts,tsx}!"], + "ignore": ["tsconfig*.json"], + "paths": { + "@main/*": ["./src/main/*"], + "@renderer/*": ["./src/renderer/*"], + "@preload/*": ["./src/preload/*"], + "@shared/*": ["./src/shared/*"] + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..8aa7377f --- /dev/null +++ b/package.json @@ -0,0 +1,98 @@ +{ + "name": "claude-code-context", + "type": "module", + "version": "0.1.0", + "description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls", + "license": "MIT", + "author": "Claude Code Context contributors", + "homepage": "https://github.com/matt1398/claude-code-context", + "repository": { + "type": "git", + "url": "https://github.com/matt1398/claude-code-context.git" + }, + "bugs": { + "url": "https://github.com/matt1398/claude-code-context/issues" + }, + "main": "dist-electron/main/index.js", + "scripts": { + "dev": "electron-vite dev", + "build": "electron-vite build", + "dist": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --mac --win", + "dist:mac": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --mac", + "dist:win": "pnpm dlx electron-builder@24.13.3 --config electron-builder.yml --win", + "preview": "electron-vite preview", + "typecheck": "tsc --noEmit", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css}\"", + "check": "pnpm typecheck && pnpm lint && pnpm test && pnpm build", + "fix": "pnpm lint:fix && pnpm format", + "quality": "pnpm check && pnpm format:check && npx knip", + "test:chunks": "tsx test/test-chunk-building.ts", + "test:semantic": "tsx test/test-semantic-steps.ts", + "test:noise": "tsx test/test-noise-filtering.ts", + "test:task-filtering": "tsx test/test-task-filtering.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-virtual": "^3.10.8", + "date-fns": "^3.6.0", + "lucide-react": "^0.562.0", + "mdast-util-to-hast": "^13.2.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "unified": "^11.0.5", + "zustand": "^4.5.0" + }, + "devDependencies": { + "@eslint-community/eslint-plugin-eslint-comments": "^4.6.0", + "@eslint/js": "^9.39.2", + "@tailwindcss/typography": "^0.5.19", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "@types/node": "^25.0.7", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^3.1.4", + "autoprefixer": "^10.4.17", + "electron": "^40.3.0", + "electron-vite": "^2.3.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-boundaries": "^5.3.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-security": "^3.0.1", + "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-sonarjs": "^3.0.6", + "eslint-plugin-tailwindcss": "^3.18.2", + "globals": "^17.2.0", + "happy-dom": "^17.4.6", + "knip": "^5.82.1", + "postcss": "^8.4.35", + "prettier": "^3.8.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "tailwindcss": "^3.4.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0", + "vite": "^5.4.2", + "vitest": "^3.1.4" + }, + "packageManager": "pnpm@10.25.0+sha512.5e82639027af37cf832061bcc6d639c219634488e0f2baebe785028a793de7b525ffcd3f7ff574f5e9860654e098fe852ba8ac5dd5cefe1767d23a020a92f501" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..64b0bb6d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,7228 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.10.8 + version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + date-fns: + specifier: ^3.6.0 + version: 3.6.0 + lucide-react: + specifier: ^0.562.0 + version: 0.562.0(react@18.3.1) + mdast-util-to-hast: + specifier: ^13.2.1 + version: 13.2.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.3.27)(react@18.3.1) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + zustand: + specifier: ^4.5.0 + version: 4.5.7(@types/react@18.3.27)(react@18.3.1) + devDependencies: + '@eslint-community/eslint-plugin-eslint-comments': + specifier: ^4.6.0 + version: 4.6.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint/js': + specifier: ^9.39.2 + version: 9.39.2 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)) + '@types/hast': + specifier: ^3.0.4 + version: 3.0.4 + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/node': + specifier: ^25.0.7 + version: 25.0.7 + '@types/react': + specifier: ^18.3.3 + version: 18.3.27 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.27) + '@vitejs/plugin-react': + specifier: ^4.3.1 + version: 4.7.0(vite@5.4.21(@types/node@25.0.7)) + '@vitest/coverage-v8': + specifier: ^3.1.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3)) + autoprefixer: + specifier: ^10.4.17 + version: 10.4.23(postcss@8.5.6) + electron: + specifier: ^40.3.0 + version: 40.3.0 + electron-vite: + specifier: ^2.3.0 + version: 2.3.0(vite@5.4.21(@types/node@25.0.7)) + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@1.21.7) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@1.21.7)) + eslint-import-resolver-typescript: + specifier: ^4.4.4 + version: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-boundaries: + specifier: ^5.3.1 + version: 5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-react-refresh: + specifier: ^0.4.26 + version: 0.4.26(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-security: + specifier: ^3.0.1 + version: 3.0.1 + eslint-plugin-simple-import-sort: + specifier: ^12.1.1 + version: 12.1.1(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-sonarjs: + specifier: ^3.0.6 + version: 3.0.6(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-tailwindcss: + specifier: ^3.18.2 + version: 3.18.2(tailwindcss@3.4.19(tsx@4.21.0)) + globals: + specifier: ^17.2.0 + version: 17.2.0 + happy-dom: + specifier: ^17.4.6 + version: 17.6.3 + knip: + specifier: ^5.82.1 + version: 5.82.1(@types/node@25.0.7)(typescript@5.9.3) + postcss: + specifier: ^8.4.35 + version: 8.5.6 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier@3.8.1) + tailwindcss: + specifier: ^3.4.1 + version: 3.4.19(tsx@4.21.0) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.54.0 + version: 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + vite: + specifier: ^5.4.2 + version: 5.4.21(@types/node@25.0.7) + vitest: + specifier: ^3.1.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.28.6': + resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.6': + resolution: {integrity: sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.6': + resolution: {integrity: sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.6': + resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-arrow-functions@7.27.1': + resolution: {integrity: sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.6': + resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@boundaries/elements@1.1.2': + resolution: {integrity: sha512-DnGHL+v36YVMoWhWZqyJYVZ9dapNm7h4N3/P0lDPirJj0CHVPkjChMCCotj74cg6LW7iPJZFGrdEfh0X0g2bmQ==} + engines: {node: '>=18.18'} + + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-plugin-eslint-comments@4.6.0': + resolution: {integrity: sha512-2EX2bBQq1ez++xz2o9tEeEQkyvfieWgUFMH4rtJJri2q0Azvhja3hZGXsjPXs31R4fQkZDtWzNDDK2zQn5UE5g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.3': + resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.2': + resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-resolver/binding-android-arm-eabi@11.16.4': + resolution: {integrity: sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.16.4': + resolution: {integrity: sha512-5ODwd1F5mdkm6JIg1CNny9yxIrCzrkKpxmqas7Alw23vE0Ot8D4ykqNBW5Z/nIZkXVEo5VDmnm0sMBBIANcpeQ==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.16.4': + resolution: {integrity: sha512-egwvDK9DMU4Q8F4BG74/n4E22pQ0lT5ukOVB6VXkTj0iG2fnyoStHoFaBnmDseLNRA4r61Mxxz8k940CIaJMDg==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.16.4': + resolution: {integrity: sha512-HMkODYrAG4HaFNCpaYzSQFkxeiz2wzl+smXwxeORIQVEo1WAgUrWbvYT/0RNJg/A8z2aGMGK5KWTUr2nX5GiMw==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.16.4': + resolution: {integrity: sha512-mkcKhIdSlUqnndD928WAVVFMEr1D5EwHOBGHadypW0PkM0h4pn89ZacQvU7Qs/Z2qquzvbyw8m4Mq3jOYI+4Dw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.4': + resolution: {integrity: sha512-ZJvzbmXI/cILQVcJL9S2Fp7GLAIY4Yr6mpGb+k6LKLUSEq85yhG+rJ9eWCqgULVIf2BFps/NlmPTa7B7oj8jhQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.16.4': + resolution: {integrity: sha512-iZUB0W52uB10gBUDAi79eTnzqp1ralikCAjfq7CdokItwZUVJXclNYANnzXmtc0Xr0ox+YsDsG2jGcj875SatA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.16.4': + resolution: {integrity: sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-musl@11.16.4': + resolution: {integrity: sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==} + cpu: [arm64] + os: [linux] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.16.4': + resolution: {integrity: sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==} + cpu: [ppc64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.16.4': + resolution: {integrity: sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-riscv64-musl@11.16.4': + resolution: {integrity: sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==} + cpu: [riscv64] + os: [linux] + + '@oxc-resolver/binding-linux-s390x-gnu@11.16.4': + resolution: {integrity: sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==} + cpu: [s390x] + os: [linux] + + '@oxc-resolver/binding-linux-x64-gnu@11.16.4': + resolution: {integrity: sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-linux-x64-musl@11.16.4': + resolution: {integrity: sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==} + cpu: [x64] + os: [linux] + + '@oxc-resolver/binding-openharmony-arm64@11.16.4': + resolution: {integrity: sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.16.4': + resolution: {integrity: sha512-hnjb0mDVQOon6NdfNJ1EmNquonJUjoYkp7UyasjxVa4iiMcApziHP4czzzme6WZbp+vzakhVv2Yi5ACTon3Zlw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.16.4': + resolution: {integrity: sha512-+i0XtNfSP7cfnh1T8FMrMm4HxTeh0jxKP/VQCLWbjdUxaAQ4damho4gN9lF5dl0tZahtdszXLUboBFNloSJNOQ==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-ia32-msvc@11.16.4': + resolution: {integrity: sha512-ePW1islJrv3lPnef/iWwrjrSpRH8kLlftdKf2auQNWvYLx6F0xvcnv9d+r/upnVuttoQY9amLnWJf+JnCRksTw==} + cpu: [ia32] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.16.4': + resolution: {integrity: sha512-qnjQhjHI4TDL3hkidZyEmQRK43w2NHl6TP5Rnt/0XxYuLdEgx/1yzShhYidyqWzdnhGhSPTM/WVP2mK66XLegA==} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/http-cache-semantics@4.0.4': + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.10.12': + resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} + + '@types/node@25.0.7': + resolution: {integrity: sha512-C/er7DlIZgRJO7WtTdYovjIFzGsz0I95UlMyR9anTb4aCpBSRWe5Jc1/RvLKUfzmOxHPGjSE5+63HgLtndxU4w==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.27': + resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.54.0': + resolution: {integrity: sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.54.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.54.0': + resolution: {integrity: sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.54.0': + resolution: {integrity: sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.54.0': + resolution: {integrity: sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.54.0': + resolution: {integrity: sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.54.0': + resolution: {integrity: sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.54.0': + resolution: {integrity: sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.54.0': + resolution: {integrity: sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.54.0': + resolution: {integrity: sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.54.0': + resolution: {integrity: sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + ast-v8-to-istanbul@0.3.10: + resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.4.23: + resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.9.14: + resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001764: + resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.2.0: + resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + electron-vite@2.3.0: + resolution: {integrity: sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@swc/core': ^1.0.0 + vite: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@swc/core': + optional: true + + electron@40.3.0: + resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@4.4.4: + resolution: {integrity: sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==} + engines: {node: ^16.17.0 || >=18.6.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-boundaries@5.3.1: + resolution: {integrity: sha512-91StsOYtDyrna1fyRJ+1Ps5CnrfyFLbdCouPZ3E/o2cllLxJke3OoScdqjpBSl7pNEYbojhpNlurQAr30sf9Bg==} + engines: {node: '>=18.18'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-plugin-security@3.0.1: + resolution: {integrity: sha512-XjVGBhtDZJfyuhIxnQ/WMm385RbX3DBu7H1J7HNNhmB2tnGxMeqVSnYv79oAj992ayvIBZghsymwkYFS6cGH4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-plugin-simple-import-sort@12.1.1: + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + + eslint-plugin-sonarjs@3.0.6: + resolution: {integrity: sha512-3mVUqsAUSylGfkJMj2v0aC2Cu/eUunDLm+XMjLf0uLjAZao205NWF3g6EXxcCAFO+rCZiQ6Or1WQkUcU9/sKFQ==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 + + eslint-plugin-tailwindcss@3.18.2: + resolution: {integrity: sha512-QbkMLDC/OkkjFQ1iz/5jkMdHfiMu/uwujUHLAJK5iwNHD8RTxVTlsUezE0toTZ6VhybNBsk+gYGPDq2agfeRNA==} + engines: {node: '>=18.12.0'} + peerDependencies: + tailwindcss: ^3.4.0 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.39.2: + resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.2.0: + resolution: {integrity: sha512-tovnCz/fEq+Ripoq+p/gN1u7l6A7wwkoBT9pRCzTHzsD/LvADIzXZdjmRymh5Ztf0DYC3Rwg5cZRYjxzBmzbWg==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + happy-dom@17.6.3: + resolution: {integrity: sha512-UVIHeVhxmxedbWPCfgS55Jg2rDfwf2BCKeylcPSqazLz5w3Kri7Q4xdBJubsr/+VUzFLh0VjIvh13RaDA2/Xug==} + engines: {node: '>=20.0.0'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knip@5.82.1: + resolution: {integrity: sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==} + engines: {node: '>=18.18.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4 <7' + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.562.0: + resolution: {integrity: sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.2: + resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + oxc-resolver@11.16.4: + resolution: {integrity: sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.54.0: + resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.6': {} + + '@babel/core@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.28.6) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.28.6 + '@babel/types': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/plugin-transform-arrow-functions@7.27.1(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.6)': + dependencies: + '@babel/core': 7.28.6 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@babel/traverse@7.28.6': + dependencies: + '@babel/code-frame': 7.28.6 + '@babel/generator': 7.28.6 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.6 + '@babel/template': 7.28.6 + '@babel/types': 7.28.6 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@1.0.2': {} + + '@boundaries/elements@1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7))': + dependencies: + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + handlebars: 4.7.8 + is-core-module: 2.16.1 + micromatch: 4.0.8 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + '@dnd-kit/accessibility@3.1.1(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@dnd-kit/utilities': 3.2.2(react@18.3.1) + react: 18.3.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@18.3.1)': + dependencies: + react: 18.3.1 + tslib: 2.8.1 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.27.2': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.27.2': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.27.2': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.27.2': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.27.2': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.27.2': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.27.2': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.27.2': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.27.2': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.27.2': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.27.2': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.27.2': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.27.2': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.27.2': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.27.2': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.27.2': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.27.2': + optional: true + + '@esbuild/netbsd-arm64@0.27.2': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.27.2': + optional: true + + '@esbuild/openbsd-arm64@0.27.2': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.27.2': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.27.2': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.27.2': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.27.2': + optional: true + + '@eslint-community/eslint-plugin-eslint-comments@4.6.0(eslint@9.39.2(jiti@1.21.7))': + dependencies: + escape-string-regexp: 4.0.0 + eslint: 9.39.2(jiti@1.21.7) + ignore: 7.0.5 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': + dependencies: + eslint: 9.39.2(jiti@1.21.7) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.2': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-resolver/binding-android-arm-eabi@11.16.4': + optional: true + + '@oxc-resolver/binding-android-arm64@11.16.4': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.16.4': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.16.4': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.16.4': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.16.4': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.16.4': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.16.4': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.16.4': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.16.4': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.16.4': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.55.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.55.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.55.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0))': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.19(tsx@4.21.0) + + '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.18 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.18': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.6 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.0.4 + '@types/keyv': 3.1.4 + '@types/node': 25.0.7 + '@types/responselike': 1.0.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/http-cache-semantics@4.0.4': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 25.0.7 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@24.10.12': + dependencies: + undici-types: 7.16.0 + + '@types/node@25.0.7': + dependencies: + undici-types: 7.16.0 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.27)': + dependencies: + '@types/react': 18.3.27 + + '@types/react@18.3.27': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 25.0.7 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 25.0.7 + optional: true + + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.54.0 + eslint: 9.39.2(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.54.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) + '@typescript-eslint/types': 8.54.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.54.0': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/visitor-keys': 8.54.0 + + '@typescript-eslint/tsconfig-utils@8.54.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.54.0': {} + + '@typescript-eslint/typescript-estree@8.54.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.54.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.54.0(typescript@5.9.3) + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.3 + minimatch: 9.0.5 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.54.0': + dependencies: + '@typescript-eslint/types': 8.54.0 + eslint-visitor-keys: 4.2.1 + + '@ungap/structured-clone@1.3.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@25.0.7))': + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@25.0.7) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.10 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.0.7))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@25.0.7) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + ast-v8-to-istanbul@0.3.10: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 9.0.1 + + async-function@1.0.0: {} + + autoprefixer@10.4.23(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001764 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.1: {} + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.9.14: {} + + binary-extensions@2.3.0: {} + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.14 + caniuse-lite: 1.0.30001764 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-crc32@0.2.13: {} + + builtin-modules@3.3.0: {} + + bytes@3.1.2: {} + + cac@6.7.14: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001764: {} + + ccount@2.0.1: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + check-error@2.1.3: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@3.6.0: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.2.0: + dependencies: + character-entities: 2.0.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + dequal@2.0.3: {} + + detect-node@2.1.0: + optional: true + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.267: {} + + electron-vite@2.3.0(vite@5.4.21(@types/node@25.0.7)): + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.28.6) + cac: 6.7.14 + esbuild: 0.21.5 + magic-string: 0.30.21 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@25.0.7) + transitivePeerDependencies: + - supports-color + + electron@40.3.0: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 24.10.12 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + env-paths@2.2.1: {} + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + es6-error@4.1.1: + optional: true + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.2(jiti@1.21.7)): + dependencies: + eslint: 9.39.2(jiti@1.21.7) + + eslint-import-context@0.1.9(unrs-resolver@1.11.1): + dependencies: + get-tsconfig: 4.13.0 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.11.1 + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 1.22.11 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)): + dependencies: + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + get-tsconfig: 4.13.0 + is-bun-module: 2.0.0 + stable-hash-x: 0.2.0 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-boundaries@5.3.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@boundaries/elements': 1.1.2(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + chalk: 4.1.2 + eslint: 9.39.2(jiti@1.21.7) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + micromatch: 4.0.8 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.2(jiti@1.21.7) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@1.21.7)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.1 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.2(jiti@1.21.7) + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@babel/core': 7.28.6 + '@babel/parser': 7.28.6 + eslint: 9.39.2(jiti@1.21.7) + hermes-parser: 0.25.1 + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.4.26(eslint@9.39.2(jiti@1.21.7)): + dependencies: + eslint: 9.39.2(jiti@1.21.7) + + eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@1.21.7)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 9.39.2(jiti@1.21.7) + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-plugin-security@3.0.1: + dependencies: + safe-regex: 2.1.1 + + eslint-plugin-simple-import-sort@12.1.1(eslint@9.39.2(jiti@1.21.7)): + dependencies: + eslint: 9.39.2(jiti@1.21.7) + + eslint-plugin-sonarjs@3.0.6(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 9.39.2(jiti@1.21.7) + functional-red-black-tree: 1.0.1 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.1.1 + scslre: 0.3.0 + semver: 7.7.3 + typescript: 5.9.3 + + eslint-plugin-tailwindcss@3.18.2(tailwindcss@3.4.19(tsx@4.21.0)): + dependencies: + fast-glob: 3.3.3 + postcss: 8.5.6 + tailwindcss: 3.4.19(tsx@4.21.0) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.2(jiti@1.21.7): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.2 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 1.21.7 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + extend@3.0.2: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + + fraction.js@5.3.4: {} + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functional-red-black-tree@1.0.1: {} + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.3 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.13.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.3 + serialize-error: 7.0.1 + optional: true + + globals@14.0.0: {} + + globals@17.2.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + happy-dom@17.6.3: + dependencies: + webidl-conversions: 7.0.0 + whatwg-mimetype: 3.0.0 + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-escaper@2.0.2: {} + + html-url-attributes@3.0.1: {} + + http-cache-semantics@4.2.0: {} + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inline-style-parser@0.2.7: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.3 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-decimal@2.0.1: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsx-ast-utils-x@0.1.0: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knip@5.82.1(@types/node@25.0.7)(typescript@5.9.3): + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@types/node': 25.0.7 + fast-glob: 3.3.3 + formatly: 0.3.0 + jiti: 2.6.1 + js-yaml: 4.1.1 + minimist: 1.2.8 + oxc-resolver: 11.16.4 + picocolors: 1.1.1 + picomatch: 4.0.3 + smol-toml: 1.6.0 + strip-json-comments: 5.0.3 + typescript: 5.9.3 + zod: 4.3.6 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.562.0(react@18.3.1): + dependencies: + react: 18.3.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + markdown-table@3.0.4: {} + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.2 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.2.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.2.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + oxc-resolver@11.16.4: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.16.4 + '@oxc-resolver/binding-android-arm64': 11.16.4 + '@oxc-resolver/binding-darwin-arm64': 11.16.4 + '@oxc-resolver/binding-darwin-x64': 11.16.4 + '@oxc-resolver/binding-freebsd-x64': 11.16.4 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.16.4 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.16.4 + '@oxc-resolver/binding-linux-arm64-gnu': 11.16.4 + '@oxc-resolver/binding-linux-arm64-musl': 11.16.4 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.16.4 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.16.4 + '@oxc-resolver/binding-linux-riscv64-musl': 11.16.4 + '@oxc-resolver/binding-linux-s390x-gnu': 11.16.4 + '@oxc-resolver/binding-linux-x64-gnu': 11.16.4 + '@oxc-resolver/binding-linux-x64-musl': 11.16.4 + '@oxc-resolver/binding-openharmony-arm64': 11.16.4 + '@oxc-resolver/binding-wasm32-wasi': 11.16.4 + '@oxc-resolver/binding-win32-arm64-msvc': 11.16.4 + '@oxc-resolver/binding-win32-ia32-msvc': 11.16.4 + '@oxc-resolver/binding-win32-x64-msvc': 11.16.4 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.2.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + tsx: 4.21.0 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-tailwindcss@0.7.2(prettier@3.8.1): + dependencies: + prettier: 3.8.1 + + prettier@3.8.1: {} + + progress@2.0.3: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@7.1.0: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-lru@5.1.1: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-is@16.13.1: {} + + react-markdown@10.1.0(@types/react@18.3.27)(react@18.3.1): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 18.3.27 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 18.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + + regexp-tree@0.1.27: {} + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.2 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + resolve-alpn@1.2.1: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + reusify@1.1.0: {} + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + rollup@4.55.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + + semver@7.7.3: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + smol-toml@1.6.0: {} + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + space-separated-tokens@2.0.2: {} + + sprintf-js@1.1.3: + optional: true + + stable-hash-x@0.2.0: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.19(tsx@4.21.0): + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.13.1: + optional: true + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.16.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@0.1.2: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@18.3.1): + dependencies: + react: 18.3.1 + + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.2.4(@types/node@25.0.7): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@25.0.7) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.0.7): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.55.1 + optionalDependencies: + '@types/node': 25.0.7 + fsevents: 2.3.3 + + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.7)(happy-dom@17.6.3): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.0.7)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 5.4.21(@types/node@25.0.7) + vite-node: 3.2.4(@types/node@25.0.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 25.0.7 + happy-dom: 17.6.3 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + walk-up-path@4.0.0: {} + + webidl-conversions@7.0.0: {} + + whatwg-mimetype@3.0.0: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@4.3.6: {} + + zustand@4.5.7(@types/react@18.3.27)(react@18.3.1): + dependencies: + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + react: 18.3.1 + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..6b58ca0b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - electron + - esbuild diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 00000000..85f717cc --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 00000000..6ad9c209 Binary files /dev/null and b/resources/icon.png differ diff --git a/resources/icons/mac/icon.icns b/resources/icons/mac/icon.icns new file mode 100644 index 00000000..3961a404 Binary files /dev/null and b/resources/icons/mac/icon.icns differ diff --git a/resources/icons/png/1024x1024.png b/resources/icons/png/1024x1024.png new file mode 100644 index 00000000..6e450783 Binary files /dev/null and b/resources/icons/png/1024x1024.png differ diff --git a/resources/icons/png/128x128.png b/resources/icons/png/128x128.png new file mode 100644 index 00000000..8fc2bf50 Binary files /dev/null and b/resources/icons/png/128x128.png differ diff --git a/resources/icons/png/16x16.png b/resources/icons/png/16x16.png new file mode 100644 index 00000000..b73af219 Binary files /dev/null and b/resources/icons/png/16x16.png differ diff --git a/resources/icons/png/24x24.png b/resources/icons/png/24x24.png new file mode 100644 index 00000000..13bec5a3 Binary files /dev/null and b/resources/icons/png/24x24.png differ diff --git a/resources/icons/png/256x256.png b/resources/icons/png/256x256.png new file mode 100644 index 00000000..f8965103 Binary files /dev/null and b/resources/icons/png/256x256.png differ diff --git a/resources/icons/png/32x32.png b/resources/icons/png/32x32.png new file mode 100644 index 00000000..97afb8fc Binary files /dev/null and b/resources/icons/png/32x32.png differ diff --git a/resources/icons/png/48x48.png b/resources/icons/png/48x48.png new file mode 100644 index 00000000..50c3eab8 Binary files /dev/null and b/resources/icons/png/48x48.png differ diff --git a/resources/icons/png/512x512.png b/resources/icons/png/512x512.png new file mode 100644 index 00000000..a61d654b Binary files /dev/null and b/resources/icons/png/512x512.png differ diff --git a/resources/icons/png/64x64.png b/resources/icons/png/64x64.png new file mode 100644 index 00000000..b6d20047 Binary files /dev/null and b/resources/icons/png/64x64.png differ diff --git a/resources/icons/win/icon.ico b/resources/icons/win/icon.ico new file mode 100644 index 00000000..574d3059 Binary files /dev/null and b/resources/icons/win/icon.ico differ diff --git a/src/CLAUDE.md b/src/CLAUDE.md new file mode 100644 index 00000000..77eeddd2 --- /dev/null +++ b/src/CLAUDE.md @@ -0,0 +1,31 @@ +# src/ Structure + +Three-process Electron architecture: + +## Processes +- `main/` - Node.js runtime (file system, IPC, lifecycle) +- `preload/` - Secure bridge (contextBridge API) +- `renderer/` - React/Chromium (UI, state, visualization) +- `shared/` - Cross-process types and utilities + +## Import Pattern +Use barrel exports from domain folders: +```typescript +import { ChunkBuilder, ProjectScanner } from './services'; +``` + +## IPC Communication +Exposed API via `window.electronAPI`, organized by domain: + +| Domain | Methods | Examples | +|--------|---------|---------| +| Sessions | 10 | `getProjects()`, `getSessions()`, `getSessionsPaginated()`, `getSessionDetail()`, `getSessionMetrics()`, `getWaterfallData()`, `getSubagentDetail()`, `searchSessions()`, `getAppVersion()` | +| Repository | 2 | `getRepositoryGroups()`, `getWorktreeSessions()` | +| Validation | 2 | `validatePath()`, `validateMentions()` | +| CLAUDE.md | 3 | `readClaudeMdFiles()`, `readDirectoryClaudeMd()`, `readMentionedFile()` | +| Config | 16 | `config.get()`, `config.update()`, `config.addTrigger()`, `config.openInEditor()`, `config.pinSession()`, `config.unpinSession()`, etc. | +| Notifications | 9 | `notifications.get()`, `notifications.markRead()`, `notifications.onNew()`, etc. | +| Utilities | 7 | `openPath()`, `openExternal()`, `onFileChange()`, `onTodoChange()`, `getZoomFactor()`, `onZoomFactorChanged()` | +| Session | 1 | `session.scrollToLine()` | + +Full API signatures in `src/preload/index.ts`, channel constants in `src/preload/constants/ipcChannels.ts`. diff --git a/src/main/CLAUDE.md b/src/main/CLAUDE.md new file mode 100644 index 00000000..1911b032 --- /dev/null +++ b/src/main/CLAUDE.md @@ -0,0 +1,32 @@ +# Main Process + +Node.js runtime handling file system, IPC, and app lifecycle. + +## Structure +- `index.ts` - App entry point, lifecycle management +- `ipc/` - IPC handlers organized by domain +- `services/` - Business logic by domain +- `types/` - Type definitions +- `utils/` - Utility functions +- `constants/` - Shared constants (messageTags, worktreePatterns) + +## IPC Organization +Handlers in `ipc/` by domain: +- `projects.ts` - Project listing +- `sessions.ts` - Session operations +- `search.ts` - Search functionality +- `subagents.ts` - Subagent details +- `validation.ts` - Path validation +- `utility.ts` - Shell & file operations +- `config.ts` - Configuration +- `notifications.ts` - Notifications + +## Adding IPC Handler +1. Add to domain file in `ipc/` +2. If new domain, create file and register in `handlers.ts` +3. Add type in `preload/index.ts` +4. Implement in appropriate service + +## File Watching +FileWatcher service monitors session files with 100ms debounce. +Notifies renderer of changes via IPC events. diff --git a/src/main/constants/messageTags.ts b/src/main/constants/messageTags.ts new file mode 100644 index 00000000..d5520132 --- /dev/null +++ b/src/main/constants/messageTags.ts @@ -0,0 +1,46 @@ +/** + * Message Tag Constants + * + * Centralized XML tag string literals used in message parsing and filtering. + */ + +// ============================================================================= +// System Output Tags +// ============================================================================= + +/** Local command stdout wrapper tag */ +export const LOCAL_COMMAND_STDOUT_TAG = ''; + +/** Local command stderr wrapper tag */ +export const LOCAL_COMMAND_STDERR_TAG = ''; + +/** Local command caveat wrapper tag */ +const LOCAL_COMMAND_CAVEAT_TAG = ''; + +/** System reminder wrapper tag */ +const SYSTEM_REMINDER_TAG = ''; + +// ============================================================================= +// Empty Output Tags +// ============================================================================= + +/** Empty stdout output */ +export const EMPTY_STDOUT = ''; + +/** Empty stderr output */ +export const EMPTY_STDERR = ''; + +// ============================================================================= +// Tag Arrays for Filtering +// ============================================================================= + +/** Tags that indicate system output (excludes from User chunks) */ +export const SYSTEM_OUTPUT_TAGS = [ + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + LOCAL_COMMAND_CAVEAT_TAG, + SYSTEM_REMINDER_TAG, +] as const; + +/** Tags that indicate hard noise (messages filtered completely) */ +export const HARD_NOISE_TAGS = [LOCAL_COMMAND_CAVEAT_TAG, SYSTEM_REMINDER_TAG] as const; diff --git a/src/main/constants/worktreePatterns.ts b/src/main/constants/worktreePatterns.ts new file mode 100644 index 00000000..f2ed2c02 --- /dev/null +++ b/src/main/constants/worktreePatterns.ts @@ -0,0 +1,44 @@ +/** + * Worktree Pattern Constants + * + * Centralized worktree-related string literals to avoid duplication. + * These are used in GitIdentityResolver for detecting worktree sources and paths. + */ + +// ============================================================================= +// Directory Names +// ============================================================================= + +/** Standard git worktrees subdirectory */ +export const WORKTREES_DIR = 'worktrees'; + +/** Workspaces directory (used by conductor) */ +export const WORKSPACES_DIR = 'workspaces'; + +/** Tasks directory (used by auto-claude) */ +export const TASKS_DIR = 'tasks'; + +// ============================================================================= +// Worktree Source Identifiers +// ============================================================================= + +/** Cursor editor worktrees directory */ +export const CURSOR_DIR = '.cursor'; + +/** Vibe Kanban worktree source */ +export const VIBE_KANBAN_DIR = 'vibe-kanban'; + +/** Conductor worktree source */ +export const CONDUCTOR_DIR = 'conductor'; + +/** Auto-Claude worktree source */ +export const AUTO_CLAUDE_DIR = '.auto-claude'; + +/** 21st/1code worktree source */ +export const TWENTYFIRST_DIR = '.21st'; + +/** Claude Desktop worktrees directory */ +export const CLAUDE_WORKTREES_DIR = '.claude-worktrees'; + +/** ccswitch worktrees directory */ +export const CCSWITCH_DIR = '.ccswitch'; diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 00000000..6aa253de --- /dev/null +++ b/src/main/index.ts @@ -0,0 +1,295 @@ +/** + * Main process entry point for Claude Code Context. + * + * Responsibilities: + * - Initialize Electron app and main window + * - Set up IPC handlers for data access + * - Initialize services (ProjectScanner, SessionParser, etc.) + * - Start file watcher for live updates + * - Manage application lifecycle + */ + +import { + CACHE_CLEANUP_INTERVAL_MINUTES, + CACHE_TTL_MINUTES, + DEFAULT_WINDOW_HEIGHT, + DEFAULT_WINDOW_WIDTH, + DEV_SERVER_PORT, + getTrafficLightPositionForZoom, + MAX_CACHE_SESSIONS, + WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, +} from '@shared/constants'; +import { createLogger } from '@shared/utils/logger'; +import { app, BrowserWindow } from 'electron'; +import { join } from 'path'; + +import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; + +// Icon path - works for both dev and production +const getIconPath = (): string => { + const isDev = process.env.NODE_ENV === 'development'; + if (isDev) { + return join(process.cwd(), 'resources/icon.png'); + } + return join(__dirname, '../../resources/icon.png'); +}; + +const logger = createLogger('App'); +import { + ChunkBuilder, + configManager, + DataCache, + FileWatcher, + NotificationManager, + ProjectScanner, + SessionParser, + SubagentResolver, +} from './services'; + +// ============================================================================= +// Application State +// ============================================================================= + +let mainWindow: BrowserWindow | null = null; + +// Service instances +let projectScanner: ProjectScanner; +let sessionParser: SessionParser; +let subagentResolver: SubagentResolver; +let chunkBuilder: ChunkBuilder; +let dataCache: DataCache; +let fileWatcher: FileWatcher; +let notificationManager: NotificationManager; +let cleanupInterval: NodeJS.Timeout | null = null; + +/** + * Initializes all services. + */ +function initializeServices(): void { + logger.info('Initializing services...'); + + // Initialize services (paths are set automatically from environment) + projectScanner = new ProjectScanner(); + sessionParser = new SessionParser(projectScanner); + subagentResolver = new SubagentResolver(projectScanner); + chunkBuilder = new ChunkBuilder(); + const disableCache = process.env.CLAUDE_CONTEXT_DISABLE_CACHE === '1'; + dataCache = new DataCache(MAX_CACHE_SESSIONS, CACHE_TTL_MINUTES, !disableCache); + + logger.info(`Projects directory: ${projectScanner.getProjectsDir()}`); + + // Initialize IPC handlers + initializeIpcHandlers(projectScanner, sessionParser, subagentResolver, chunkBuilder, dataCache); + + // Initialize notification manager using singleton pattern + // This ensures IPC handlers and FileWatcher use the same instance + // Note: mainWindow will be set later via setMainWindow() when window is created + notificationManager = NotificationManager.getInstance(); + + // Start file watcher with notification manager for error detection + fileWatcher = new FileWatcher(dataCache); + fileWatcher.setNotificationManager(notificationManager); + fileWatcher.start(); + + // Forward file change events to renderer + // Note: Error detection is handled internally by FileWatcher via NotificationManager + fileWatcher.on('file-change', (event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('file-change', event); + } + }); + + fileWatcher.on('todo-change', (event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('todo-change', event); + } + }); + + // Start automatic cache cleanup + cleanupInterval = dataCache.startAutoCleanup(CACHE_CLEANUP_INTERVAL_MINUTES); + + logger.info('Services initialized successfully'); +} + +/** + * Shuts down all services. + */ +function shutdownServices(): void { + logger.info('Shutting down services...'); + + // Stop file watcher + if (fileWatcher) { + fileWatcher.stop(); + } + + // Stop cache cleanup + if (cleanupInterval) { + clearInterval(cleanupInterval); + cleanupInterval = null; + } + + // Remove IPC handlers + removeIpcHandlers(); + + logger.info('Services shut down successfully'); +} + +/** + * Update native traffic-light position and notify renderer of the current zoom factor. + */ +function syncTrafficLightPosition(win: BrowserWindow): void { + const zoomFactor = win.webContents.getZoomFactor(); + const position = getTrafficLightPositionForZoom(zoomFactor); + win.setWindowButtonPosition(position); + win.webContents.send(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor); +} + +/** + * Creates the main application window. + */ +function createWindow(): void { + mainWindow = new BrowserWindow({ + width: DEFAULT_WINDOW_WIDTH, + height: DEFAULT_WINDOW_HEIGHT, + icon: getIconPath(), + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + nodeIntegration: false, + contextIsolation: true, + }, + backgroundColor: '#1a1a1a', + titleBarStyle: 'hidden', + trafficLightPosition: getTrafficLightPositionForZoom(1), + title: 'Claude Code Context', + }); + + // Load the renderer + if (process.env.NODE_ENV === 'development') { + void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`); + mainWindow.webContents.openDevTools(); + } else { + void mainWindow.loadFile(join(__dirname, '../renderer/index.html')); + } + + // Set traffic light position + notify renderer on first load + mainWindow.webContents.on('did-finish-load', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + syncTrafficLightPosition(mainWindow); + } + }); + + // Sync traffic light position when zoom changes (Cmd+/-, Cmd+0) + // zoom-changed event doesn't fire in Electron 40, so we detect zoom keys directly. + // Also keeps zoom bounds within a practical readability range. + const MIN_ZOOM_LEVEL = -3; // ~70% + const MAX_ZOOM_LEVEL = 5; + const ZOOM_IN_KEYS = new Set(['+', '=']); + const ZOOM_OUT_KEYS = new Set(['-', '_']); + mainWindow.webContents.on('before-input-event', (event, input) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + if (!input.meta || input.type !== 'keyDown') return; + + const currentLevel = mainWindow.webContents.getZoomLevel(); + + // Block zoom-out beyond minimum + if (ZOOM_OUT_KEYS.has(input.key) && currentLevel <= MIN_ZOOM_LEVEL) { + event.preventDefault(); + return; + } + // Block zoom-in beyond maximum + if (ZOOM_IN_KEYS.has(input.key) && currentLevel >= MAX_ZOOM_LEVEL) { + event.preventDefault(); + return; + } + + // For zoom keys (including Cmd+0 reset), defer sync until zoom is applied + if (ZOOM_IN_KEYS.has(input.key) || ZOOM_OUT_KEYS.has(input.key) || input.key === '0') { + setTimeout(() => { + if (mainWindow && !mainWindow.isDestroyed()) { + syncTrafficLightPosition(mainWindow); + } + }, 100); + } + }); + + mainWindow.on('closed', () => { + mainWindow = null; + // Clear main window reference from notification manager + if (notificationManager) { + notificationManager.setMainWindow(null); + } + }); + + // Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event) + mainWindow.webContents.on('render-process-gone', (_event, details) => { + logger.error('Renderer process gone:', details.reason, details.exitCode); + // Could show an error dialog or attempt to reload the window + }); + + // Set main window reference for notification manager + if (notificationManager) { + notificationManager.setMainWindow(mainWindow); + } + + logger.info('Main window created'); +} + +/** + * Application ready handler. + */ +void app.whenReady().then(() => { + logger.info('App ready, initializing...'); + + // Initialize services first + initializeServices(); + + // Apply configuration settings + const config = configManager.getConfig(); + + // Apply launch at login setting + app.setLoginItemSettings({ + openAtLogin: config.general.launchAtLogin, + }); + + // Apply dock visibility and icon (macOS) + if (process.platform === 'darwin') { + if (!config.general.showDockIcon) { + app.dock?.hide(); + } + // Set dock icon + app.dock?.setIcon(getIconPath()); + } + + // Then create window + createWindow(); + + // Listen for notification click events + notificationManager.on('notification-clicked', (_error) => { + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + } + }); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } + }); +}); + +/** + * All windows closed handler. + */ +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +/** + * Before quit handler - cleanup. + */ +app.on('before-quit', () => { + shutdownServices(); +}); diff --git a/src/main/ipc/CLAUDE.md b/src/main/ipc/CLAUDE.md new file mode 100644 index 00000000..1462ee87 --- /dev/null +++ b/src/main/ipc/CLAUDE.md @@ -0,0 +1,57 @@ +# IPC Handlers + +Domain-organized IPC request handlers for main process. + +## Structure +``` +ipc/ +├── handlers.ts # Initialization and registration +├── config.ts # App configuration handlers +├── configValidation.ts # Config input validation/sanitization +├── guards.ts # IPC argument type guards +├── notifications.ts # Notification management +├── projects.ts # Project listing, repository grouping +├── search.ts # Session content search +├── sessions.ts # Session operations, pagination +├── subagents.ts # Subagent detail drill-down +├── utility.ts # Shell operations, file reading +└── validation.ts # Path validation, file mentioning +``` + +## Handler Pattern +Each domain module exports: +```typescript +// Setup with services +initialize{Domain}Handlers(services) + +// Register with ipcMain +register{Domain}Handlers(ipcMain) + +// Cleanup on app quit +remove{Domain}Handlers(ipcMain) +``` + +## Service Dependencies +`initializeIpcHandlers()` receives service instances: +- `ProjectScanner` - File system scanning +- `SessionParser` - JSONL parsing +- `SubagentResolver` - Subagent linking +- `ChunkBuilder` - Chunk analysis +- `DataCache` - Result caching + +## Response Pattern +Config handlers use `IpcResult` wrapper: +```typescript +return { success: true, data: result }; +return { success: false, error: message }; +``` + +Other handlers return data directly or `null` on error. + +## Adding New Handler +1. Add to existing domain file or create new file +2. Call `initialize{Domain}Handlers()` if new domain +3. Add `register/remove{Domain}Handlers` in `handlers.ts` +4. Add channel constant in `preload/constants/ipcChannels.ts` +5. Add method to ElectronAPI in `preload/index.ts` +6. Implement service logic in `src/main/services/` diff --git a/src/main/ipc/config.ts b/src/main/ipc/config.ts new file mode 100644 index 00000000..ecb2976c --- /dev/null +++ b/src/main/ipc/config.ts @@ -0,0 +1,628 @@ +/** + * IPC Handlers for App Configuration. + * + * Handlers: + * - config:get: Get full app configuration + * - config:update: Update a specific config section + * - config:addIgnoreRegex: Add an ignore pattern for notifications + * - config:removeIgnoreRegex: Remove an ignore pattern + * - config:addIgnoreRepository: Add a repository to ignore list + * - config:removeIgnoreRepository: Remove a repository from ignore list + * - config:snooze: Set snooze duration for notifications + * - config:clearSnooze: Clear the snooze timer + * - config:addTrigger: Add a new notification trigger + * - config:updateTrigger: Update an existing notification trigger + * - config:removeTrigger: Remove a notification trigger + * - config:getTriggers: Get all notification triggers + * - config:testTrigger: Test a trigger against historical session data + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { execFile } from 'child_process'; +import { BrowserWindow, dialog, type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { + type AppConfig, + ConfigManager, + type NotificationTrigger, + type TriggerContentType, + type TriggerMatchField, + type TriggerMode, + type TriggerTokenType, +} from '../services'; + +import { validateConfigUpdatePayload } from './configValidation'; +import { validateTriggerId } from './guards'; + +import type { TriggerColor } from '@shared/constants/triggerColors'; + +const logger = createLogger('IPC:config'); + +// Get singleton instance +const configManager = ConfigManager.getInstance(); + +/** + * Response type for config operations + */ +interface ConfigResult { + success: boolean; + data?: T; + error?: string; +} + +/** + * Registers all config-related IPC handlers. + */ +export function registerConfigHandlers(ipcMain: IpcMain): void { + // Get full configuration + ipcMain.handle('config:get', handleGetConfig); + + // Update configuration section + ipcMain.handle('config:update', handleUpdateConfig); + + // Ignore regex pattern handlers + ipcMain.handle('config:addIgnoreRegex', handleAddIgnoreRegex); + ipcMain.handle('config:removeIgnoreRegex', handleRemoveIgnoreRegex); + + // Ignore repository handlers + ipcMain.handle('config:addIgnoreRepository', handleAddIgnoreRepository); + ipcMain.handle('config:removeIgnoreRepository', handleRemoveIgnoreRepository); + + // Snooze handlers + ipcMain.handle('config:snooze', handleSnooze); + ipcMain.handle('config:clearSnooze', handleClearSnooze); + + // Trigger management handlers + ipcMain.handle('config:addTrigger', handleAddTrigger); + ipcMain.handle('config:updateTrigger', handleUpdateTrigger); + ipcMain.handle('config:removeTrigger', handleRemoveTrigger); + ipcMain.handle('config:getTriggers', handleGetTriggers); + ipcMain.handle('config:testTrigger', handleTestTrigger); + + // Session pin handlers + ipcMain.handle('config:pinSession', handlePinSession); + ipcMain.handle('config:unpinSession', handleUnpinSession); + + // Dialog handlers + ipcMain.handle('config:selectFolders', handleSelectFolders); + + // Editor handlers + ipcMain.handle('config:openInEditor', handleOpenInEditor); + + logger.info('Config handlers registered (including trigger management)'); +} + +// ============================================================================= +// Handler Functions +// ============================================================================= + +/** + * Handler for 'config:get' IPC call. + * Returns the full app configuration. + */ +async function handleGetConfig(_event: IpcMainInvokeEvent): Promise> { + try { + const config = configManager.getConfig(); + return { success: true, data: config }; + } catch (error) { + logger.error('Error in config:get:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:update' IPC call. + * Updates a specific section of the configuration. + * Returns the full updated config. + */ +async function handleUpdateConfig( + _event: IpcMainInvokeEvent, + section: unknown, + data: unknown +): Promise> { + try { + const validation = validateConfigUpdatePayload(section, data); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + + configManager.updateConfig(validation.section, validation.data); + const updatedConfig = configManager.getConfig(); + return { success: true, data: updatedConfig }; + } catch (error) { + logger.error('Error in config:update:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:addIgnoreRegex' IPC call. + * Adds a regex pattern to the notification ignore list. + */ +async function handleAddIgnoreRegex( + _event: IpcMainInvokeEvent, + pattern: string +): Promise { + try { + if (!pattern || typeof pattern !== 'string') { + return { success: false, error: 'Pattern is required and must be a string' }; + } + + // Validate that the pattern is a valid regex + try { + new RegExp(pattern); + } catch { + return { success: false, error: 'Invalid regex pattern' }; + } + + configManager.addIgnoreRegex(pattern); + return { success: true }; + } catch (error) { + logger.error('Error in config:addIgnoreRegex:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:removeIgnoreRegex' IPC call. + * Removes a regex pattern from the notification ignore list. + */ +async function handleRemoveIgnoreRegex( + _event: IpcMainInvokeEvent, + pattern: string +): Promise { + try { + if (!pattern || typeof pattern !== 'string') { + return { success: false, error: 'Pattern is required and must be a string' }; + } + + configManager.removeIgnoreRegex(pattern); + return { success: true }; + } catch (error) { + logger.error('Error in config:removeIgnoreRegex:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:addIgnoreRepository' IPC call. + * Adds a repository to the notification ignore list. + */ +async function handleAddIgnoreRepository( + _event: IpcMainInvokeEvent, + repositoryId: string +): Promise { + try { + if (!repositoryId || typeof repositoryId !== 'string') { + return { success: false, error: 'Repository ID is required and must be a string' }; + } + + configManager.addIgnoreRepository(repositoryId); + return { success: true }; + } catch (error) { + logger.error('Error in config:addIgnoreRepository:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:removeIgnoreRepository' IPC call. + * Removes a repository from the notification ignore list. + */ +async function handleRemoveIgnoreRepository( + _event: IpcMainInvokeEvent, + repositoryId: string +): Promise { + try { + if (!repositoryId || typeof repositoryId !== 'string') { + return { success: false, error: 'Repository ID is required and must be a string' }; + } + + configManager.removeIgnoreRepository(repositoryId); + return { success: true }; + } catch (error) { + logger.error('Error in config:removeIgnoreRepository:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:snooze' IPC call. + * Sets the snooze timer for notifications. + */ +async function handleSnooze(_event: IpcMainInvokeEvent, minutes: number): Promise { + try { + if (typeof minutes !== 'number' || minutes <= 0 || minutes > 24 * 60) { + return { success: false, error: 'Minutes must be a positive number' }; + } + + configManager.setSnooze(minutes); + return { success: true }; + } catch (error) { + logger.error('Error in config:snooze:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:clearSnooze' IPC call. + * Clears the snooze timer. + */ +async function handleClearSnooze(_event: IpcMainInvokeEvent): Promise { + try { + configManager.clearSnooze(); + return { success: true }; + } catch (error) { + logger.error('Error in config:clearSnooze:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:addTrigger' - Adds a new notification trigger. + */ +async function handleAddTrigger( + _event: IpcMainInvokeEvent, + trigger: { + id: string; + name: string; + enabled: boolean; + contentType: string; + mode?: TriggerMode; + requireError?: boolean; + toolName?: string; + matchField?: string; + matchPattern?: string; + ignorePatterns?: string[]; + tokenThreshold?: number; + tokenType?: TriggerTokenType; + repositoryIds?: string[]; + color?: string; + } +): Promise { + try { + if (!trigger.id || !trigger.name || !trigger.contentType) { + return { + success: false, + error: 'Trigger must have id, name, and contentType', + }; + } + + configManager.addTrigger({ + id: trigger.id, + name: trigger.name, + enabled: trigger.enabled, + contentType: trigger.contentType as TriggerContentType, + mode: trigger.mode ?? (trigger.requireError ? 'error_status' : 'content_match'), + requireError: trigger.requireError, + toolName: trigger.toolName, + matchField: trigger.matchField as TriggerMatchField | undefined, + matchPattern: trigger.matchPattern, + ignorePatterns: trigger.ignorePatterns, + tokenThreshold: trigger.tokenThreshold, + tokenType: trigger.tokenType, + repositoryIds: trigger.repositoryIds, + color: trigger.color as TriggerColor | undefined, + isBuiltin: false, + }); + + return { success: true }; + } catch (error) { + logger.error('Error in config:addTrigger:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to add trigger', + }; + } +} + +/** + * Handler for 'config:updateTrigger' - Updates an existing notification trigger. + */ +async function handleUpdateTrigger( + _event: IpcMainInvokeEvent, + triggerId: string, + updates: Partial<{ + name: string; + enabled: boolean; + contentType: string; + requireError: boolean; + toolName: string; + matchField: string; + matchPattern: string; + ignorePatterns: string[]; + mode: TriggerMode; + tokenThreshold: number; + tokenType: TriggerTokenType; + repositoryIds: string[]; + color: string; + }> +): Promise { + try { + const validatedTriggerId = validateTriggerId(triggerId); + if (!validatedTriggerId.valid) { + return { + success: false, + error: validatedTriggerId.error ?? 'Trigger ID is required', + }; + } + + configManager.updateTrigger(validatedTriggerId.value!, updates as Partial); + + return { success: true }; + } catch (error) { + logger.error('Error in config:updateTrigger:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update trigger', + }; + } +} + +/** + * Handler for 'config:removeTrigger' - Removes a notification trigger. + */ +async function handleRemoveTrigger( + _event: IpcMainInvokeEvent, + triggerId: string +): Promise { + try { + const validatedTriggerId = validateTriggerId(triggerId); + if (!validatedTriggerId.valid) { + return { + success: false, + error: validatedTriggerId.error ?? 'Trigger ID is required', + }; + } + + configManager.removeTrigger(validatedTriggerId.value!); + + return { success: true }; + } catch (error) { + logger.error('Error in config:removeTrigger:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to remove trigger', + }; + } +} + +/** + * Handler for 'config:getTriggers' - Gets all notification triggers. + */ +async function handleGetTriggers( + _event: IpcMainInvokeEvent +): Promise> { + try { + const triggers = configManager.getTriggers(); + + return { success: true, data: triggers }; + } catch (error) { + logger.error('Error in config:getTriggers:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get triggers', + }; + } +} + +/** + * Handler for 'config:testTrigger' - Tests a trigger against historical session data. + * Returns errors that would have been detected by the trigger. + * + * Safety: Results are truncated if: + * - More than 10,000 total matches found + * - More than 100 sessions scanned + * - Test runs longer than 30 seconds + */ +async function handleTestTrigger( + _event: IpcMainInvokeEvent, + trigger: NotificationTrigger +): Promise< + ConfigResult<{ + totalCount: number; + errors: { + id: string; + sessionId: string; + projectId: string; + message: string; + timestamp: number; + source: string; + toolUseId?: string; + subagentId?: string; + lineNumber?: number; + context: { projectName: string }; + }[]; + /** True if results were truncated due to safety limits */ + truncated?: boolean; + }> +> { + try { + const { errorDetector } = await import('../services'); + const result = await errorDetector.testTrigger(trigger, 50); + + // Map the DetectedError objects to the format expected by the renderer + // Include toolUseId, subagentId, and lineNumber for deep linking to exact error location + const errors = result.errors.map((error) => ({ + id: error.id, + sessionId: error.sessionId, + projectId: error.projectId, + message: error.message, + timestamp: error.timestamp, + source: error.source, + toolUseId: error.toolUseId, + subagentId: error.subagentId, + lineNumber: error.lineNumber, + context: { projectName: error.context.projectName }, + })); + + return { + success: true, + data: { totalCount: result.totalCount, errors, truncated: result.truncated }, + }; + } catch (error) { + logger.error('Error in config:testTrigger:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to test trigger', + }; + } +} + +/** + * Handler for 'config:pinSession' - Pins a session for a project. + */ +async function handlePinSession( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.pinSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in config:pinSession:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:unpinSession' - Unpins a session for a project. + */ +async function handleUnpinSession( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + if (!projectId || typeof projectId !== 'string') { + return { success: false, error: 'Project ID is required and must be a string' }; + } + if (!sessionId || typeof sessionId !== 'string') { + return { success: false, error: 'Session ID is required and must be a string' }; + } + + configManager.unpinSession(projectId, sessionId); + return { success: true }; + } catch (error) { + logger.error('Error in config:unpinSession:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:openInEditor' - Opens the config JSON file in an external editor. + * Tries editors in order: $VISUAL, $EDITOR, cursor, code, then falls back to system open. + */ +async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise { + try { + const configPath = configManager.getConfigPath(); + + // Try editors in priority order + const editors: string[] = []; + if (process.env.VISUAL) editors.push(process.env.VISUAL); + if (process.env.EDITOR) editors.push(process.env.EDITOR); + editors.push('cursor', 'code', 'subl', 'zed'); + + for (const editor of editors) { + try { + await new Promise((resolve, reject) => { + const child = execFile(editor, [configPath], { timeout: 5000 }); + // If the process spawns successfully, resolve after a short delay + // (editors typically fork and the parent exits quickly) + const timer = setTimeout(() => resolve(), 500); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); + return { success: true }; + } catch { + // Editor not found, try next + continue; + } + } + + // Fallback: open with system default + const { shell } = await import('electron'); + const errorMessage = await shell.openPath(configPath); + if (errorMessage) { + return { success: false, error: errorMessage }; + } + return { success: true }; + } catch (error) { + logger.error('Error in config:openInEditor:', error); + return { success: false, error: getErrorMessage(error) }; + } +} + +/** + * Handler for 'config:selectFolders' - Opens native folder selection dialog. + * Allows users to select one or more folders for trigger project scope. + */ +async function handleSelectFolders(_event: IpcMainInvokeEvent): Promise> { + try { + // Get the focused window for proper dialog parenting + const focusedWindow = BrowserWindow.getFocusedWindow(); + + // dialog.showOpenDialog accepts either (options) or (window, options) + const dialogOptions: Electron.OpenDialogOptions = { + properties: ['openDirectory', 'multiSelections'], + title: 'Select Project Folders', + buttonLabel: 'Select', + }; + + const result = focusedWindow + ? await dialog.showOpenDialog(focusedWindow, dialogOptions) + : await dialog.showOpenDialog(dialogOptions); + + if (result.canceled) { + return { success: true, data: [] }; + } + + return { success: true, data: result.filePaths }; + } catch (error) { + logger.error('Error in config:selectFolders:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to open folder dialog', + }; + } +} + +// ============================================================================= +// Cleanup +// ============================================================================= + +/** + * Removes all config-related IPC handlers. + * Should be called when shutting down. + */ +export function removeConfigHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('config:get'); + ipcMain.removeHandler('config:update'); + ipcMain.removeHandler('config:addIgnoreRegex'); + ipcMain.removeHandler('config:removeIgnoreRegex'); + ipcMain.removeHandler('config:addIgnoreRepository'); + ipcMain.removeHandler('config:removeIgnoreRepository'); + ipcMain.removeHandler('config:snooze'); + ipcMain.removeHandler('config:clearSnooze'); + ipcMain.removeHandler('config:addTrigger'); + ipcMain.removeHandler('config:updateTrigger'); + ipcMain.removeHandler('config:removeTrigger'); + ipcMain.removeHandler('config:getTriggers'); + ipcMain.removeHandler('config:testTrigger'); + ipcMain.removeHandler('config:pinSession'); + ipcMain.removeHandler('config:unpinSession'); + ipcMain.removeHandler('config:selectFolders'); + ipcMain.removeHandler('config:openInEditor'); + logger.info('Config handlers removed'); +} diff --git a/src/main/ipc/configValidation.ts b/src/main/ipc/configValidation.ts new file mode 100644 index 00000000..205ae670 --- /dev/null +++ b/src/main/ipc/configValidation.ts @@ -0,0 +1,292 @@ +/** + * Runtime validation for config:update IPC payloads. + * Prevents invalid/unknown data from mutating persisted config. + */ + +import type { + AppConfig, + DisplayConfig, + GeneralConfig, + NotificationConfig, + NotificationTrigger, +} from '../services'; + +type ConfigSection = keyof AppConfig; + +interface ValidationSuccess { + valid: true; + section: K; + data: Partial; +} + +interface ValidationFailure { + valid: false; + error: string; +} + +export type ConfigUpdateValidationResult = + | ValidationSuccess<'notifications'> + | ValidationSuccess<'general'> + | ValidationSuccess<'display'> + | ValidationFailure; + +const VALID_SECTIONS = new Set(['notifications', 'general', 'display']); +const MAX_SNOOZE_MINUTES = 24 * 60; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function isValidTrigger(trigger: unknown): trigger is NotificationTrigger { + if (!isPlainObject(trigger)) { + return false; + } + + if (typeof trigger.id !== 'string' || trigger.id.trim().length === 0) { + return false; + } + + if (typeof trigger.name !== 'string' || trigger.name.trim().length === 0) { + return false; + } + + if (typeof trigger.enabled !== 'boolean') { + return false; + } + + if ( + trigger.contentType !== 'tool_result' && + trigger.contentType !== 'tool_use' && + trigger.contentType !== 'thinking' && + trigger.contentType !== 'text' + ) { + return false; + } + + if ( + trigger.mode !== 'error_status' && + trigger.mode !== 'content_match' && + trigger.mode !== 'token_threshold' + ) { + return false; + } + + return true; +} + +function validateNotificationsSection( + data: unknown +): ValidationSuccess<'notifications'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'notifications update must be an object' }; + } + + const allowedKeys: (keyof NotificationConfig)[] = [ + 'enabled', + 'soundEnabled', + 'includeSubagentErrors', + 'ignoredRegex', + 'ignoredRepositories', + 'snoozedUntil', + 'snoozeMinutes', + 'triggers', + ]; + + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (!allowedKeys.includes(key as keyof NotificationConfig)) { + return { + valid: false, + error: `notifications.${key} is not supported via config:update`, + }; + } + + switch (key as keyof NotificationConfig) { + case 'enabled': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.enabled = value; + break; + case 'soundEnabled': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.soundEnabled = value; + break; + case 'includeSubagentErrors': + if (typeof value !== 'boolean') { + return { valid: false, error: `notifications.${key} must be a boolean` }; + } + result.includeSubagentErrors = value; + break; + case 'ignoredRegex': + if (!isStringArray(value)) { + return { valid: false, error: `notifications.${key} must be a string[]` }; + } + result.ignoredRegex = value; + break; + case 'ignoredRepositories': + if (!isStringArray(value)) { + return { valid: false, error: `notifications.${key} must be a string[]` }; + } + result.ignoredRepositories = value; + break; + case 'snoozedUntil': + if (value !== null && !isFiniteNumber(value)) { + return { valid: false, error: 'notifications.snoozedUntil must be a number or null' }; + } + if (typeof value === 'number' && value < 0) { + return { valid: false, error: 'notifications.snoozedUntil must be >= 0' }; + } + result.snoozedUntil = value; + break; + case 'snoozeMinutes': + if (!isFiniteNumber(value) || !Number.isInteger(value)) { + return { valid: false, error: 'notifications.snoozeMinutes must be an integer' }; + } + if (value <= 0 || value > MAX_SNOOZE_MINUTES) { + return { + valid: false, + error: `notifications.snoozeMinutes must be between 1 and ${MAX_SNOOZE_MINUTES}`, + }; + } + result.snoozeMinutes = value; + break; + case 'triggers': + if (!Array.isArray(value) || !value.every((trigger) => isValidTrigger(trigger))) { + return { valid: false, error: 'notifications.triggers must be a valid trigger[]' }; + } + result.triggers = value; + break; + default: + return { valid: false, error: `Unsupported notifications key: ${key}` }; + } + } + + return { + valid: true, + section: 'notifications', + data: result, + }; +} + +function validateGeneralSection(data: unknown): ValidationSuccess<'general'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'general update must be an object' }; + } + + const allowedKeys: (keyof GeneralConfig)[] = [ + 'launchAtLogin', + 'showDockIcon', + 'theme', + 'defaultTab', + ]; + + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (!allowedKeys.includes(key as keyof GeneralConfig)) { + return { valid: false, error: `general.${key} is not a valid setting` }; + } + + switch (key as keyof GeneralConfig) { + case 'launchAtLogin': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.launchAtLogin = value; + break; + case 'showDockIcon': + if (typeof value !== 'boolean') { + return { valid: false, error: `general.${key} must be a boolean` }; + } + result.showDockIcon = value; + break; + case 'theme': + if (value !== 'dark' && value !== 'light' && value !== 'system') { + return { valid: false, error: 'general.theme must be one of: dark, light, system' }; + } + result.theme = value; + break; + case 'defaultTab': + if (value !== 'dashboard' && value !== 'last-session') { + return { + valid: false, + error: 'general.defaultTab must be one of: dashboard, last-session', + }; + } + result.defaultTab = value; + break; + default: + return { valid: false, error: `Unsupported general key: ${key}` }; + } + } + + return { + valid: true, + section: 'general', + data: result, + }; +} + +function validateDisplaySection(data: unknown): ValidationSuccess<'display'> | ValidationFailure { + if (!isPlainObject(data)) { + return { valid: false, error: 'display update must be an object' }; + } + + const allowedKeys: (keyof DisplayConfig)[] = [ + 'showTimestamps', + 'compactMode', + 'syntaxHighlighting', + ]; + + const result: Partial = {}; + + for (const [key, value] of Object.entries(data)) { + if (!allowedKeys.includes(key as keyof DisplayConfig)) { + return { valid: false, error: `display.${key} is not a valid setting` }; + } + + if (typeof value !== 'boolean') { + return { valid: false, error: `display.${key} must be a boolean` }; + } + + result[key as keyof DisplayConfig] = value; + } + + return { + valid: true, + section: 'display', + data: result, + }; +} + +export function validateConfigUpdatePayload( + section: unknown, + data: unknown +): ConfigUpdateValidationResult { + if (typeof section !== 'string' || !VALID_SECTIONS.has(section as ConfigSection)) { + return { valid: false, error: 'Section must be one of: notifications, general, display' }; + } + + switch (section as ConfigSection) { + case 'notifications': + return validateNotificationsSection(data); + case 'general': + return validateGeneralSection(data); + case 'display': + return validateDisplaySection(data); + default: + return { valid: false, error: 'Invalid section' }; + } +} diff --git a/src/main/ipc/guards.ts b/src/main/ipc/guards.ts new file mode 100644 index 00000000..b544c151 --- /dev/null +++ b/src/main/ipc/guards.ts @@ -0,0 +1,148 @@ +/** + * IPC guard utilities for runtime validation and coercion. + * + * Main goals: + * - Reject malformed IDs and unbounded inputs at IPC boundaries + * - Keep validation logic consistent across handlers + */ + +import { isValidProjectId } from '@main/utils/pathDecoder'; + +const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const NOTIFICATION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; +const TRIGGER_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/; + +const MAX_QUERY_LENGTH = 512; +const MAX_RESULTS = 200; +const MAX_PAGE_LIMIT = 200; + +interface ValidationResult { + valid: boolean; + value?: T; + error?: string; +} + +function validateString( + value: unknown, + fieldName: string, + maxLength: number = 256 +): ValidationResult { + if (typeof value !== 'string') { + return { valid: false, error: `${fieldName} must be a string` }; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return { valid: false, error: `${fieldName} cannot be empty` }; + } + + if (trimmed.length > maxLength) { + return { valid: false, error: `${fieldName} exceeds max length (${maxLength})` }; + } + + return { valid: true, value: trimmed }; +} + +export function validateProjectId(projectId: unknown): ValidationResult { + const basic = validateString(projectId, 'projectId'); + if (!basic.valid) { + return basic; + } + + if (!isValidProjectId(basic.value!)) { + return { valid: false, error: 'projectId is not a valid encoded Claude project path' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateSessionId(sessionId: unknown): ValidationResult { + const basic = validateString(sessionId, 'sessionId', 128); + if (!basic.valid) { + return basic; + } + + if (!SESSION_ID_PATTERN.test(basic.value!)) { + return { valid: false, error: 'sessionId contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateSubagentId(subagentId: unknown): ValidationResult { + const basic = validateString(subagentId, 'subagentId', 128); + if (!basic.valid) { + return basic; + } + + if (!SUBAGENT_ID_PATTERN.test(basic.value!)) { + return { valid: false, error: 'subagentId contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateNotificationId(notificationId: unknown): ValidationResult { + const basic = validateString(notificationId, 'notificationId', 128); + if (!basic.valid) { + return basic; + } + + if (!NOTIFICATION_ID_PATTERN.test(basic.value!)) { + return { valid: false, error: 'notificationId contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateTriggerId(triggerId: unknown): ValidationResult { + const basic = validateString(triggerId, 'triggerId', 128); + if (!basic.valid) { + return basic; + } + + if (!TRIGGER_ID_PATTERN.test(basic.value!)) { + return { valid: false, error: 'triggerId contains invalid characters' }; + } + + return { valid: true, value: basic.value }; +} + +export function validateSearchQuery(query: unknown): ValidationResult { + if (typeof query !== 'string') { + return { valid: false, error: 'query must be a string' }; + } + + const trimmed = query.trim(); + if (trimmed.length === 0) { + return { valid: false, error: 'query cannot be empty' }; + } + + if (trimmed.length > MAX_QUERY_LENGTH) { + return { valid: false, error: `query exceeds max length (${MAX_QUERY_LENGTH})` }; + } + + return { valid: true, value: trimmed }; +} + +function coerceLimit(value: unknown, defaultValue: number, maxValue: number): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return defaultValue; + } + + const normalized = Math.floor(value); + if (normalized <= 0) { + return defaultValue; + } + + return Math.min(normalized, maxValue); +} + +export function coerceSearchMaxResults(value: unknown, defaultValue: number = 50): number { + return coerceLimit(value, defaultValue, MAX_RESULTS); +} + +export function coercePageLimit(value: unknown, defaultValue: number = 20): number { + return coerceLimit(value, defaultValue, MAX_PAGE_LIMIT); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts new file mode 100644 index 00000000..cdc6b905 --- /dev/null +++ b/src/main/ipc/handlers.ts @@ -0,0 +1,93 @@ +/** + * IPC Handlers - Orchestrates domain-specific handler modules. + * + * This module initializes and registers all IPC handlers from domain modules: + * - projects.ts: Project listing and repository groups + * - sessions.ts: Session operations and pagination + * - search.ts: Session search functionality + * - subagents.ts: Subagent detail retrieval + * - validation.ts: Path validation and scroll handling + * - utility.ts: Shell operations and file reading + * - notifications.ts: Notification management + * - config.ts: App configuration + */ + +import { createLogger } from '@shared/utils/logger'; +import { ipcMain } from 'electron'; + +import { registerConfigHandlers, removeConfigHandlers } from './config'; + +const logger = createLogger('IPC:handlers'); +import { registerNotificationHandlers, removeNotificationHandlers } from './notifications'; +import { + initializeProjectHandlers, + registerProjectHandlers, + removeProjectHandlers, +} from './projects'; +import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search'; +import { + initializeSessionHandlers, + registerSessionHandlers, + removeSessionHandlers, +} from './sessions'; +import { + initializeSubagentHandlers, + registerSubagentHandlers, + removeSubagentHandlers, +} from './subagents'; +import { registerUtilityHandlers, removeUtilityHandlers } from './utility'; +import { registerValidationHandlers, removeValidationHandlers } from './validation'; + +import type { + ChunkBuilder, + DataCache, + ProjectScanner, + SessionParser, + SubagentResolver, +} from '../services'; + +/** + * Initializes IPC handlers with service instances. + */ +export function initializeIpcHandlers( + scanner: ProjectScanner, + parser: SessionParser, + resolver: SubagentResolver, + builder: ChunkBuilder, + cache: DataCache +): void { + // Initialize domain handlers with their required services + initializeProjectHandlers(scanner); + initializeSessionHandlers(scanner, parser, resolver, builder, cache); + initializeSearchHandlers(scanner); + initializeSubagentHandlers(builder, cache, parser, resolver); + + // Register all handlers + registerProjectHandlers(ipcMain); + registerSessionHandlers(ipcMain); + registerSearchHandlers(ipcMain); + registerSubagentHandlers(ipcMain); + registerValidationHandlers(ipcMain); + registerUtilityHandlers(ipcMain); + registerNotificationHandlers(ipcMain); + registerConfigHandlers(ipcMain); + + logger.info('All handlers registered'); +} + +/** + * Removes all IPC handlers. + * Should be called when shutting down. + */ +export function removeIpcHandlers(): void { + removeProjectHandlers(ipcMain); + removeSessionHandlers(ipcMain); + removeSearchHandlers(ipcMain); + removeSubagentHandlers(ipcMain); + removeValidationHandlers(ipcMain); + removeUtilityHandlers(ipcMain); + removeNotificationHandlers(ipcMain); + removeConfigHandlers(ipcMain); + + logger.info('All handlers removed'); +} diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts new file mode 100644 index 00000000..7f1afe8e --- /dev/null +++ b/src/main/ipc/notifications.ts @@ -0,0 +1,186 @@ +/** + * IPC Handlers for Notification Operations. + * + * Handlers: + * - notifications:get: Get all notifications (paginated) + * - notifications:markRead: Mark notification as read + * - notifications:markAllRead: Mark all as read + * - notifications:delete: Delete a single notification + * - notifications:clear: Clear all notifications + * - notifications:getUnreadCount: Get unread count for badge + */ + +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { + type GetNotificationsOptions, + type GetNotificationsResult, + NotificationManager, +} from '../services'; + +import { coercePageLimit, validateNotificationId } from './guards'; + +const logger = createLogger('IPC:notifications'); + +/** + * Registers all notification-related IPC handlers. + * + * @param ipcMain - The Electron IpcMain instance + */ +export function registerNotificationHandlers(ipcMain: IpcMain): void { + ipcMain.handle('notifications:get', handleGetNotifications); + ipcMain.handle('notifications:markRead', handleMarkRead); + ipcMain.handle('notifications:markAllRead', handleMarkAllRead); + ipcMain.handle('notifications:delete', handleDelete); + ipcMain.handle('notifications:clear', handleClear); + ipcMain.handle('notifications:getUnreadCount', handleGetUnreadCount); + + logger.info('Notification handlers registered'); +} + +/** + * Removes all notification IPC handlers. + * Should be called when shutting down. + */ +export function removeNotificationHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('notifications:get'); + ipcMain.removeHandler('notifications:markRead'); + ipcMain.removeHandler('notifications:markAllRead'); + ipcMain.removeHandler('notifications:delete'); + ipcMain.removeHandler('notifications:clear'); + ipcMain.removeHandler('notifications:getUnreadCount'); + + logger.info('Notification handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'notifications:get' IPC call. + * Gets all notifications with optional pagination and filtering. + */ +async function handleGetNotifications( + _event: IpcMainInvokeEvent, + options?: GetNotificationsOptions +): Promise { + try { + const opts = options ?? {}; + const safeOptions: GetNotificationsOptions = { + limit: coercePageLimit(opts.limit, 20), + offset: + typeof opts.offset === 'number' && Number.isFinite(opts.offset) && opts.offset >= 0 + ? Math.floor(opts.offset) + : 0, + }; + const manager = NotificationManager.getInstance(); + const result = await manager.getNotifications(safeOptions); + return result; + } catch (error) { + logger.error('Error in notifications:get:', getErrorMessage(error)); + return { + notifications: [], + total: 0, + totalCount: 0, + unreadCount: 0, + hasMore: false, + }; + } +} + +/** + * Handler for 'notifications:markRead' IPC call. + * Marks a specific notification as read. + */ +async function handleMarkRead( + _event: IpcMainInvokeEvent, + notificationId: string +): Promise { + try { + const validatedNotification = validateNotificationId(notificationId); + if (!validatedNotification.valid) { + logger.error( + `notifications:markRead rejected: ${validatedNotification.error ?? 'Invalid notificationId'}` + ); + return false; + } + + const manager = NotificationManager.getInstance(); + const success = await manager.markRead(validatedNotification.value!); + return success; + } catch (error) { + logger.error(`Error in notifications:markRead for ${notificationId}:`, error); + return false; + } +} + +/** + * Handler for 'notifications:markAllRead' IPC call. + * Marks all notifications as read. + */ +async function handleMarkAllRead(_event: IpcMainInvokeEvent): Promise { + try { + const manager = NotificationManager.getInstance(); + const success = await manager.markAllRead(); + return success; + } catch (error) { + logger.error('Error in notifications:markAllRead:', error); + return false; + } +} + +/** + * Handler for 'notifications:delete' IPC call. + * Deletes a single notification. + */ +async function handleDelete(_event: IpcMainInvokeEvent, notificationId: string): Promise { + try { + const validatedNotification = validateNotificationId(notificationId); + if (!validatedNotification.valid) { + logger.error( + `notifications:delete rejected: ${validatedNotification.error ?? 'Invalid notificationId'}` + ); + return false; + } + + const manager = NotificationManager.getInstance(); + const success = manager.deleteNotification(validatedNotification.value!); + return success; + } catch (error) { + logger.error(`Error in notifications:delete for ${notificationId}:`, error); + return false; + } +} + +/** + * Handler for 'notifications:clear' IPC call. + * Clears all notifications. + */ +async function handleClear(_event: IpcMainInvokeEvent): Promise { + try { + const manager = NotificationManager.getInstance(); + const success = await manager.clearAll(); + return success; + } catch (error) { + logger.error('Error in notifications:clear:', error); + return false; + } +} + +/** + * Handler for 'notifications:getUnreadCount' IPC call. + * Gets the count of unread notifications for badge display. + */ +async function handleGetUnreadCount(_event: IpcMainInvokeEvent): Promise { + try { + const manager = NotificationManager.getInstance(); + const count = await manager.getUnreadCount(); + return count; + } catch (error) { + logger.error('Error in notifications:getUnreadCount:', error); + return 0; + } +} diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts new file mode 100644 index 00000000..21d96677 --- /dev/null +++ b/src/main/ipc/projects.ts @@ -0,0 +1,109 @@ +/** + * IPC Handlers for Project Operations. + * + * Handlers: + * - get-projects: List all projects + * - get-repository-groups: List projects grouped by git repository + * - get-worktree-sessions: List sessions for a specific worktree + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { type Project, type RepositoryGroup, type Session } from '../types'; + +import { validateProjectId } from './guards'; + +import type { ProjectScanner } from '../services'; + +const logger = createLogger('IPC:projects'); + +// Service instance - set via initialize +let projectScanner: ProjectScanner; + +/** + * Initializes project handlers with service instance. + */ +export function initializeProjectHandlers(scanner: ProjectScanner): void { + projectScanner = scanner; +} + +/** + * Registers all project-related IPC handlers. + */ +export function registerProjectHandlers(ipcMain: IpcMain): void { + ipcMain.handle('get-projects', handleGetProjects); + ipcMain.handle('get-repository-groups', handleGetRepositoryGroups); + ipcMain.handle('get-worktree-sessions', handleGetWorktreeSessions); + + logger.info('Project handlers registered'); +} + +/** + * Removes all project IPC handlers. + */ +export function removeProjectHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('get-projects'); + ipcMain.removeHandler('get-repository-groups'); + ipcMain.removeHandler('get-worktree-sessions'); + + logger.info('Project handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'get-projects' IPC call. + * Lists all projects from ~/.claude/projects/ + */ +async function handleGetProjects(_event: IpcMainInvokeEvent): Promise { + try { + const projects = await projectScanner.scan(); + return projects; + } catch (error) { + logger.error('Error in get-projects:', error); + return []; + } +} + +/** + * Handler for 'get-repository-groups' IPC call. + * Lists all projects grouped by git repository. + * Worktrees of the same repo are grouped together. + */ +async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise { + try { + const groups = await projectScanner.scanWithWorktreeGrouping(); + return groups; + } catch (error) { + logger.error('Error in get-repository-groups:', error); + return []; + } +} + +/** + * Handler for 'get-worktree-sessions' IPC call. + * Lists all sessions for a specific worktree within a repository group. + */ +async function handleGetWorktreeSessions( + _event: IpcMainInvokeEvent, + worktreeId: string +): Promise { + try { + const validatedProject = validateProjectId(worktreeId); + if (!validatedProject.valid) { + logger.error( + `get-worktree-sessions rejected: ${validatedProject.error ?? 'Invalid worktreeId'}` + ); + return []; + } + + const sessions = await projectScanner.listWorktreeSessions(validatedProject.value!); + return sessions; + } catch (error) { + logger.error(`Error in get-worktree-sessions for ${worktreeId}:`, error); + return []; + } +} diff --git a/src/main/ipc/search.ts b/src/main/ipc/search.ts new file mode 100644 index 00000000..81797f02 --- /dev/null +++ b/src/main/ipc/search.ts @@ -0,0 +1,82 @@ +/** + * IPC Handlers for Search Operations. + * + * Handlers: + * - search-sessions: Search sessions in a project + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { type SearchSessionsResult } from '../types'; + +import { coerceSearchMaxResults, validateProjectId, validateSearchQuery } from './guards'; + +const logger = createLogger('IPC:search'); + +import type { ProjectScanner } from '../services'; + +// Service instance - set via initialize +let projectScanner: ProjectScanner; + +/** + * Initializes search handlers with service instance. + */ +export function initializeSearchHandlers(scanner: ProjectScanner): void { + projectScanner = scanner; +} + +/** + * Registers all search-related IPC handlers. + */ +export function registerSearchHandlers(ipcMain: IpcMain): void { + ipcMain.handle('search-sessions', handleSearchSessions); + + logger.info('Search handlers registered'); +} + +/** + * Removes all search IPC handlers. + */ +export function removeSearchHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('search-sessions'); + + logger.info('Search handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'search-sessions' IPC call. + * Searches sessions in a project for a query string. + */ +async function handleSearchSessions( + _event: IpcMainInvokeEvent, + projectId: string, + query: string, + maxResults?: number +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedQuery = validateSearchQuery(query); + if (!validatedProject.valid || !validatedQuery.valid) { + logger.error( + `search-sessions rejected: ${validatedProject.error ?? validatedQuery.error ?? 'Invalid inputs'}` + ); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + const safeMaxResults = coerceSearchMaxResults(maxResults, 50); + const result = await projectScanner.searchSessions( + validatedProject.value!, + validatedQuery.value!, + safeMaxResults + ); + return result; + } catch (error) { + logger.error(`Error in search-sessions for project ${projectId}:`, error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } +} diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts new file mode 100644 index 00000000..7f76ac65 --- /dev/null +++ b/src/main/ipc/sessions.ts @@ -0,0 +1,305 @@ +/** + * IPC Handlers for Session Operations. + * + * Handlers: + * - get-sessions: List sessions for a project + * - get-sessions-paginated: List sessions with cursor-based pagination + * - get-session-detail: Get full session detail with subagents + * - get-session-groups: Get conversation groups for a session + * - get-session-metrics: Get metrics for a session + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { DataCache } from '../services'; +import { + type ConversationGroup, + type PaginatedSessionsResult, + type Session, + type SessionDetail, + type SessionMetrics, + type SessionsPaginationOptions, +} from '../types'; + +import { coercePageLimit, validateProjectId, validateSessionId } from './guards'; + +import type { ChunkBuilder, ProjectScanner, SessionParser, SubagentResolver } from '../services'; +import type { WaterfallData } from '@shared/types'; + +const logger = createLogger('IPC:sessions'); + +// Service instances - set via initialize +let projectScanner: ProjectScanner; +let sessionParser: SessionParser; +let subagentResolver: SubagentResolver; +let chunkBuilder: ChunkBuilder; +let dataCache: DataCache; + +/** + * Initializes session handlers with service instances. + */ +export function initializeSessionHandlers( + scanner: ProjectScanner, + parser: SessionParser, + resolver: SubagentResolver, + builder: ChunkBuilder, + cache: DataCache +): void { + projectScanner = scanner; + sessionParser = parser; + subagentResolver = resolver; + chunkBuilder = builder; + dataCache = cache; +} + +/** + * Registers all session-related IPC handlers. + */ +export function registerSessionHandlers(ipcMain: IpcMain): void { + ipcMain.handle('get-sessions', handleGetSessions); + ipcMain.handle('get-sessions-paginated', handleGetSessionsPaginated); + ipcMain.handle('get-session-detail', handleGetSessionDetail); + ipcMain.handle('get-session-groups', handleGetSessionGroups); + ipcMain.handle('get-session-metrics', handleGetSessionMetrics); + ipcMain.handle('get-waterfall-data', handleGetWaterfallData); + + logger.info('Session handlers registered'); +} + +/** + * Removes all session IPC handlers. + */ +export function removeSessionHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('get-sessions'); + ipcMain.removeHandler('get-sessions-paginated'); + ipcMain.removeHandler('get-session-detail'); + ipcMain.removeHandler('get-session-groups'); + ipcMain.removeHandler('get-session-metrics'); + ipcMain.removeHandler('get-waterfall-data'); + + logger.info('Session handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'get-sessions' IPC call. + * Lists all sessions for a given project. + */ +async function handleGetSessions( + _event: IpcMainInvokeEvent, + projectId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + if (!validatedProject.valid) { + logger.error(`get-sessions rejected: ${validatedProject.error ?? 'Invalid projectId'}`); + return []; + } + + const sessions = await projectScanner.listSessions(validatedProject.value!); + return sessions; + } catch (error) { + logger.error(`Error in get-sessions for project ${projectId}:`, error); + return []; + } +} + +/** + * Handler for 'get-sessions-paginated' IPC call. + * Lists sessions for a project with cursor-based pagination. + */ +async function handleGetSessionsPaginated( + _event: IpcMainInvokeEvent, + projectId: string, + cursor: string | null, + limit?: number, + options?: SessionsPaginationOptions +): Promise { + try { + const validatedProject = validateProjectId(projectId); + if (!validatedProject.valid) { + logger.error( + `get-sessions-paginated rejected: ${validatedProject.error ?? 'Invalid projectId'}` + ); + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } + + const safeLimit = coercePageLimit(limit, 20); + const result = await projectScanner.listSessionsPaginated( + validatedProject.value!, + cursor, + safeLimit, + options + ); + return result; + } catch (error) { + logger.error(`Error in get-sessions-paginated for project ${projectId}:`, error); + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } +} + +/** + * Handler for 'get-session-detail' IPC call. + * Gets full session detail including parsed chunks and subagents. + * Uses cache to avoid re-parsing large files. + */ +async function handleGetSessionDetail( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedSession = validateSessionId(sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + logger.error( + `get-session-detail rejected: ${validatedProject.error ?? validatedSession.error ?? 'Invalid parameters'}` + ); + return null; + } + + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + + // Check cache first + let sessionDetail = dataCache.get(cacheKey); + + if (sessionDetail) { + return sessionDetail; + } + + // Get session metadata + const session = await projectScanner.getSession(safeProjectId, safeSessionId); + if (!session) { + logger.error(`Session not found: ${sessionId}`); + return null; + } + + // Parse session messages + const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId); + + // Resolve subagents + const subagents = await subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + + // Build session detail with chunks + sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents); + + // Cache the result + dataCache.set(cacheKey, sessionDetail); + + return sessionDetail; + } catch (error) { + logger.error(`Error in get-session-detail for ${projectId}/${sessionId}:`, error); + return null; + } +} + +/** + * Handler for 'get-session-groups' IPC call. + * Gets conversation groups for a session using the new buildGroups API. + * This is an alternative to chunks that provides a simpler, more natural grouping. + */ +async function handleGetSessionGroups( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedSession = validateSessionId(sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + logger.error( + `get-session-groups rejected: ${validatedProject.error ?? validatedSession.error ?? 'Invalid parameters'}` + ); + return []; + } + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + + // Parse session messages + const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId); + + // Resolve subagents + const subagents = await subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + + // Build conversation groups using the new API + const groups = chunkBuilder.buildGroups(parsedSession.messages, subagents); + + return groups; + } catch (error) { + logger.error(`Error in get-session-groups for ${projectId}/${sessionId}:`, error); + return []; + } +} + +/** + * Handler for 'get-session-metrics' IPC call. + * Gets metrics for a session without full detail. + */ +async function handleGetSessionMetrics( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedSession = validateSessionId(sessionId); + if (!validatedProject.valid || !validatedSession.valid) { + return null; + } + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + + // Try to get from cache first + const cacheKey = DataCache.buildKey(safeProjectId, safeSessionId); + const cached = dataCache.get(cacheKey); + + if (cached) { + return cached.metrics; + } + + // Parse session to get metrics + const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId); + return parsedSession.metrics; + } catch (error) { + logger.error(`Error in get-session-metrics for ${projectId}/${sessionId}:`, error); + return null; + } +} + +/** + * Handler for 'get-waterfall-data' IPC call. + * Builds waterfall chart data for a session. + */ +async function handleGetWaterfallData( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string +): Promise { + try { + const detail = await handleGetSessionDetail(_event, projectId, sessionId); + if (!detail) { + return null; + } + + return chunkBuilder.buildWaterfallData(detail.chunks, detail.processes); + } catch (error) { + logger.error(`Error in get-waterfall-data for ${projectId}/${sessionId}:`, error); + return null; + } +} diff --git a/src/main/ipc/subagents.ts b/src/main/ipc/subagents.ts new file mode 100644 index 00000000..d010e3c6 --- /dev/null +++ b/src/main/ipc/subagents.ts @@ -0,0 +1,124 @@ +/** + * IPC Handlers for Subagent Operations. + * + * Handlers: + * - get-subagent-detail: Get detailed information for a specific subagent + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; + +import { type SubagentDetail } from '../types'; + +import { validateProjectId, validateSessionId, validateSubagentId } from './guards'; + +import type { ChunkBuilder, DataCache, SessionParser, SubagentResolver } from '../services'; + +const logger = createLogger('IPC:subagents'); + +// Service instances - set via initialize +let chunkBuilder: ChunkBuilder; +let dataCache: DataCache; +let sessionParser: SessionParser; +let subagentResolver: SubagentResolver; + +/** + * Initializes subagent handlers with service instances. + */ +export function initializeSubagentHandlers( + builder: ChunkBuilder, + cache: DataCache, + parser: SessionParser, + resolver: SubagentResolver +): void { + chunkBuilder = builder; + dataCache = cache; + sessionParser = parser; + subagentResolver = resolver; +} + +/** + * Registers all subagent-related IPC handlers. + */ +export function registerSubagentHandlers(ipcMain: IpcMain): void { + ipcMain.handle('get-subagent-detail', handleGetSubagentDetail); + + logger.info('Subagent handlers registered'); +} + +/** + * Removes all subagent IPC handlers. + */ +export function removeSubagentHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('get-subagent-detail'); + + logger.info('Subagent handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'get-subagent-detail' IPC call. + * Gets detailed information for a specific subagent for drill-down modal. + */ +async function handleGetSubagentDetail( + _event: IpcMainInvokeEvent, + projectId: string, + sessionId: string, + subagentId: string +): Promise { + try { + const validatedProject = validateProjectId(projectId); + const validatedSession = validateSessionId(sessionId); + const validatedSubagent = validateSubagentId(subagentId); + if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) { + logger.error( + `get-subagent-detail rejected: ${ + validatedProject.error ?? + validatedSession.error ?? + validatedSubagent.error ?? + 'Invalid parameters' + }` + ); + return null; + } + const safeProjectId = validatedProject.value!; + const safeSessionId = validatedSession.value!; + const safeSubagentId = validatedSubagent.value!; + + const cacheKey = `subagent-${safeProjectId}-${safeSessionId}-${safeSubagentId}`; + + // Check cache first + let subagentDetail = dataCache.getSubagent(cacheKey); + + if (subagentDetail) { + return subagentDetail; + } + + // Build subagent detail + const builtDetail = await chunkBuilder.buildSubagentDetail( + safeProjectId, + safeSessionId, + safeSubagentId, + sessionParser, + subagentResolver + ); + + if (!builtDetail) { + logger.error(`Subagent not found: ${safeSubagentId}`); + return null; + } + + subagentDetail = builtDetail; + + // Cache the result + dataCache.setSubagent(cacheKey, subagentDetail); + + return subagentDetail; + } catch (error) { + logger.error(`Error in get-subagent-detail for ${subagentId}:`, error); + return null; + } +} diff --git a/src/main/ipc/utility.ts b/src/main/ipc/utility.ts new file mode 100644 index 00000000..c13dd394 --- /dev/null +++ b/src/main/ipc/utility.ts @@ -0,0 +1,230 @@ +/** + * IPC Handlers for Utility Operations. + * + * Handlers: + * - shell:openPath: Opens a folder or file in the system's default application + * - read-claude-md-files: Reads all global CLAUDE.md files for a project + * - read-directory-claude-md: Reads a specific directory's CLAUDE.md file + * - read-mentioned-file: Validates mentioned files for context injection + */ + +import { createLogger } from '@shared/utils/logger'; +import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; +import * as fs from 'fs'; + +import { type ClaudeMdFileInfo, readAllClaudeMdFiles, readDirectoryClaudeMd } from '../services'; + +const logger = createLogger('IPC:utility'); +import { validateFilePath, validateOpenPath } from '../utils/pathValidation'; +import { countTokens } from '../utils/tokenizer'; + +/** + * Registers all utility-related IPC handlers. + */ +export function registerUtilityHandlers(ipcMain: IpcMain): void { + ipcMain.handle('get-app-version', handleGetAppVersion); + ipcMain.handle('shell:openPath', handleShellOpenPath); + ipcMain.handle('shell:openExternal', handleShellOpenExternal); + ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles); + ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd); + ipcMain.handle('read-mentioned-file', handleReadMentionedFile); + + logger.info('Utility handlers registered'); +} + +/** + * Removes all utility IPC handlers. + */ +export function removeUtilityHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('get-app-version'); + ipcMain.removeHandler('shell:openPath'); + ipcMain.removeHandler('shell:openExternal'); + ipcMain.removeHandler('read-claude-md-files'); + ipcMain.removeHandler('read-directory-claude-md'); + ipcMain.removeHandler('read-mentioned-file'); + + logger.info('Utility handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'get-app-version' IPC call. + * Returns the app version from package.json. + */ +function handleGetAppVersion(): string { + return app.getVersion(); +} + +/** + * Handler for 'shell:openExternal' IPC call. + * Opens a URL in the system's default browser. + */ +async function handleShellOpenExternal( + _event: IpcMainInvokeEvent, + url: string +): Promise<{ success: boolean; error?: string }> { + try { + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return { success: false, error: 'Invalid URL' }; + } + + const protocol = parsedUrl.protocol.toLowerCase(); + if (protocol !== 'http:' && protocol !== 'https:' && protocol !== 'mailto:') { + logger.error(`shell:openExternal - invalid URL scheme: ${url}`); + return { success: false, error: 'Only http, https, and mailto URLs are allowed' }; + } + + await shell.openExternal(parsedUrl.toString()); + return { success: true }; + } catch (error) { + logger.error('Error in shell:openExternal:', error); + return { success: false, error: String(error) }; + } +} + +/** + * Handler for 'shell:openPath' IPC call. + * Opens a folder or file in the system's default application (Finder on macOS). + * Validates path security before opening. + */ +async function handleShellOpenPath( + _event: IpcMainInvokeEvent, + targetPath: string, + projectRoot?: string +): Promise<{ success: boolean; error?: string }> { + try { + // Validate path security + const validation = validateOpenPath(targetPath, projectRoot ?? null); + if (!validation.valid) { + logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`); + return { success: false, error: validation.error }; + } + + const safePath = validation.normalizedPath!; + + // Check if path exists + if (!fs.existsSync(safePath)) { + logger.error(`shell:openPath - path does not exist: ${safePath}`); + return { success: false, error: 'Path does not exist' }; + } + + // Open in default application (Finder on macOS) + const errorMessage = await shell.openPath(safePath); + if (errorMessage) { + logger.error(`shell:openPath - failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } + + return { success: true }; + } catch (error) { + logger.error('Error in shell:openPath:', error); + return { success: false, error: String(error) }; + } +} + +/** + * Handler for 'read-claude-md-files' IPC call. + * Reads all global CLAUDE.md files for a project. + */ +async function handleReadClaudeMdFiles( + _event: IpcMainInvokeEvent, + projectRoot: string +): Promise> { + try { + const result = readAllClaudeMdFiles(projectRoot); + // Convert Map to object for IPC serialization + const files: Record = {}; + result.files.forEach((info, key) => { + files[key] = info; + }); + + return files; + } catch (error) { + logger.error(`Error in read-claude-md-files:`, error); + return {}; + } +} + +/** + * Handler for 'read-directory-claude-md' IPC call. + * Reads a specific directory's CLAUDE.md file. + */ +async function handleReadDirectoryClaudeMd( + _event: IpcMainInvokeEvent, + dirPath: string +): Promise { + try { + const info = readDirectoryClaudeMd(dirPath); + return info; + } catch (error) { + logger.error(`Error in read-directory-claude-md:`, error); + return { + path: dirPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } +} + +/** + * Handler for 'read-mentioned-file' IPC call. + * Validates mentioned files for context injection. + * Returns file info if file exists, is a regular file, within allowed directories, and within token limits. + * + * Security: Validates path against allowed directories and sensitive file patterns. + */ +async function handleReadMentionedFile( + _event: IpcMainInvokeEvent, + absolutePath: string, + projectRoot: string, + maxTokens: number = 25000 +): Promise<{ path: string; exists: boolean; charCount: number; estimatedTokens: number } | null> { + try { + // Validate path security + const validation = validateFilePath(absolutePath, projectRoot || null); + if (!validation.valid) { + return null; + } + + const safePath = validation.normalizedPath!; + + // Check if file exists + if (!fs.existsSync(safePath)) { + return null; + } + + // Check if it's a file (not directory) + const stats = fs.statSync(safePath); + if (!stats.isFile()) { + return null; + } + + // Read file content + const content = fs.readFileSync(safePath, 'utf8'); + + // Calculate tokens + const estimatedTokens = countTokens(content); + + // Check token limit + if (estimatedTokens > maxTokens) { + return null; + } + + return { + path: safePath, + exists: true, + charCount: content.length, + estimatedTokens, + }; + } catch (error) { + logger.error(`Error in read-mentioned-file for ${absolutePath}:`, error); + return null; + } +} diff --git a/src/main/ipc/validation.ts b/src/main/ipc/validation.ts new file mode 100644 index 00000000..edf52760 --- /dev/null +++ b/src/main/ipc/validation.ts @@ -0,0 +1,145 @@ +/** + * IPC Handlers for Validation Operations. + * + * Handlers: + * - validate-path: Validate if a file/directory path exists relative to project + * - validate-mentions: Batch validate path mentions (@file references) + * - session:scrollToLine: Deep link handler for scrolling to a specific line in a session + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('IPC:validation'); + +/** + * Registers all validation-related IPC handlers. + */ +export function registerValidationHandlers(ipcMain: IpcMain): void { + ipcMain.handle('validate-path', handleValidatePath); + ipcMain.handle('validate-mentions', handleValidateMentions); + ipcMain.handle('session:scrollToLine', handleScrollToLine); + + logger.info('Validation handlers registered'); +} + +/** + * Removes all validation IPC handlers. + */ +export function removeValidationHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('validate-path'); + ipcMain.removeHandler('validate-mentions'); + ipcMain.removeHandler('session:scrollToLine'); + + logger.info('Validation handlers removed'); +} + +// ============================================================================= +// Security Helpers +// ============================================================================= + +/** + * Checks if a path is contained within a base directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd). + */ +function isPathContained(fullPath: string, basePath: string): boolean { + const normalizedFull = path.normalize(fullPath); + const normalizedBase = path.normalize(basePath); + + // Ensure the full path starts with the base path followed by a separator + // or is exactly the base path + return normalizedFull === normalizedBase || normalizedFull.startsWith(normalizedBase + path.sep); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +/** + * Handler for 'validate-path' IPC call. + * Validates if a file/directory path exists relative to project. + */ +async function handleValidatePath( + _event: IpcMainInvokeEvent, + relativePath: string, + projectPath: string +): Promise<{ exists: boolean; isDirectory?: boolean }> { + try { + const fullPath = path.join(projectPath, relativePath); + + // Security: Ensure path doesn't escape project directory + if (!isPathContained(fullPath, projectPath)) { + logger.warn('validate-path blocked path traversal attempt:', relativePath); + return { exists: false }; + } + + if (!fs.existsSync(fullPath)) { + return { exists: false }; + } + + const stats = fs.statSync(fullPath); + return { + exists: true, + isDirectory: stats.isDirectory(), + }; + } catch { + return { exists: false }; + } +} + +/** + * Handler for 'validate-mentions' IPC call. + * Batch validates path mentions (@file references). + * Slash commands do not need validation. + */ +async function handleValidateMentions( + _event: IpcMainInvokeEvent, + mentions: { type: 'path'; value: string }[], + projectPath: string +): Promise> { + const results = new Map(); + + for (const mention of mentions) { + const fullPath = path.join(projectPath, mention.value); + + // Security: Skip paths that escape project directory + if (!isPathContained(fullPath, projectPath)) { + results.set(`@${mention.value}`, false); + continue; + } + + results.set(`@${mention.value}`, fs.existsSync(fullPath)); + } + + return Object.fromEntries(results); +} + +/** + * Handler for 'session:scrollToLine' IPC call. + * Used for deep linking from notifications to specific lines in a session. + * The actual scrolling happens in the renderer; this handler validates and returns the data. + */ +async function handleScrollToLine( + _event: IpcMainInvokeEvent, + sessionId: string, + lineNumber: number +): Promise<{ success: boolean; sessionId: string; lineNumber: number }> { + try { + if (!sessionId) { + logger.error('session:scrollToLine called with empty sessionId'); + return { success: false, sessionId: '', lineNumber: 0 }; + } + + if (typeof lineNumber !== 'number' || lineNumber < 0) { + logger.error('session:scrollToLine called with invalid lineNumber'); + return { success: false, sessionId, lineNumber: 0 }; + } + + return { success: true, sessionId, lineNumber }; + } catch (error) { + logger.error(`Error in session:scrollToLine:`, error); + return { success: false, sessionId: '', lineNumber: 0 }; + } +} diff --git a/src/main/services/CLAUDE.md b/src/main/services/CLAUDE.md new file mode 100644 index 00000000..9ed40777 --- /dev/null +++ b/src/main/services/CLAUDE.md @@ -0,0 +1,59 @@ +# Services + +Business logic organized by domain. + +## Domains +- `analysis/` - Chunk building, semantic steps, tool execution +- `discovery/` - Project/session scanning, subagent resolution +- `error/` - Error detection, trigger checking +- `infrastructure/` - Cache, file watching, config, notifications +- `parsing/` - JSONL parsing, message classification + +## Key Services + +### Analysis +- **ChunkBuilder** - Orchestrates chunk building +- **ChunkFactory** - Creates chunk objects +- **ConversationGroupBuilder** - Builds conversation groups +- **ProcessLinker** - Links subagents to chunks +- **SemanticStepExtractor** - Extracts steps +- **SemanticStepGrouper** - Groups semantic steps +- **SubagentDetailBuilder** - Builds subagent detail views +- **ToolExecutionBuilder** - Builds tool execution tracking +- **ToolResultExtractor** - Extracts tool results +- **ToolSummaryFormatter** - Formats tool summaries + +### Discovery +- **ProjectPathResolver** - Resolves project paths +- **ProjectScanner** - Scans ~/.claude/projects/ +- **SessionContentFilter** - Filters session content +- **SessionSearcher** - Searches session content +- **SubagentLocator** - Locates subagent files +- **SubagentResolver** - Parses subagent files, detects parallel execution, enriches team metadata/colors +- **SubprojectRegistry** - Tracks subproject associations +- **WorktreeGrouper** - Groups projects by git worktree + +### Parsing +- **SessionParser** - Parses JSONL files +- **MessageClassifier** - Categorizes messages (user, system, AI, noise) +- **ClaudeMdReader** - Reads CLAUDE.md configuration +- **GitIdentityResolver** - Resolves git identities + +### Error +- **ErrorDetector** - Per-tool-use token counting, returns `DetectedError[]` +- **ErrorMessageBuilder** - Builds error notification messages +- **ErrorTriggerChecker** - Matches against notification triggers +- **ErrorTriggerTester** - Tests triggers against historical data +- **TriggerMatcher** - Pattern matching for triggers + +### Infrastructure +- **DataCache** - LRU cache (50 entries, 10min TTL) +- **FileWatcher** - 100ms debounced file watching +- **ConfigManager** - App configuration +- **NotificationManager** - Notification handling +- **TriggerManager** - Notification trigger management + +## Adding Service +1. Create in appropriate domain folder +2. Export from domain's index.ts +3. Re-export from services/index.ts diff --git a/src/main/services/analysis/ChunkBuilder.ts b/src/main/services/analysis/ChunkBuilder.ts new file mode 100644 index 00000000..072ea18d --- /dev/null +++ b/src/main/services/analysis/ChunkBuilder.ts @@ -0,0 +1,443 @@ +/** + * ChunkBuilder service - Builds visualization chunks from parsed session data. + * + * Responsibilities: + * - Group messages into chunks (user message + responses) + * - Attach subagents to chunks + * - Build waterfall chart data + * - Calculate chunk metrics + * + * This module orchestrates chunk building using specialized modules: + * - MessageClassifier: Classify messages into categories + * - ChunkFactory: Create individual chunk objects + * - ProcessLinker: Link subagent processes to chunks + * - SemanticStepExtractor: Extract semantic steps from AI chunks + * - SemanticStepGrouper: Group semantic steps for UI + * - ToolExecutionBuilder: Build tool execution tracking + * - SubagentDetailBuilder: Build subagent drill-down details + * - ConversationGroupBuilder: Alternative grouping strategy + */ + +import { + type Chunk, + type ConversationGroup, + EMPTY_METRICS, + type EnhancedChunk, + isAIChunk, + isCompactChunk, + isSystemChunk, + isUserChunk, + type MessageCategory, + type ParsedMessage, + type Process, + type Session, + type SessionDetail, + type SessionMetrics, + type SubagentDetail, + type TokenUsage, +} from '@main/types'; +import { calculateMetrics } from '@main/utils/jsonl'; +import { createLogger } from '@shared/utils/logger'; + +import type { WaterfallData, WaterfallItem } from '@shared/types'; + +const logger = createLogger('Service:ChunkBuilder'); + +import { classifyMessages } from '../parsing/MessageClassifier'; + +import { + buildAIChunkFromBuffer, + buildCompactChunk, + buildSystemChunk, + buildUserChunk, +} from './ChunkFactory'; +import { buildGroups as buildConversationGroups } from './ConversationGroupBuilder'; +import { buildSubagentDetail as buildSubagentDetailFn } from './SubagentDetailBuilder'; + +import type { SubagentResolver } from '../discovery/SubagentResolver'; +import type { SessionParser } from '../parsing/SessionParser'; + +export class ChunkBuilder { + // =========================================================================== + // Chunk Building + // =========================================================================== + + /** + * Build chunks from messages using 4-category classification. + * Produces independent UserChunks, AIChunks, and SystemChunks. + * + * Categories: + * - User: Genuine user input (creates UserChunk, renders RIGHT) + * - System: Command output (creates SystemChunk, renders LEFT) + * - Hard Noise: Filtered out entirely (system metadata, caveats, reminders) + * - AI: All other messages grouped into AIChunks (renders LEFT) + * + * All chunk types are INDEPENDENT - no pairing between User and AI. + */ + buildChunks(messages: ParsedMessage[], subagents: Process[] = []): EnhancedChunk[] { + const chunks: EnhancedChunk[] = []; + + // Filter to main thread messages (non-sidechain) + const mainMessages = messages.filter((m) => !m.isSidechain); + logger.debug(`Total messages: ${messages.length}, Main thread: ${mainMessages.length}`); + + // Classify each message into categories using MessageClassifier + const classified = classifyMessages(mainMessages); + + // Log classification summary + const categoryCounts = new Map(); + for (const { category } of classified) { + categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1); + } + logger.debug('Message classification:', Object.fromEntries(categoryCounts)); + + // Build chunks from classification - AI chunks are INDEPENDENT + let aiBuffer: ParsedMessage[] = []; + + for (const { message, category } of classified) { + switch (category) { + case 'hardNoise': + // Skip - filtered out + break; + + case 'compact': + // Flush any buffered AI messages first + if (aiBuffer.length > 0) { + chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages)); + aiBuffer = []; + } + chunks.push(buildCompactChunk(message)); + break; + + case 'user': + // Flush any buffered AI messages first + if (aiBuffer.length > 0) { + chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages)); + aiBuffer = []; + } + chunks.push(buildUserChunk(message)); + break; + + case 'system': + // Flush any buffered AI messages first + if (aiBuffer.length > 0) { + chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages)); + aiBuffer = []; + } + chunks.push(buildSystemChunk(message)); + break; + + case 'ai': + aiBuffer.push(message); + break; + } + } + + // Flush remaining AI buffer + if (aiBuffer.length > 0) { + chunks.push(buildAIChunkFromBuffer(aiBuffer, subagents, messages)); + } + + // Log final chunk summary + const userChunkCount = chunks.filter(isUserChunk).length; + const aiChunkCount = chunks.filter(isAIChunk).length; + const systemChunkCount = chunks.filter(isSystemChunk).length; + const compactChunkCount = chunks.filter(isCompactChunk).length; + logger.debug( + `Created ${chunks.length} chunks: ${userChunkCount} user, ${aiChunkCount} AI, ${systemChunkCount} system, ${compactChunkCount} compact` + ); + + return chunks; + } + + // =========================================================================== + // Simplified Grouping Strategy (delegates to ConversationGroupBuilder) + // =========================================================================== + + /** + * Build conversation groups using simplified grouping strategy. + * Groups one user message with all AI responses until the next user message. + * + * This is a cleaner alternative to buildChunks() that: + * - Uses simpler time-based grouping + * - Separates Task executions from regular tool executions + * - Links subagents more explicitly via TaskExecution + */ + buildGroups(messages: ParsedMessage[], subagents: Process[]): ConversationGroup[] { + return buildConversationGroups(messages, subagents); + } + + // =========================================================================== + // Session Detail Building + // =========================================================================== + + /** + * Build a complete SessionDetail from parsed data. + */ + buildSessionDetail( + session: Session, + messages: ParsedMessage[], + subagents: Process[] + ): SessionDetail { + // Build chunks + const chunks = this.buildChunks(messages, subagents); + + // Calculate overall metrics + const metrics = calculateMetrics(messages); + + return { + session, + messages, + chunks, + processes: subagents, + metrics, + }; + } + + /** + * Build waterfall chart data from chunks and resolved processes. + */ + buildWaterfallData(chunks: Chunk[], processes: Process[]): WaterfallData { + const items: WaterfallItem[] = []; + + for (const chunk of chunks) { + const baseChunkItem: WaterfallItem = { + id: chunk.id, + label: this.getChunkLabel(chunk), + startTime: chunk.startTime, + endTime: chunk.endTime, + durationMs: chunk.durationMs, + tokenUsage: this.toTokenUsage(chunk.metrics), + level: 0, + type: 'chunk', + isParallel: false, + }; + items.push(baseChunkItem); + + if (isAIChunk(chunk)) { + for (const toolExec of chunk.toolExecutions) { + const endTime = toolExec.endTime ?? toolExec.startTime; + items.push({ + id: `tool-${toolExec.toolCall.id}`, + label: toolExec.toolCall.name, + startTime: toolExec.startTime, + endTime, + durationMs: + toolExec.durationMs ?? Math.max(endTime.getTime() - toolExec.startTime.getTime(), 0), + tokenUsage: { + input_tokens: 0, + output_tokens: 0, + }, + level: 1, + type: 'tool', + isParallel: false, + parentId: chunk.id, + }); + } + + for (const process of chunk.processes) { + items.push({ + id: `subagent-${process.id}`, + label: process.description || process.subagentType || process.id, + startTime: process.startTime, + endTime: process.endTime, + durationMs: process.durationMs, + tokenUsage: this.toTokenUsage(process.metrics), + level: 1, + type: 'subagent', + isParallel: process.isParallel, + parentId: chunk.id, + metadata: { + subagentType: process.subagentType, + messageCount: process.messages.length, + }, + }); + } + } + } + + // Add any process that was not attached to an AI chunk (defensive fallback) + for (const process of processes) { + const itemId = `subagent-${process.id}`; + if (items.some((item) => item.id === itemId)) { + continue; + } + items.push({ + id: itemId, + label: process.description || process.subagentType || process.id, + startTime: process.startTime, + endTime: process.endTime, + durationMs: process.durationMs, + tokenUsage: this.toTokenUsage(process.metrics), + level: 0, + type: 'subagent', + isParallel: process.isParallel, + metadata: { + subagentType: process.subagentType, + messageCount: process.messages.length, + }, + }); + } + + const sortedItems = [...items]; + sortedItems.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + if (sortedItems.length === 0) { + const now = new Date(); + return { + items: [], + minTime: now, + maxTime: now, + totalDurationMs: 0, + }; + } + + const minTime = sortedItems.reduce( + (min, item) => (item.startTime.getTime() < min.getTime() ? item.startTime : min), + sortedItems[0].startTime + ); + const maxTime = sortedItems.reduce( + (max, item) => (item.endTime.getTime() > max.getTime() ? item.endTime : max), + sortedItems[0].endTime + ); + + return { + items: sortedItems, + minTime, + maxTime, + totalDurationMs: Math.max(maxTime.getTime() - minTime.getTime(), 0), + }; + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Get total metrics for all chunks. + */ + getTotalChunkMetrics(chunks: (Chunk | EnhancedChunk)[]): SessionMetrics { + if (chunks.length === 0) { + return { ...EMPTY_METRICS }; + } + + let durationMs = 0; + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + let messageCount = 0; + + for (const chunk of chunks) { + durationMs += chunk.durationMs; + inputTokens += chunk.metrics.inputTokens; + outputTokens += chunk.metrics.outputTokens; + cacheReadTokens += chunk.metrics.cacheReadTokens; + cacheCreationTokens += chunk.metrics.cacheCreationTokens; + messageCount += chunk.metrics.messageCount; + } + + return { + durationMs, + totalTokens: inputTokens + outputTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + messageCount, + }; + } + + /** + * Find chunk containing a specific message UUID. + */ + findChunkByMessageId( + chunks: (Chunk | EnhancedChunk)[], + messageUuid: string + ): Chunk | EnhancedChunk | undefined { + return chunks.find((c) => { + // UserChunk: check userMessage + if (isUserChunk(c)) { + return c.userMessage.uuid === messageUuid; + } + // AIChunk: check responses + if (isAIChunk(c)) { + return c.responses.some((r) => r.uuid === messageUuid); + } + return false; + }); + } + + /** + * Find chunk containing a specific subagent. + * Only AIChunks have processes. + */ + findChunkBySubagentId( + chunks: (Chunk | EnhancedChunk)[], + subagentId: string + ): Chunk | EnhancedChunk | undefined { + return chunks.find((c) => { + if (isAIChunk(c)) { + return c.processes.some((s: Process) => s.id === subagentId); + } + return false; + }); + } + + private getChunkLabel(chunk: Chunk): string { + switch (chunk.chunkType) { + case 'user': + return 'User'; + case 'ai': + return 'Assistant'; + case 'system': + return 'System'; + case 'compact': + return 'Compact'; + default: + return 'Chunk'; + } + } + + private toTokenUsage(metrics: SessionMetrics): TokenUsage { + return { + input_tokens: metrics.inputTokens, + output_tokens: metrics.outputTokens, + cache_read_input_tokens: metrics.cacheReadTokens || undefined, + cache_creation_input_tokens: metrics.cacheCreationTokens || undefined, + }; + } + + // =========================================================================== + // Subagent Detail Building (for drill-down) + // =========================================================================== + + /** + * Build detailed information for a specific subagent. + * Used for drill-down modal to show subagent's internal execution. + * + * @param projectId - Project ID + * @param sessionId - Parent session ID (currently unused, kept for API consistency) + * @param subagentId - Subagent ID to load + * @param sessionParser - SessionParser instance for parsing subagent file + * @param subagentResolver - SubagentResolver instance for nested subagents + * @returns SubagentDetail or null if not found + */ + async buildSubagentDetail( + projectId: string, + sessionId: string, + subagentId: string, + sessionParser: SessionParser, + subagentResolver: SubagentResolver + ): Promise { + // Delegate to the extracted module, passing buildChunks as a callback + return buildSubagentDetailFn( + projectId, + sessionId, + subagentId, + sessionParser, + subagentResolver, + (messages, subagents) => this.buildChunks(messages, subagents) + ); + } +} diff --git a/src/main/services/analysis/ChunkFactory.ts b/src/main/services/analysis/ChunkFactory.ts new file mode 100644 index 00000000..340f8feb --- /dev/null +++ b/src/main/services/analysis/ChunkFactory.ts @@ -0,0 +1,203 @@ +/** + * ChunkFactory service - Creates individual chunk objects from messages. + * + * Responsibilities: + * - Build UserChunk from user messages + * - Build SystemChunk from command output messages + * - Build CompactChunk from summary messages + * - Build AIChunk from buffered AI messages + * - Calculate timing and metrics for chunks + */ + +import { + type EnhancedAIChunk, + type EnhancedCompactChunk, + type EnhancedSystemChunk, + type EnhancedUserChunk, + type ParsedMessage, + type Process, +} from '@main/types'; +import { calculateStepContext } from '@main/utils/contextAccumulator'; +import { calculateMetrics } from '@main/utils/jsonl'; +import { fillTimelineGaps } from '@main/utils/timelineGapFilling'; + +import { linkProcessesToAIChunk } from './ProcessLinker'; +import { extractSemanticStepsFromAIChunk } from './SemanticStepExtractor'; +import { buildSemanticStepGroups } from './SemanticStepGrouper'; +import { buildToolExecutions } from './ToolExecutionBuilder'; + +/** + * Generate a stable chunk ID based on message UUID. + * Using the message UUID ensures IDs are consistent across re-parses. + */ +function generateStableChunkId(prefix: string, message: ParsedMessage): string { + return `${prefix}-${message.uuid}`; +} + +/** + * Build a UserChunk from a user message. + */ +export function buildUserChunk(message: ParsedMessage): EnhancedUserChunk { + const id = generateStableChunkId('user', message); + const metrics = calculateMetrics([message]); + + return { + id, + chunkType: 'user', + userMessage: message, + startTime: message.timestamp, + endTime: message.timestamp, + durationMs: 0, + metrics, + rawMessages: [message], + }; +} + +/** + * Build a SystemChunk from a command output message. + */ +export function buildSystemChunk(message: ParsedMessage): EnhancedSystemChunk { + const id = generateStableChunkId('system', message); + const commandOutput = extractCommandOutput(message); + const metrics = calculateMetrics([message]); + + return { + id, + chunkType: 'system', + message, + commandOutput, + startTime: message.timestamp, + endTime: message.timestamp, + durationMs: 0, + metrics, + rawMessages: [message], + }; +} + +/** + * Build a CompactChunk from a compact summary message. + */ +export function buildCompactChunk(message: ParsedMessage): EnhancedCompactChunk { + const id = generateStableChunkId('compact', message); + const metrics = calculateMetrics([message]); + + return { + id, + chunkType: 'compact', + message, + startTime: message.timestamp, + endTime: message.timestamp, + durationMs: 0, + metrics, + rawMessages: [message], + }; +} + +/** + * Extract command output from tag. + */ +function extractCommandOutput(message: ParsedMessage): string { + const content = typeof message.content === 'string' ? message.content : ''; + const match = /([\s\S]*?)<\/local-command-stdout>/.exec(content); + const matchStderr = /([\s\S]*?)<\/local-command-stderr>/.exec(content); + if (match) { + return match[1]; + } + if (matchStderr) { + return matchStderr[1]; + } + return content; +} + +/** + * Build an AIChunk from buffered AI messages. + */ +export function buildAIChunkFromBuffer( + responses: ParsedMessage[], + subagents: Process[], + allMessages: ParsedMessage[] +): EnhancedAIChunk { + // Use first response message's UUID for stable ID + const id = + responses.length > 0 ? generateStableChunkId('ai', responses[0]) : `ai-empty-${Date.now()}`; // Fallback for edge case + const { startTime, endTime, durationMs } = calculateAIChunkTiming(responses); + const metrics = calculateMetrics(responses); + const toolExecutions = buildToolExecutions(responses); + + // Collect sidechain messages for this time range + const sidechainMessages = collectSidechainMessages(allMessages, startTime, endTime); + + const chunk: EnhancedAIChunk = { + id, + chunkType: 'ai', + responses, + startTime, + endTime, + durationMs, + metrics, + processes: [], + sidechainMessages, + toolExecutions, + semanticSteps: [], + rawMessages: responses, + }; + + // Link processes to this chunk + linkProcessesToAIChunk(chunk, subagents); + + // Extract semantic steps using the extracted module + chunk.semanticSteps = extractSemanticStepsFromAIChunk(chunk); + chunk.semanticSteps = fillTimelineGaps({ + steps: chunk.semanticSteps, + chunkStartTime: chunk.startTime, + chunkEndTime: chunk.endTime, + }); + calculateStepContext(chunk.semanticSteps, chunk.rawMessages); + chunk.semanticStepGroups = buildSemanticStepGroups(chunk.semanticSteps); + + return chunk; +} + +/** + * Calculate timing for AI chunks (responses only, no user message). + */ +function calculateAIChunkTiming(responses: ParsedMessage[]): { + startTime: Date; + endTime: Date; + durationMs: number; +} { + if (responses.length === 0) { + const now = new Date(); + return { startTime: now, endTime: now, durationMs: 0 }; + } + + const startTime = responses[0].timestamp; + let endTime = startTime; + for (const resp of responses) { + if (resp.timestamp > endTime) { + endTime = resp.timestamp; + } + } + + return { + startTime, + endTime, + durationMs: endTime.getTime() - startTime.getTime(), + }; +} + +/** + * Collect sidechain messages in a time range. + */ +function collectSidechainMessages( + messages: ParsedMessage[], + startTime: Date, + endTime: Date | undefined +): ParsedMessage[] { + return messages.filter((m) => { + if (!m.isSidechain) return false; + if (m.timestamp < startTime) return false; + if (endTime && m.timestamp >= endTime) return false; + return true; + }); +} diff --git a/src/main/services/analysis/ConversationGroupBuilder.ts b/src/main/services/analysis/ConversationGroupBuilder.ts new file mode 100644 index 00000000..96780091 --- /dev/null +++ b/src/main/services/analysis/ConversationGroupBuilder.ts @@ -0,0 +1,213 @@ +/** + * ConversationGroupBuilder - Alternative grouping strategy for conversation flow. + * + * Groups one user message with all AI responses until the next user message. + * This is a cleaner alternative to buildChunks() that: + * - Uses simpler time-based grouping + * - Separates Task executions from regular tool executions + * - Links subagents more explicitly via TaskExecution + */ + +import { + type ConversationGroup, + isParsedUserChunkMessage, + type ParsedMessage, + type Process, + type TaskExecution, + type ToolCall, + type ToolExecution, +} from '@main/types'; +import { calculateMetrics } from '@main/utils/jsonl'; + +/** + * Build conversation groups using simplified grouping strategy. + * Groups one user message with all AI responses until the next user message. + */ +export function buildGroups(messages: ParsedMessage[], subagents: Process[]): ConversationGroup[] { + const groups: ConversationGroup[] = []; + + // Step 1: Filter to main thread only (not sidechain) + const mainMessages = messages.filter((m) => !m.isSidechain); + + // Step 2: Find all REAL user messages (these start groups) + // Use isParsedUserChunkMessage to filter out noise + const userMessages = mainMessages.filter(isParsedUserChunkMessage); + + // Step 3: For each user message, collect all AI responses until next user message + for (let i = 0; i < userMessages.length; i++) { + const userMsg = userMessages[i]; + const nextUserMsg = userMessages[i + 1]; + + // Collect all messages between this user message and the next + const aiResponses = collectAIResponses(mainMessages, userMsg, nextUserMsg); + + // Separate Task tool results from regular tool executions + const { taskExecutions, regularToolExecutions } = separateTaskExecutions( + aiResponses, + subagents + ); + + // Link subagents to this group + const groupSubagents = linkSubagentsToGroup(userMsg, nextUserMsg, subagents); + + // Calculate metrics + const { startTime, endTime, durationMs } = calculateGroupTiming(userMsg, aiResponses); + const metrics = calculateMetrics([userMsg, ...aiResponses]); + + groups.push({ + id: `group-${i + 1}`, + type: 'user-ai-exchange', + userMessage: userMsg, + aiResponses, + processes: groupSubagents, + toolExecutions: regularToolExecutions, + taskExecutions, + startTime, + endTime, + durationMs, + metrics, + }); + } + + return groups; +} + +/** + * Collect AI responses between a user message and the next user message. + * Simpler than collectResponses - just uses timestamp boundaries. + */ +function collectAIResponses( + messages: ParsedMessage[], + userMsg: ParsedMessage, + nextUserMsg: ParsedMessage | undefined +): ParsedMessage[] { + const responses: ParsedMessage[] = []; + const startTime = userMsg.timestamp; + const endTime = nextUserMsg?.timestamp; + + for (const msg of messages) { + // Skip if before this user message + if (msg.timestamp <= startTime) continue; + + // Skip if at or after next user message + if (endTime && msg.timestamp >= endTime) continue; + + // Include ALL non-user messages (assistant + internal user messages) + if (msg.type === 'assistant' || (msg.type === 'user' && msg.isMeta === true)) { + responses.push(msg); + } + } + + return responses; +} + +/** + * Separate Task executions from regular tool executions. + * Task tools spawn subagents, so we track them separately to avoid duplication. + */ +function separateTaskExecutions( + responses: ParsedMessage[], + allSubagents: Process[] +): { taskExecutions: TaskExecution[]; regularToolExecutions: ToolExecution[] } { + const taskExecutions: TaskExecution[] = []; + const regularToolExecutions: ToolExecution[] = []; + + // Build map of tool_use_id -> subagent for Task calls + const taskIdToSubagent = new Map(); + for (const subagent of allSubagents) { + if (subagent.parentTaskId) { + taskIdToSubagent.set(subagent.parentTaskId, subagent); + } + } + + // Collect all tool calls + const toolCalls = new Map(); + for (const msg of responses) { + if (msg.type === 'assistant') { + for (const toolCall of msg.toolCalls) { + toolCalls.set(toolCall.id, { call: toolCall, timestamp: msg.timestamp }); + } + } + } + + // Match with results + for (const msg of responses) { + if (msg.type === 'user' && msg.isMeta === true && msg.sourceToolUseID) { + const callInfo = toolCalls.get(msg.sourceToolUseID); + if (!callInfo) continue; + + // Check if this is a Task call with a subagent + const subagent = taskIdToSubagent.get(msg.sourceToolUseID); + if (callInfo.call.name === 'Task' && subagent) { + // This is a Task execution + taskExecutions.push({ + taskCall: callInfo.call, + taskCallTimestamp: callInfo.timestamp, + subagent, + toolResult: msg, + resultTimestamp: msg.timestamp, + durationMs: msg.timestamp.getTime() - callInfo.timestamp.getTime(), + }); + } else { + // Regular tool execution + const result = msg.toolResults[0]; + if (result) { + regularToolExecutions.push({ + toolCall: callInfo.call, + result, + startTime: callInfo.timestamp, + endTime: msg.timestamp, + durationMs: msg.timestamp.getTime() - callInfo.timestamp.getTime(), + }); + } + } + } + } + + return { taskExecutions, regularToolExecutions }; +} + +/** + * Link subagents to a conversation group based on timing. + */ +function linkSubagentsToGroup( + userMsg: ParsedMessage, + nextUserMsg: ParsedMessage | undefined, + allSubagents: Process[] +): Process[] { + const groupSubagents: Process[] = []; + const startTime = userMsg.timestamp; + const endTime = nextUserMsg?.timestamp ?? new Date(Date.now() + 1000 * 60 * 60 * 24); // Far future if no next message + + // Collect subagents that start within this group's time range + for (const subagent of allSubagents) { + if (subagent.startTime >= startTime && subagent.startTime < endTime) { + groupSubagents.push(subagent); + } + } + + return groupSubagents; +} + +/** + * Calculate group timing from user message and AI responses. + */ +function calculateGroupTiming( + userMsg: ParsedMessage, + aiResponses: ParsedMessage[] +): { startTime: Date; endTime: Date; durationMs: number } { + const startTime = userMsg.timestamp; + + let endTime = startTime; + for (const resp of aiResponses) { + if (resp.timestamp > endTime) { + endTime = resp.timestamp; + } + } + + return { + startTime, + endTime, + durationMs: endTime.getTime() - startTime.getTime(), + }; +} diff --git a/src/main/services/analysis/ProcessLinker.ts b/src/main/services/analysis/ProcessLinker.ts new file mode 100644 index 00000000..addcc2c1 --- /dev/null +++ b/src/main/services/analysis/ProcessLinker.ts @@ -0,0 +1,61 @@ +/** + * ProcessLinker service - Links subagent processes to AI chunks. + * + * Uses a two-tier linking strategy: + * 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call + * that spawned them. This is reliable even when the response is still in progress. + * 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to + * checking if the subagent's startTime falls within the chunk's time range. + */ + +import { type EnhancedAIChunk, type Process } from '@main/types'; + +/** + * Link processes to a single AI chunk. + * + * Uses a two-tier linking strategy: + * 1. Primary: parentTaskId matching - Links subagents to chunks containing the Task tool call + * that spawned them. This is reliable even when the response is still in progress. + * 2. Fallback: Timing-based - For orphaned subagents without parentTaskId, falls back to + * checking if the subagent's startTime falls within the chunk's time range. + */ +export function linkProcessesToAIChunk(chunk: EnhancedAIChunk, subagents: Process[]): void { + // Build set of Task tool IDs from this chunk's responses + const chunkTaskIds = new Set(); + for (const response of chunk.responses) { + for (const toolCall of response.toolCalls) { + if (toolCall.isTask) { + chunkTaskIds.add(toolCall.id); + } + } + } + + // Track which subagents have been linked + const linkedSubagentIds = new Set(); + + // Primary linking: Match subagents to Task calls by parentTaskId + for (const subagent of subagents) { + if (subagent.parentTaskId && chunkTaskIds.has(subagent.parentTaskId)) { + chunk.processes.push(subagent); + linkedSubagentIds.add(subagent.id); + } + } + + // Fallback linking: For orphaned subagents, use timing-based matching + // This handles edge cases where parentTaskId might not be set + for (const subagent of subagents) { + if (linkedSubagentIds.has(subagent.id)) { + continue; // Already linked via parentTaskId + } + + // Only use timing fallback if subagent has no parentTaskId + // (If it has parentTaskId but didn't match, it belongs to a different chunk) + if (!subagent.parentTaskId) { + if (subagent.startTime >= chunk.startTime && subagent.startTime <= chunk.endTime) { + chunk.processes.push(subagent); + } + } + } + + chunk.processes.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} diff --git a/src/main/services/analysis/SemanticStepExtractor.ts b/src/main/services/analysis/SemanticStepExtractor.ts new file mode 100644 index 00000000..12c4195f --- /dev/null +++ b/src/main/services/analysis/SemanticStepExtractor.ts @@ -0,0 +1,210 @@ +/** + * SemanticStepExtractor - Extracts semantic steps from AI chunks. + * + * Semantic steps represent logical units of work within AI responses: + * - thinking: Claude's reasoning process + * - tool_call: Tool invocation + * - tool_result: Tool execution result + * - output: Text output from Claude + * - subagent: Nested agent execution + * - interruption: User interruption + */ + +import { countContentTokens } from '@main/utils/tokenizer'; + +import type { AIChunk, EnhancedAIChunk, SemanticStep } from '@main/types'; + +/** + * Extract semantic steps from AI chunk responses. + * Semantic steps represent logical units of work within responses. + * + * Note: ALL tool calls are included, including Task tools with subagents. + * Task tools are filtered in the renderer's buildDisplayItems, + * but they are kept here for accurate context token tracking in aggregateToolOutputs. + */ +export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk): SemanticStep[] { + const steps: SemanticStep[] = []; + let stepIdCounter = 0; + + // Note: Task tool calls are included in semantic steps for context token tracking. + // The renderer's buildDisplayItems filters Task tools with subagents. + + // Process only AI responses (no user message in AIChunk) + for (const msg of chunk.responses) { + if (msg.type === 'assistant') { + // Extract from content blocks + const content = Array.isArray(msg.content) ? msg.content : []; + + for (const block of content) { + if (block.type === 'thinking' && block.thinking) { + // Calculate tokens for thinking content (output from Claude) + const thinkingTokens = countContentTokens(block.thinking); + + steps.push({ + id: `${msg.uuid}-thinking-${stepIdCounter++}`, + type: 'thinking', + startTime: new Date(msg.timestamp), + durationMs: 0, // Estimated from token count + content: { + thinkingText: block.thinking, + tokenCount: thinkingTokens, // Pre-computed token count + }, + tokens: { + input: 0, + output: thinkingTokens, // Thinking is output from Claude + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + sourceMessageId: msg.uuid, + }); + } + + if (block.type === 'tool_use' && block.id && block.name) { + // Include ALL tool calls in semantic steps, including Task tools with processes. + // Task tools with processes are filtered from DISPLAY in the renderer's buildDisplayItems, + // but they should be included here for accurate context token tracking. + // The renderer's aggregateToolOutputs will correctly count Task tool tokens + // as part of the main session's context consumption. + + // Calculate tool call tokens directly from name + input + // This reflects what actually enters the context window + const callTokens = countContentTokens(block.name + JSON.stringify(block.input)); + + steps.push({ + id: block.id, + type: 'tool_call', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + toolName: block.name, + toolInput: block.input, + sourceModel: msg.model, + }, + tokens: { + input: callTokens, + output: 0, + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + sourceMessageId: msg.uuid, + }); + } + + if (block.type === 'text' && block.text) { + // Calculate tokens for text output (Claude's generated text) + const textTokens = countContentTokens(block.text); + + steps.push({ + id: `${msg.uuid}-output-${stepIdCounter++}`, + type: 'output', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + outputText: block.text, + tokenCount: textTokens, // Pre-computed token count for consistency + }, + tokens: { + input: 0, // Text output is generated by Claude, not input + output: textTokens, + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + sourceMessageId: msg.uuid, + }); + } + } + } + + // Tool results from internal user messages + // Note: isMeta can be true or null in JSONL, so check for toolResults presence directly + if (msg.type === 'user' && msg.toolResults && msg.toolResults.length > 0) { + for (const result of msg.toolResults) { + steps.push({ + id: result.toolUseId, + type: 'tool_result', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + toolResultContent: + typeof result.content === 'string' ? result.content : JSON.stringify(result.content), + isError: result.isError, + toolUseResult: msg.toolUseResult, // Enriched data from message + tokenCount: countContentTokens(result.content), // Pre-computed token count + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + }); + } + } + + // User interruption messages + // These are user messages with array content containing text like "[Request interrupted by user]" + if (msg.type === 'user' && Array.isArray(msg.content)) { + let foundInterruption = false; + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + const textContent = block.text; + // Check for interruption patterns + if ( + textContent.includes('[Request interrupted by user]') || + textContent.includes('[Request interrupted by user for tool use]') + ) { + steps.push({ + id: `${msg.uuid}-interruption-${stepIdCounter++}`, + type: 'interruption', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + interruptionText: textContent, + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + }); + foundInterruption = true; + } + } + } + + // User-rejected tool use (toolUseResult field is "User rejected tool use") + if (!foundInterruption && (msg.toolUseResult as unknown) === 'User rejected tool use') { + steps.push({ + id: `${msg.uuid}-interruption-${stepIdCounter++}`, + type: 'interruption', + startTime: new Date(msg.timestamp), + durationMs: 0, + content: { + interruptionText: 'Request interrupted by user', + }, + context: msg.agentId ? 'subagent' : 'main', + agentId: msg.agentId, + }); + } + } + } + + // Link processes as steps + for (const process of chunk.processes) { + steps.push({ + id: process.id, + type: 'subagent', + startTime: process.startTime, + endTime: process.endTime, + durationMs: process.durationMs, + content: { + subagentId: process.id, + subagentDescription: process.description, + }, + tokens: { + input: process.metrics.inputTokens, + output: process.metrics.outputTokens, + cached: process.metrics.cacheReadTokens, + }, + isParallel: process.isParallel, + context: 'subagent', + agentId: process.id, + }); + } + + // Sort by startTime + return steps.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} diff --git a/src/main/services/analysis/SemanticStepGrouper.ts b/src/main/services/analysis/SemanticStepGrouper.ts new file mode 100644 index 00000000..9f3a9ec2 --- /dev/null +++ b/src/main/services/analysis/SemanticStepGrouper.ts @@ -0,0 +1,114 @@ +/** + * SemanticStepGrouper - Groups semantic steps for UI presentation. + * + * Groups steps by their source assistant message for collapsible UI. + * Steps from the same assistant message share the message UUID. + */ + +import type { SemanticStep, SemanticStepGroup } from '@main/types'; + +/** + * Build semantic step groups from steps. + * Groups steps by their source assistant message for collapsible UI. + */ +export function buildSemanticStepGroups(steps: SemanticStep[]): SemanticStepGroup[] { + const groups: SemanticStepGroup[] = []; + let groupIdCounter = 0; + + // Group steps by assistant message or standalone type + const stepsByGroup = new Map(); + + for (const step of steps) { + const messageId = extractMessageIdFromStep(step); + const existingSteps = stepsByGroup.get(messageId) ?? []; + existingSteps.push(step); + stepsByGroup.set(messageId, existingSteps); + } + + // Build groups + for (const [messageId, groupSteps] of stepsByGroup) { + const startTime = groupSteps[0].startTime; + const endTimes = groupSteps + .map((s) => s.endTime ?? new Date(s.startTime.getTime() + s.durationMs)) + .map((d) => d.getTime()); + const endTime = new Date(Math.max(...endTimes)); + const totalDuration = groupSteps.reduce((sum, s) => sum + s.durationMs, 0); + + groups.push({ + id: `group-${++groupIdCounter}`, + label: buildGroupLabel(groupSteps), + steps: groupSteps, + isGrouped: messageId !== null && groupSteps.length > 1, + sourceMessageId: messageId ?? undefined, + startTime, + endTime, + totalDuration, + }); + } + + // Sort by startTime + return groups.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); +} + +/** + * Extract the assistant message ID from a step, or null if standalone. + * Steps from the same assistant message share the message UUID. + * Subagents, tool results, and interruptions are standalone (null). + */ +function extractMessageIdFromStep(step: SemanticStep): string | null { + // Use sourceMessageId if available + if (step.sourceMessageId) { + return step.sourceMessageId; + } + + // Standalone steps (not grouped) + if (step.type === 'subagent') return null; + if (step.type === 'tool_result') return null; + if (step.type === 'interruption') return null; + if (step.type === 'tool_call') return null; // Tool calls are standalone + + return null; +} + +/** + * Build a descriptive label for a group. + */ +function buildGroupLabel(steps: SemanticStep[]): string { + if (steps.length === 1) { + const step = steps[0]; + switch (step.type) { + case 'thinking': + return 'Thinking'; + case 'tool_call': + return `Tool: ${step.content.toolName ?? 'Unknown'}`; + case 'tool_result': + return `Result: ${step.content.isError ? 'Error' : 'Success'}`; + case 'subagent': + return step.content.subagentDescription ?? 'Subagent'; + case 'output': + return 'Output'; + case 'interruption': + return 'Interruption'; + } + } + + // Multiple steps grouped together + const hasThinking = steps.some((s) => s.type === 'thinking'); + const hasOutput = steps.some((s) => s.type === 'output'); + const toolCalls = steps.filter((s) => s.type === 'tool_call'); + + if (toolCalls.length > 0) { + return `Tools (${toolCalls.length})`; + } + if (hasThinking && hasOutput) { + return 'Assistant Response'; + } + if (hasThinking) { + return 'Thinking'; + } + if (hasOutput) { + return 'Output'; + } + + return `Response (${steps.length} steps)`; +} diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts new file mode 100644 index 00000000..82fea77e --- /dev/null +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -0,0 +1,135 @@ +/** + * SubagentDetailBuilder - Builds detailed information for subagent drill-down. + * + * Loads subagent JSONL files, resolves nested subagents, and builds + * complete SubagentDetail objects for the drill-down modal. + */ + +import { + type EnhancedAIChunk, + type EnhancedChunk, + isEnhancedAIChunk, + type ParsedMessage, + type Process, + type SemanticStepGroup, + type SubagentDetail, +} from '@main/types'; +import { countTokens } from '@main/utils/tokenizer'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Service:SubagentDetailBuilder'); + +import { buildSemanticStepGroups } from './SemanticStepGrouper'; + +import type { SubagentResolver } from '../discovery/SubagentResolver'; +import type { SessionParser } from '../parsing/SessionParser'; + +/** + * Build detailed information for a specific subagent. + * Used for drill-down modal to show subagent's internal execution. + * + * @param projectId - Project ID + * @param _sessionId - Parent session ID (currently unused, kept for API consistency) + * @param subagentId - Subagent ID to load + * @param sessionParser - SessionParser instance for parsing subagent file + * @param subagentResolver - SubagentResolver instance for nested subagents + * @param buildChunksFn - Function to build chunks from messages and subagents + * @returns SubagentDetail or null if not found + */ +export async function buildSubagentDetail( + projectId: string, + _sessionId: string, // Unused but kept for API consistency + subagentId: string, + sessionParser: SessionParser, + subagentResolver: SubagentResolver, + buildChunksFn: (messages: ParsedMessage[], subagents: Process[]) => EnhancedChunk[] +): Promise { + try { + const fs = await import('fs/promises'); + const path = await import('path'); + const os = await import('os'); + + // Construct path to subagent JSONL file + const claudeDir = path.join(os.homedir(), '.claude', 'projects'); + const subagentPath = path.join(claudeDir, projectId, 'subagents', `agent-${subagentId}.jsonl`); + + // Check if file exists + try { + await fs.access(subagentPath); + } catch { + logger.warn(`Subagent file not found: ${subagentPath}`); + return null; + } + + // Parse subagent JSONL file + const parsedSession = await sessionParser.parseSessionFile(subagentPath); + + // Resolve nested subagents within this subagent + const nestedSubagents = await subagentResolver.resolveSubagents( + projectId, + subagentId, // Use subagentId as sessionId for nested resolution + parsedSession.taskCalls + ); + + // Build chunks with semantic steps + const chunks = buildChunksFn(parsedSession.messages, nestedSubagents); + + // Extract description (try to get from first user message) + let description = 'Subagent'; + if (parsedSession.messages.length > 0) { + const firstUserMsg = parsedSession.messages.find( + (m) => m.type === 'user' && typeof m.content === 'string' + ); + if (firstUserMsg && typeof firstUserMsg.content === 'string') { + description = firstUserMsg.content.substring(0, 100); + if (firstUserMsg.content.length > 100) { + description += '...'; + } + } + } + + // Calculate timing + const times = parsedSession.messages.map((m) => m.timestamp.getTime()); + const startTime = new Date(Math.min(...times)); + const endTime = new Date(Math.max(...times)); + const duration = endTime.getTime() - startTime.getTime(); + + // Calculate thinking tokens + let thinkingTokens = 0; + for (const msg of parsedSession.messages) { + if (msg.type === 'assistant' && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'thinking' && block.thinking) { + thinkingTokens += countTokens(block.thinking); + } + } + } + } + + // Build semantic step groups from AI chunks only (UserChunks don't have semanticSteps) + const allSemanticSteps = chunks + .filter((c): c is EnhancedAIChunk => isEnhancedAIChunk(c)) + .flatMap((c) => c.semanticSteps); + const semanticStepGroups: SemanticStepGroup[] | undefined = + allSemanticSteps.length > 0 ? buildSemanticStepGroups(allSemanticSteps) : undefined; + + return { + id: subagentId, + description, + chunks, + semanticStepGroups, + startTime, + endTime, + duration, + metrics: { + inputTokens: parsedSession.metrics.inputTokens, + outputTokens: parsedSession.metrics.outputTokens, + thinkingTokens, + messageCount: parsedSession.metrics.messageCount, + }, + }; + } catch (error) { + logger.error(`Error building subagent detail for ${subagentId}:`, error); + return null; + } +} diff --git a/src/main/services/analysis/ToolExecutionBuilder.ts b/src/main/services/analysis/ToolExecutionBuilder.ts new file mode 100644 index 00000000..c7e2656d --- /dev/null +++ b/src/main/services/analysis/ToolExecutionBuilder.ts @@ -0,0 +1,82 @@ +/** + * ToolExecutionBuilder - Builds tool execution tracking from messages. + * + * Matches tool calls with their results using: + * 1. sourceToolUseID for accurate internal user message matching + * 2. toolResults array fallback for other patterns + */ + +import type { ParsedMessage, ToolCall, ToolExecution } from '@main/types'; + +/** + * Build tool execution tracking from messages. + * Enhanced to use sourceToolUseID for more accurate matching. + */ +export function buildToolExecutions(messages: ParsedMessage[]): ToolExecution[] { + const executions: ToolExecution[] = []; + const toolCallMap = new Map(); + + // First pass: collect all tool calls + for (const msg of messages) { + for (const toolCall of msg.toolCalls) { + toolCallMap.set(toolCall.id, { + call: toolCall, + startTime: msg.timestamp, + }); + } + } + + // Second pass: match with results and build executions + // Try sourceToolUseID first (most accurate), then fall back to toolResults array + for (const msg of messages) { + // Check if this message has a sourceToolUseID (internal user messages) + if (msg.sourceToolUseID) { + const callInfo = toolCallMap.get(msg.sourceToolUseID); + if (callInfo && msg.toolResults.length > 0) { + // Use the first tool result for this internal user message + const result = msg.toolResults[0]; + executions.push({ + toolCall: callInfo.call, + result, + startTime: callInfo.startTime, + endTime: msg.timestamp, + durationMs: msg.timestamp.getTime() - callInfo.startTime.getTime(), + }); + } + } + + // Also check toolResults array for any results not matched above + for (const result of msg.toolResults) { + // Skip if already matched via sourceToolUseID + const alreadyMatched = executions.some((e) => e.result?.toolUseId === result.toolUseId); + if (alreadyMatched) continue; + + const callInfo = toolCallMap.get(result.toolUseId); + if (callInfo) { + executions.push({ + toolCall: callInfo.call, + result, + startTime: callInfo.startTime, + endTime: msg.timestamp, + durationMs: msg.timestamp.getTime() - callInfo.startTime.getTime(), + }); + } + } + } + + // Add calls without results + for (const [id, callInfo] of toolCallMap) { + const hasResult = executions.some((e) => e.toolCall.id === id); + if (!hasResult) { + executions.push({ + toolCall: callInfo.call, + startTime: callInfo.startTime, + }); + } + } + + // Sort by start time + executions.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + return executions; +} diff --git a/src/main/services/analysis/ToolResultExtractor.ts b/src/main/services/analysis/ToolResultExtractor.ts new file mode 100644 index 00000000..eccde23d --- /dev/null +++ b/src/main/services/analysis/ToolResultExtractor.ts @@ -0,0 +1,224 @@ +/** + * ToolResultExtractor service - Extracts tool results from messages. + * + * Provides utilities for: + * - Building tool_use maps for linking results to calls + * - Building tool_result maps for token estimation + * - Estimating token counts from content + * - Extracting tool results from various message formats + */ + +import { isToolResultContent, type ParsedMessage } from '@main/types'; +import { countContentTokens } from '@main/utils/tokenizer'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Extracted tool result information for trigger matching. + */ +export interface ExtractedToolResult { + toolUseId: string; + isError: boolean; + content: string | unknown[]; + toolName?: string; +} + +/** + * Tool use information from assistant messages. + */ +export interface ToolUseInfo { + name: string; + input: Record; +} + +/** + * Tool result information for token estimation. + */ +export interface ToolResultInfo { + content: string | unknown[]; + isError: boolean; +} + +// ============================================================================= +// Map Building +// ============================================================================= + +/** + * Builds a map of tool_use_id to tool_use content. + * This allows linking tool_results back to their tool_use calls to check tool names. + */ +export function buildToolUseMap(messages: ParsedMessage[]): Map { + const map = new Map(); + + for (const message of messages) { + if (message.type !== 'assistant') continue; + + // Check content array for tool_use blocks + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use') { + const toolUse = block; + map.set(toolUse.id, { + name: toolUse.name, + input: toolUse.input || {}, + }); + } + } + } + + // Also check toolCalls if present + if (message.toolCalls) { + for (const toolCall of message.toolCalls) { + map.set(toolCall.id, { + name: toolCall.name, + input: toolCall.input || {}, + }); + } + } + } + + return map; +} + +/** + * Builds a map of tool_use_id to tool_result content. + * Used for estimating output tokens per tool_use. + */ +export function buildToolResultMap(messages: ParsedMessage[]): Map { + const map = new Map(); + + for (const message of messages) { + // Check content array for tool_result blocks + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (isToolResultContent(block)) { + map.set(block.tool_use_id, { + content: block.content, + isError: block.is_error === true, + }); + } + } + } + + // Also check toolResults array if present + if (message.toolResults) { + for (const toolResult of message.toolResults) { + map.set(toolResult.toolUseId, { + content: toolResult.content, + isError: toolResult.isError === true, + }); + } + } + + // Also check toolUseResult if present (enriched data) + if (message.toolUseResult && message.sourceToolUseID) { + const content = extractContentFromToolUseResult(message.toolUseResult); + const isError = + message.toolUseResult.isError === true || message.toolUseResult.is_error === true; + map.set(message.sourceToolUseID, { content, isError }); + } + } + + return map; +} + +// ============================================================================= +// Token Estimation +// ============================================================================= + +/** + * Estimates token count from content using the shared tokenizer. + * Uses the same calculation as ChunkBuilder for consistency with UI display. + */ +export function estimateTokens(content: string | unknown[] | Record): number { + if (typeof content === 'string') { + return countContentTokens(content); + } + // For objects/arrays, use countContentTokens which handles JSON.stringify + return countContentTokens(content as unknown[]); +} + +// ============================================================================= +// Tool Result Extraction +// ============================================================================= + +/** + * Extracts content string from toolUseResult. + */ +function extractContentFromToolUseResult(toolUseResult: Record): string { + if (typeof toolUseResult.error === 'string') { + return toolUseResult.error; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim()) { + return toolUseResult.stderr; + } + if (typeof toolUseResult.content === 'string') { + return toolUseResult.content; + } + if (typeof toolUseResult.message === 'string') { + return toolUseResult.message; + } + return ''; +} + +/** + * Extracts tool results from a message. + * Handles multiple patterns of tool result storage. + * + * @param message - The parsed message to extract from + * @param findToolNameFn - Function to find tool name by tool use ID + */ +export function extractToolResults( + message: ParsedMessage, + findToolNameFn: (message: ParsedMessage, toolUseId: string) => string | null +): ExtractedToolResult[] { + const results: ExtractedToolResult[] = []; + + // Pattern 1: Check toolResults array on ParsedMessage + if (message.toolResults && message.toolResults.length > 0) { + for (const toolResult of message.toolResults) { + results.push({ + toolUseId: toolResult.toolUseId, + isError: toolResult.isError === true, + content: toolResult.content, + toolName: findToolNameFn(message, toolResult.toolUseId) ?? undefined, + }); + } + } + + // Pattern 2: Check toolUseResult field (enriched data from entry) + if (message.toolUseResult) { + const toolUseResult = message.toolUseResult; + const hasError = toolUseResult.isError === true || toolUseResult.is_error === true; + const toolUseId = + (typeof toolUseResult.toolUseId === 'string' ? toolUseResult.toolUseId : undefined) ?? + message.sourceToolUseID; + + if (toolUseId) { + results.push({ + toolUseId, + isError: hasError, + content: extractContentFromToolUseResult(toolUseResult), + toolName: typeof toolUseResult.toolName === 'string' ? toolUseResult.toolName : undefined, + }); + } + } + + // Pattern 3: Check content blocks for tool_result + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (isToolResultContent(block)) { + results.push({ + toolUseId: block.tool_use_id, + isError: block.is_error === true, + content: block.content, + toolName: findToolNameFn(message, block.tool_use_id) ?? undefined, + }); + } + } + } + + return results; +} diff --git a/src/main/services/analysis/ToolSummaryFormatter.ts b/src/main/services/analysis/ToolSummaryFormatter.ts new file mode 100644 index 00000000..840d5b0c --- /dev/null +++ b/src/main/services/analysis/ToolSummaryFormatter.ts @@ -0,0 +1,113 @@ +/** + * ToolSummaryFormatter service - Formats tool information for display. + * + * Provides utilities for: + * - Extracting filenames from paths + * - Truncating long strings + * - Formatting token counts + * - Generating human-readable tool summaries + */ + +import { formatTokens } from '@shared/utils/tokenFormatting'; +import * as path from 'path'; + +// Re-export for backwards compatibility +export { formatTokens }; + +// ============================================================================= +// String Utilities +// ============================================================================= + +/** + * Extracts filename from a file path. + */ +function getFileName(filePath: string): string { + return path.basename(filePath) || filePath; +} + +/** + * Truncates a string to a maximum length with ellipsis. + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + '...'; +} + +// ============================================================================= +// Tool Summary Generation +// ============================================================================= + +/** + * Generates a human-readable summary for a tool call. + * Simplified version of LinkedToolItem's getToolSummary. + */ +export function getToolSummary(toolName: string, input: Record): string { + switch (toolName) { + case 'Edit': + case 'Read': + case 'Write': { + const filePath = input.file_path as string | undefined; + if (filePath) return getFileName(filePath); + return toolName; + } + + case 'Bash': { + const description = input.description as string | undefined; + const command = input.command as string | undefined; + if (description) return truncate(description, 50); + if (command) return truncate(command, 50); + return 'Bash'; + } + + case 'Grep': + case 'Glob': { + const pattern = input.pattern as string | undefined; + if (pattern) return `"${truncate(pattern, 30)}"`; + return toolName; + } + + case 'Task': { + const description = input.description as string | undefined; + const prompt = input.prompt as string | undefined; + const subagentType = input.subagent_type as string | undefined; + const desc = description ?? prompt; + const typeStr = subagentType ? `${subagentType} - ` : ''; + if (desc) return `${typeStr}${truncate(desc, 40)}`; + return subagentType ?? 'Task'; + } + + case 'Skill': { + const skill = input.skill as string | undefined; + if (skill) return skill; + return 'Skill'; + } + + case 'WebFetch': { + const url = input.url as string | undefined; + if (url) { + try { + const urlObj = new URL(url); + return truncate(urlObj.hostname + urlObj.pathname, 50); + } catch { + return truncate(url, 50); + } + } + return 'WebFetch'; + } + + case 'WebSearch': { + const query = input.query as string | undefined; + if (query) return `"${truncate(query, 40)}"`; + return 'WebSearch'; + } + + default: { + // Try common parameter names + const nameField = input.name ?? input.path ?? input.file ?? input.query ?? input.command; + if (typeof nameField === 'string') { + return truncate(nameField, 50); + } + return toolName; + } + } +} diff --git a/src/main/services/analysis/index.ts b/src/main/services/analysis/index.ts new file mode 100644 index 00000000..7f3adbe9 --- /dev/null +++ b/src/main/services/analysis/index.ts @@ -0,0 +1,26 @@ +/** + * Analysis services - Chunk building and session analysis. + * + * Exports: + * - ChunkBuilder: Builds visualization chunks from parsed session data + * - ChunkFactory: Creates individual chunk objects + * - ProcessLinker: Links subagent processes to chunks + * - ConversationGroupBuilder: Alternative grouping strategy + * - SemanticStepExtractor: Extracts semantic steps from AI chunks + * - SemanticStepGrouper: Groups semantic steps for UI + * - ToolExecutionBuilder: Builds tool execution tracking + * - ToolResultExtractor: Extracts results from tool calls + * - ToolSummaryFormatter: Formats tool summaries + * - SubagentDetailBuilder: Builds subagent drill-down details + */ + +export * from './ChunkBuilder'; +export * from './ChunkFactory'; +export * from './ConversationGroupBuilder'; +export * from './ProcessLinker'; +export * from './SemanticStepExtractor'; +export * from './SemanticStepGrouper'; +export * from './SubagentDetailBuilder'; +export * from './ToolExecutionBuilder'; +export * from './ToolResultExtractor'; +export * from './ToolSummaryFormatter'; diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts new file mode 100644 index 00000000..b1861197 --- /dev/null +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -0,0 +1,118 @@ +/** + * ProjectPathResolver - Resolves encoded project IDs to canonical filesystem paths. + * + * Resolution order: + * 1) cwd hint (if provided and absolute) + * 2) cwd extracted from session JSONL files (authoritative) + * 3) decodePath(projectId) fallback (lossy, best-effort) + * + * Results are memoized per projectId and can be invalidated by file watcher events. + */ + +import { extractCwd } from '@main/utils/jsonl'; +import { decodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { subprojectRegistry } from './SubprojectRegistry'; + +const logger = createLogger('Discovery:ProjectPathResolver'); + +interface ResolveProjectPathOptions { + cwdHint?: string; + sessionPaths?: string[]; + forceRefresh?: boolean; +} + +export class ProjectPathResolver { + private readonly projectsDir: string; + private readonly projectPathCache = new Map(); + + constructor(projectsDir?: string) { + this.projectsDir = projectsDir ?? getProjectsBasePath(); + } + + /** + * Resolve a project ID to a canonical path. + */ + async resolveProjectPath( + projectId: string, + options?: ResolveProjectPathOptions + ): Promise { + const opts = options ?? {}; + + // Short-circuit for composite IDs: use the registry's cwd directly + const registryCwd = subprojectRegistry.getCwd(projectId); + if (registryCwd) { + this.projectPathCache.set(projectId, registryCwd); + return registryCwd; + } + + if (!opts.forceRefresh) { + const cached = this.projectPathCache.get(projectId); + if (cached) { + return cached; + } + } + + const cwdHint = opts.cwdHint?.trim(); + if (cwdHint && path.isAbsolute(cwdHint)) { + this.projectPathCache.set(projectId, cwdHint); + return cwdHint; + } + + const sessionPaths = opts.sessionPaths?.length + ? opts.sessionPaths + : this.listSessionPaths(projectId); + + for (const sessionPath of sessionPaths) { + try { + const cwd = await extractCwd(sessionPath); + if (cwd && path.isAbsolute(cwd)) { + this.projectPathCache.set(projectId, cwd); + return cwd; + } + } catch { + // Ignore unreadable or malformed files and continue to next candidate. + } + } + + const decoded = decodePath(extractBaseDir(projectId)); + this.projectPathCache.set(projectId, decoded); + return decoded; + } + + /** + * Invalidate a single project's cached path. + */ + invalidateProject(projectId: string): void { + this.projectPathCache.delete(projectId); + } + + /** + * Clear all cached project paths. + */ + clear(): void { + this.projectPathCache.clear(); + } + + private listSessionPaths(projectId: string): string[] { + const projectDir = path.join(this.projectsDir, extractBaseDir(projectId)); + if (!fs.existsSync(projectDir)) { + return []; + } + + try { + const entries = fs.readdirSync(projectDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => path.join(projectDir, entry.name)); + } catch (error) { + logger.error(`Failed to read session files for ${projectId}:`, error); + return []; + } + } +} + +export const projectPathResolver = new ProjectPathResolver(); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts new file mode 100644 index 00000000..e142dbe7 --- /dev/null +++ b/src/main/services/discovery/ProjectScanner.ts @@ -0,0 +1,819 @@ +/** + * ProjectScanner service - Scans ~/.claude/projects/ directory and lists all projects. + * + * Responsibilities: + * - Read project directories from ~/.claude/projects/ + * - Decode directory names to original paths (with cwd fallback) + * - List session files for each project + * - Read task list data from ~/.claude/todos/ + * - Return sorted list of projects by recent activity + * + * Delegates to specialized services: + * - SessionContentFilter: Noise detection and message filtering + * - WorktreeGrouper: Git repository grouping + * - SubagentLocator: Subagent file lookup + * - SessionSearcher: Search functionality + */ + +import { + type PaginatedSessionsResult, + type Project, + type RepositoryGroup, + type SearchSessionsResult, + type Session, + type SessionCursor, + type SessionsPaginationOptions, +} from '@main/types'; +import { analyzeSessionFileMetadata, extractCwd } from '@main/utils/jsonl'; +import { + buildSessionPath, + buildSubagentsPath, + buildTodoPath, + extractBaseDir, + extractProjectName, + extractSessionId, + getProjectsBasePath, + getTodosBasePath, + isValidEncodedPath, +} from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { SessionContentFilter } from './SessionContentFilter'; +import { subprojectRegistry } from './SubprojectRegistry'; + +const logger = createLogger('Discovery:ProjectScanner'); +import { ProjectPathResolver } from './ProjectPathResolver'; +import { SessionSearcher } from './SessionSearcher'; +import { SubagentLocator } from './SubagentLocator'; +import { WorktreeGrouper } from './WorktreeGrouper'; + +export class ProjectScanner { + private readonly projectsDir: string; + private readonly todosDir: string; + private readonly contentPresenceCache = new Map< + string, + { mtimeMs: number; hasContent: boolean } + >(); + private readonly sessionMetadataCache = new Map< + string, + { + mtimeMs: number; + metadata: Awaited>; + } + >(); + + // Delegated services + private readonly sessionContentFilter: typeof SessionContentFilter; + private readonly worktreeGrouper: WorktreeGrouper; + private readonly subagentLocator: SubagentLocator; + private readonly sessionSearcher: SessionSearcher; + private readonly projectPathResolver: ProjectPathResolver; + + constructor(projectsDir?: string, todosDir?: string) { + this.projectsDir = projectsDir ?? getProjectsBasePath(); + this.todosDir = todosDir ?? getTodosBasePath(); + + // Initialize delegated services + this.sessionContentFilter = SessionContentFilter; + this.worktreeGrouper = new WorktreeGrouper(this.projectsDir); + this.subagentLocator = new SubagentLocator(this.projectsDir); + this.sessionSearcher = new SessionSearcher(this.projectsDir); + this.projectPathResolver = new ProjectPathResolver(this.projectsDir); + } + + // =========================================================================== + // Project Scanning + // =========================================================================== + + /** + * Scans the projects directory and returns a list of all projects. + * @returns Promise resolving to projects sorted by most recent activity + */ + async scan(): Promise { + try { + if (!fs.existsSync(this.projectsDir)) { + logger.warn(`Projects directory does not exist: ${this.projectsDir}`); + return []; + } + + // Clear the subproject registry on full re-scan + subprojectRegistry.clear(); + + const entries = fs.readdirSync(this.projectsDir, { withFileTypes: true }); + + // Filter to only directories with valid encoding pattern + const projectDirs = entries.filter( + (entry) => entry.isDirectory() && isValidEncodedPath(entry.name) + ); + + // Process each project directory (may return multiple projects per dir) + const projectArrays = await Promise.all(projectDirs.map((dir) => this.scanProject(dir.name))); + + // Flatten and sort by most recent + const validProjects = projectArrays.flat(); + validProjects.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); + + return validProjects; + } catch (error) { + logger.error('Error scanning projects directory:', error); + return []; + } + } + + // =========================================================================== + // Repository Grouping (Worktree Support) + // =========================================================================== + + /** + * Scans projects and groups them by git repository. + * Projects belonging to the same git repository (main repo + worktrees) + * are grouped together under a single RepositoryGroup. + * Non-git projects are represented as single-worktree groups. + * + * Sessions are filtered to exclude noise-only sessions, so counts + * accurately reflect visible sessions in the UI. + * + * @returns Promise resolving to RepositoryGroups sorted by most recent activity + */ + async scanWithWorktreeGrouping(): Promise { + try { + // 1. Scan all projects using existing logic + const projects = await this.scan(); + + if (projects.length === 0) { + return []; + } + + // 2. Delegate to WorktreeGrouper + return this.worktreeGrouper.groupByRepository(projects); + } catch (error) { + logger.error('Error scanning with worktree grouping:', error); + return []; + } + } + + /** + * Lists sessions for a specific worktree within a repository group. + * This is a convenience method that delegates to listSessions since + * worktree.id is the same as project.id. + * + * @param worktreeId - The worktree ID (same as project ID) + */ + async listWorktreeSessions(worktreeId: string): Promise { + return this.listSessions(worktreeId); + } + + // =========================================================================== + // Project Scanning (continued) + // =========================================================================== + + /** + * Scans a single project directory and returns project metadata. + * If sessions have different cwd values, splits into multiple projects. + */ + private async scanProject(encodedName: string): Promise { + try { + const projectPath = path.join(this.projectsDir, encodedName); + const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + + // Get session files (.jsonl at root level) + const sessionFiles = entries.filter( + (entry) => entry.isFile() && entry.name.endsWith('.jsonl') + ); + + if (sessionFiles.length === 0) { + return []; + } + + // Collect file stats and cwd for each session + interface SessionInfo { + sessionId: string; + filePath: string; + mtimeMs: number; + birthtimeMs: number; + cwd: string | null; + } + + const sessionInfos: SessionInfo[] = await Promise.all( + sessionFiles.map(async (file) => { + const filePath = path.join(projectPath, file.name); + const stats = fs.statSync(filePath); + let cwd: string | null = null; + try { + cwd = await extractCwd(filePath); + } catch { + // Ignore unreadable files + } + return { + sessionId: extractSessionId(file.name), + filePath, + mtimeMs: stats.mtimeMs, + birthtimeMs: stats.birthtimeMs, + cwd, + }; + }) + ); + + // Group sessions by cwd + const cwdGroups = new Map(); + const baseName = extractProjectName(encodedName); + const decodedFallback = baseName; // Used when cwd is null + + for (const info of sessionInfos) { + const key = info.cwd ?? `__decoded__${decodedFallback}`; + const group = cwdGroups.get(key) ?? []; + group.push(info); + cwdGroups.set(key, group); + } + + // If only 1 unique cwd, return single project (current behavior) + if (cwdGroups.size <= 1) { + const allSessionIds = sessionInfos.map((s) => s.sessionId); + let mostRecentSession: number | undefined; + let createdAt = Date.now(); + for (const info of sessionInfos) { + if (!mostRecentSession || info.mtimeMs > mostRecentSession) { + mostRecentSession = info.mtimeMs; + } + if (info.birthtimeMs < createdAt) { + createdAt = info.birthtimeMs; + } + } + + const sessionPaths = sessionInfos.map((s) => s.filePath); + const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, { + sessionPaths, + }); + + return [ + { + id: encodedName, + path: actualPath, + name: baseName, + sessions: allSessionIds, + createdAt: Math.floor(createdAt), + mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, + }, + ]; + } + + // Multiple unique cwds: split into subprojects + const projects: Project[] = []; + + // Find the "root" cwd (shortest path, or the one matching the decoded name) + const cwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__')); + const rootCwd = cwdKeys.reduce( + (shortest, cwd) => (cwd.length <= shortest.length ? cwd : shortest), + cwdKeys[0] ?? '' + ); + + for (const [cwdKey, sessions] of cwdGroups) { + const isDecodedFallback = cwdKey.startsWith('__decoded__'); + const actualCwd = isDecodedFallback ? null : cwdKey; + + // Register in subproject registry + const sessionIds = sessions.map((s) => s.sessionId); + const compositeId = subprojectRegistry.register( + encodedName, + actualCwd ?? decodedFallback, + sessionIds + ); + + // Compute timestamps + let mostRecentSession: number | undefined; + let createdAt = Date.now(); + for (const info of sessions) { + if (!mostRecentSession || info.mtimeMs > mostRecentSession) { + mostRecentSession = info.mtimeMs; + } + if (info.birthtimeMs < createdAt) { + createdAt = info.birthtimeMs; + } + } + + // Build display name + let displayName: string; + if (!actualCwd || actualCwd === rootCwd) { + displayName = baseName; + } else { + // Use last segment of cwd for disambiguation + const lastSegment = path.basename(actualCwd); + displayName = `${baseName} (${lastSegment})`; + } + + projects.push({ + id: compositeId, + path: actualCwd ?? decodedFallback, + name: displayName, + sessions: sessionIds, + createdAt: Math.floor(createdAt), + mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, + }); + } + + return projects; + } catch (error) { + logger.error(`Error scanning project ${encodedName}:`, error); + return []; + } + } + + /** + * Gets details for a specific project by ID. + * Handles composite IDs by scanning the base directory and finding the matching subproject. + */ + async getProject(projectId: string): Promise { + const baseDir = extractBaseDir(projectId); + const projectPath = path.join(this.projectsDir, baseDir); + + if (!fs.existsSync(projectPath)) { + return null; + } + + // For composite IDs, scan and find the matching subproject + if (subprojectRegistry.isComposite(projectId)) { + const projects = await this.scanProject(baseDir); + return projects.find((p) => p.id === projectId) ?? null; + } + + const projects = await this.scanProject(baseDir); + return projects.find((p) => p.id === projectId) ?? projects[0] ?? null; + } + + // =========================================================================== + // Session Listing + // =========================================================================== + + /** + * Lists all sessions for a given project with metadata. + * Filters out sessions that contain only noise messages. + */ + async listSessions(projectId: string): Promise { + try { + const baseDir = extractBaseDir(projectId); + const projectPath = path.join(this.projectsDir, baseDir); + const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + + if (!fs.existsSync(projectPath)) { + return []; + } + + const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); + + // Filter to only sessions belonging to this subproject + if (sessionFilter) { + sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); + } + + const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name)); + const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths); + + const sessions = await Promise.all( + sessionFiles.map(async (file) => { + const sessionId = extractSessionId(file.name); + const filePath = path.join(projectPath, file.name); + + // Check if session has non-noise messages (delegated to SessionContentFilter) + const hasContent = await this.hasDisplayableContent(filePath); + if (!hasContent) { + return null; // Filter out noise-only sessions + } + + return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath); + }) + ); + + // Filter out null results (noise-only sessions) + const validSessions = sessions.filter((s): s is Session => s !== null); + + // Sort by created date (most recent first) + validSessions.sort((a, b) => b.createdAt - a.createdAt); + + return validSessions; + } catch (error) { + logger.error(`Error listing sessions for project ${projectId}:`, error); + return []; + } + } + + /** + * Lists sessions for a project with cursor-based pagination. + * Efficiently fetches only the sessions needed for the current page. + * + * @param projectId - The project ID to list sessions for + * @param cursor - Base64-encoded cursor from previous page (null for first page) + * @param limit - Number of sessions to return (default 20) + * @returns Paginated result with sessions, cursor, and metadata + */ + async listSessionsPaginated( + projectId: string, + cursor: string | null, + limit: number = 20, + options?: SessionsPaginationOptions + ): Promise { + try { + const includeTotalCount = options?.includeTotalCount ?? false; + const prefilterAll = options?.prefilterAll ?? false; + const baseDir = extractBaseDir(projectId); + const projectPath = path.join(this.projectsDir, baseDir); + const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + + if (!fs.existsSync(projectPath)) { + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } + + // Step 1: Get all session files with their timestamps (lightweight stat calls) + const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + let sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); + + // Filter to only sessions belonging to this subproject + if (sessionFilter) { + sessionFiles = sessionFiles.filter((f) => sessionFilter.has(extractSessionId(f.name))); + } + + // Get stats for all session files + interface SessionFileInfo { + name: string; + sessionId: string; + timestamp: number; + filePath: string; + mtimeMs: number; + } + const fileInfos: SessionFileInfo[] = []; + + for (const file of sessionFiles) { + const filePath = path.join(projectPath, file.name); + try { + const stats = fs.statSync(filePath); + fileInfos.push({ + name: file.name, + sessionId: extractSessionId(file.name), + timestamp: stats.mtimeMs, + filePath, + mtimeMs: stats.mtimeMs, + }); + } catch { + // Skip files we can't stat + continue; + } + } + + // Step 2: Sort by timestamp descending (most recent first) + fileInfos.sort((a, b) => { + if (b.timestamp !== a.timestamp) { + return b.timestamp - a.timestamp; + } + // Tie-breaker: sort by sessionId alphabetically + return a.sessionId.localeCompare(b.sessionId); + }); + + // Step 3: Optionally pre-filter all sessions for accurate total count + // This is slower but provides exact totalCount. + let validSessionIds: Set | null = null; + let totalCount = 0; + if (prefilterAll) { + validSessionIds = new Set(); + for (const fileInfo of fileInfos) { + if (await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs)) { + validSessionIds.add(fileInfo.sessionId); + } + } + totalCount = validSessionIds.size; + } + + // Step 4: Apply cursor filter to find starting position + let startIndex = 0; + if (cursor) { + try { + const decoded = JSON.parse( + Buffer.from(cursor, 'base64').toString('utf8') + ) as SessionCursor; + startIndex = fileInfos.findIndex((info) => { + // Find the first item that comes AFTER the cursor + if (info.timestamp < decoded.timestamp) return true; + if (info.timestamp === decoded.timestamp && info.sessionId > decoded.sessionId) + return true; + return false; + }); + // If cursor not found, start from beginning + if (startIndex === -1) startIndex = fileInfos.length; + } catch { + // Invalid cursor, start from beginning + startIndex = 0; + } + } + + // Step 5: Fetch sessions for this page + const decodedPath = await this.resolveProjectPathForId( + projectId, + fileInfos.map((fileInfo) => fileInfo.filePath) + ); + const sessions: Session[] = []; + let scannedCandidates = 0; + + // Fast path: avoid pre-filtering everything. Scan until we have enough page items. + for (let i = startIndex; i < fileInfos.length; i++) { + const fileInfo = fileInfos[i]; + if (!fileInfo) { + continue; + } + scannedCandidates++; + + let hasContent: boolean; + if (validSessionIds) { + hasContent = validSessionIds.has(fileInfo.sessionId); + } else { + hasContent = await this.hasDisplayableContent(fileInfo.filePath, fileInfo.mtimeMs); + } + if (!hasContent) { + continue; + } + + const session = await this.buildSessionMetadata( + projectId, + fileInfo.sessionId, + fileInfo.filePath, + decodedPath + ); + sessions.push(session); + + if (sessions.length >= limit + 1) { + break; + } + } + + // Step 6: Build next cursor + let nextCursor: string | null = null; + const hasMore = sessions.length > limit || startIndex + scannedCandidates < fileInfos.length; + + const pageSessions = hasMore ? sessions.slice(0, limit) : sessions; + + // If total count wasn't precomputed, keep UI-safe lower bound + if (!includeTotalCount) { + // Lightweight mode: return a lower-bound count to avoid full scans. + totalCount = pageSessions.length + (hasMore ? 1 : 0); + } + + if (pageSessions.length > 0 && hasMore) { + const lastSession = pageSessions[pageSessions.length - 1]; + const lastFileInfo = fileInfos.find((f) => f.sessionId === lastSession.id); + if (lastFileInfo) { + const cursorData: SessionCursor = { + timestamp: lastFileInfo.timestamp, + sessionId: lastFileInfo.sessionId, + }; + nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64'); + } + } + + return { + sessions: pageSessions, + nextCursor, + hasMore: nextCursor !== null, + totalCount, + }; + } catch (error) { + logger.error(`Error listing paginated sessions for project ${projectId}:`, error); + return { sessions: [], nextCursor: null, hasMore: false, totalCount: 0 }; + } + } + + /** + * Build session metadata from a session file. + */ + private async buildSessionMetadata( + projectId: string, + sessionId: string, + filePath: string, + projectPath: string + ): Promise { + const stats = fs.statSync(filePath); + const cachedMetadata = this.sessionMetadataCache.get(filePath); + const metadata = + cachedMetadata?.mtimeMs === stats.mtimeMs + ? cachedMetadata.metadata + : await analyzeSessionFileMetadata(filePath); + if (cachedMetadata?.mtimeMs !== stats.mtimeMs) { + this.sessionMetadataCache.set(filePath, { mtimeMs: stats.mtimeMs, metadata }); + } + + // Check for subagents (delegated to SubagentLocator) + const hasSubagents = this.subagentLocator.hasSubagentsSync(projectId, sessionId); + + // Load task list data if exists + const todoData = await this.loadTodoData(sessionId); + + return { + id: sessionId, + projectId, + projectPath, + todoData, + createdAt: Math.floor(stats.birthtimeMs), + firstMessage: metadata.firstUserMessage?.text, + messageTimestamp: metadata.firstUserMessage?.timestamp, + hasSubagents, + messageCount: metadata.messageCount, + isOngoing: metadata.isOngoing, + gitBranch: metadata.gitBranch ?? undefined, + }; + } + + /** + * Gets a single session's metadata. + */ + async getSession(projectId: string, sessionId: string): Promise { + const filePath = this.getSessionPath(projectId, sessionId); + + if (!fs.existsSync(filePath)) { + return null; + } + + const decodedPath = await this.resolveProjectPathForId(projectId); + return this.buildSessionMetadata(projectId, sessionId, filePath, decodedPath); + } + + // =========================================================================== + // Task List Data + // =========================================================================== + + /** + * Loads task list data for a session from ~/.claude/todos/{sessionId}.json + */ + async loadTodoData(sessionId: string): Promise { + try { + const todoPath = buildTodoPath(path.dirname(this.projectsDir), sessionId); + + if (!fs.existsSync(todoPath)) { + return undefined; + } + + const content = fs.readFileSync(todoPath, 'utf8'); + return JSON.parse(content) as unknown; + } catch (error) { + // Log but continue - task list data is non-critical + logger.debug(`Failed to load task list data for session ${sessionId}:`, error); + return undefined; + } + } + + // =========================================================================== + // Path Helpers + // =========================================================================== + + /** + * Gets the path to the session JSONL file. + */ + getSessionPath(projectId: string, sessionId: string): string { + return buildSessionPath(this.projectsDir, projectId, sessionId); + } + + /** + * Gets the path to the subagents directory. + */ + getSubagentsPath(projectId: string, sessionId: string): string { + return buildSubagentsPath(this.projectsDir, projectId, sessionId); + } + + /** + * Lists all session file paths for a project. + */ + async listSessionFiles(projectId: string): Promise { + try { + const baseDir = extractBaseDir(projectId); + const projectPath = path.join(this.projectsDir, baseDir); + const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + + if (!fs.existsSync(projectPath)) { + return []; + } + + const entries = fs.readdirSync(projectPath, { withFileTypes: true }); + + let files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')); + + if (sessionFilter) { + files = files.filter((entry) => sessionFilter.has(extractSessionId(entry.name))); + } + + return files.map((entry) => path.join(projectPath, entry.name)); + } catch (error) { + logger.error(`Error listing session files for project ${projectId}:`, error); + return []; + } + } + + // =========================================================================== + // Subagent Detection (delegated to SubagentLocator) + // =========================================================================== + + /** + * Checks if a session has a subagents directory (async). + */ + async hasSubagents(projectId: string, sessionId: string): Promise { + return this.subagentLocator.hasSubagents(projectId, sessionId); + } + + /** + * Checks if a session has subagent files (session-specific only). + * Only checks the NEW structure: {projectId}/{sessionId}/subagents/ + * Verifies that at least one subagent file has non-empty content. + */ + hasSubagentsSync(projectId: string, sessionId: string): boolean { + return this.subagentLocator.hasSubagentsSync(projectId, sessionId); + } + + /** + * Lists all subagent files for a session from both NEW and OLD structures. + * Returns NEW structure files first, then OLD structure files. + */ + async listSubagentFiles(projectId: string, sessionId: string): Promise { + return this.subagentLocator.listSubagentFiles(projectId, sessionId); + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Gets the base projects directory path. + */ + getProjectsDir(): string { + return this.projectsDir; + } + + /** + * Gets the base todos directory path. + */ + getTodosDir(): string { + return this.todosDir; + } + + /** + * Checks if the projects directory exists. + */ + projectsDirExists(): boolean { + return fs.existsSync(this.projectsDir); + } + + // =========================================================================== + // Search (delegated to SessionSearcher) + // =========================================================================== + + /** + * Searches sessions in a project for a query string. + * Filters out noise messages and returns matching content. + * + * @param projectId - The project ID to search in + * @param query - Search query string + * @param maxResults - Maximum number of results to return (default 50) + */ + async searchSessions( + projectId: string, + query: string, + maxResults: number = 50 + ): Promise { + return this.sessionSearcher.searchSessions(projectId, query, maxResults); + } + + /** + * Resolves the project path for a given project ID. + * For composite IDs, uses the registry's cwd directly. + * For plain IDs, delegates to ProjectPathResolver. + */ + private async resolveProjectPathForId( + projectId: string, + sessionPaths?: string[] + ): Promise { + const registryCwd = subprojectRegistry.getCwd(projectId); + if (registryCwd) { + return registryCwd; + } + const baseDir = extractBaseDir(projectId); + return this.projectPathResolver.resolveProjectPath(baseDir, { + sessionPaths, + }); + } + + /** + * Checks whether a session file has non-noise displayable content. + * Uses mtime-based memoization to avoid expensive re-parsing on repeated requests. + */ + private async hasDisplayableContent(filePath: string, mtimeMs?: number): Promise { + try { + const effectiveMtime = mtimeMs ?? fs.statSync(filePath).mtimeMs; + const cached = this.contentPresenceCache.get(filePath); + if (cached?.mtimeMs === effectiveMtime) { + return cached.hasContent; + } + + const hasContent = await this.sessionContentFilter.hasNonNoiseMessages(filePath); + this.contentPresenceCache.set(filePath, { mtimeMs: effectiveMtime, hasContent }); + return hasContent; + } catch { + return false; + } + } +} diff --git a/src/main/services/discovery/SessionContentFilter.ts b/src/main/services/discovery/SessionContentFilter.ts new file mode 100644 index 00000000..f146ff20 --- /dev/null +++ b/src/main/services/discovery/SessionContentFilter.ts @@ -0,0 +1,249 @@ +/** + * SessionContentFilter - Filters noise messages from sessions. + * + * Responsibilities: + * - Check if session files contain displayable content + * - Categorize messages as displayable or noise + * - Filter out system-generated and meta messages + * + * A session is displayable if it contains at least one: + * - Real user message (creates UserChunk) + * - System output message (creates SystemChunk) + * - Assistant message (creates AIChunk) + * - Compact boundary message (creates CompactChunk) + * + * Filtered out (hard noise): + * - system entries + * - summary entries + * - file-history-snapshot entries + * - queue-operation entries + * - user messages with ONLY or + * - synthetic assistant messages (model='') + */ + +import { type ChatHistoryEntry, type ContentBlock } from '@main/types'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as readline from 'readline'; + +const logger = createLogger('Service:SessionContentFilter'); + +/** + * Hard noise tags - user messages with ONLY these tags are filtered out. + */ +const HARD_NOISE_TAGS = ['', '']; + +/** + * Hard noise entry types - these types are always filtered out. + */ +const HARD_NOISE_TYPES = ['system', 'summary', 'file-history-snapshot', 'queue-operation']; + +/** + * SessionContentFilter provides static methods for filtering noise messages. + */ +export class SessionContentFilter { + /** + * Checks if a session file contains any displayable conversation items. + * Returns true if the session has at least one message that would create + * a visible chunk (UserChunk, SystemChunk, AIChunk, or CompactChunk). + * + * Uses the same logic as ChunkBuilder to ensure consistency with ChatHistory: + * - Sessions that pass this check will have non-empty conversation.items + * - Sessions that fail will show "No conversation history" in ChatHistory + * + * @param filePath - Path to the session JSONL file + * @returns Promise resolving to true if session has displayable content + */ + static async hasNonNoiseMessages(filePath: string): Promise { + if (!fs.existsSync(filePath)) { + return false; + } + + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const entry = JSON.parse(line) as ChatHistoryEntry; + + // Skip entries without uuid (queue-operation, etc.) + if (!entry.uuid) { + continue; + } + + // Check if this entry would create a displayable chunk + // This aligns with ChunkBuilder.categorizeMessage() logic + if (SessionContentFilter.isDisplayableEntry(entry)) { + fileStream.destroy(); + return true; + } + } catch { + // Skip malformed lines + continue; + } + } + } catch (error) { + logger.error(`Error checking displayable messages in ${filePath}:`, error); + } + + // No displayable messages found + return false; + } + + /** + * Checks if a JSONL entry would create a displayable chunk. + * Mirrors the logic in ChunkBuilder.categorizeMessage() and isParsed*Message() guards. + * + * @param entry - The parsed JSONL entry + * @returns true if the entry would create a displayable chunk + */ + static isDisplayableEntry(entry: ChatHistoryEntry): boolean { + const entryType = entry.type; + + // Hard noise types - never displayed + if (HARD_NOISE_TYPES.includes(entryType)) { + return false; + } + + // Sidechain messages are subagent messages - not part of main conversation + if ('isSidechain' in entry && entry.isSidechain === true) { + return false; + } + + // Assistant messages - displayable (creates AIChunk) + // Filter synthetic messages (model='') + if (entryType === 'assistant') { + const assistantEntry = entry as { message?: { model?: string } }; + return assistantEntry.message?.model !== ''; + } + + // User messages - check for real user input vs noise + if (entryType === 'user') { + return SessionContentFilter.isDisplayableUserEntry(entry); + } + + return false; + } + + /** + * Checks if a user entry is displayable. + * + * @param entry - The user entry to check + * @returns true if the user entry would create a displayable chunk + */ + private static isDisplayableUserEntry(entry: ChatHistoryEntry): boolean { + const userEntry = entry as { + message?: { content?: string | ContentBlock[] }; + isMeta?: boolean; + }; + const content = userEntry.message?.content; + const isMeta = userEntry.isMeta; + + // Internal user messages (tool results) - part of AI response flow + // These ARE displayable as they're part of AIChunks + if (isMeta === true) { + return true; + } + + // String content + if (typeof content === 'string') { + return SessionContentFilter.isDisplayableStringContent(content); + } + + // Array content (newer format) + if (Array.isArray(content)) { + return SessionContentFilter.isDisplayableArrayContent(content); + } + + return false; + } + + /** + * Checks if string content is displayable. + * + * @param content - The string content to check + * @returns true if displayable + */ + private static isDisplayableStringContent(content: string): boolean { + const trimmed = content.trim(); + + // Check for hard noise tags - user messages with ONLY these tags + for (const tag of HARD_NOISE_TAGS) { + const openTag = tag; + const closeTag = tag.replace('<', '') || + trimmed.startsWith('') + ) { + return true; + } + + // Real user input (creates UserChunk) - displayable + if (trimmed.length > 0) { + return true; + } + + return false; + } + + /** + * Checks if array content is displayable. + * + * @param content - The array content to check + * @returns true if displayable + */ + private static isDisplayableArrayContent(content: ContentBlock[]): boolean { + // Check for tool_result blocks (part of AI response flow) + const hasToolResult = content.some((block: ContentBlock) => block.type === 'tool_result'); + if (hasToolResult) { + return true; + } + + // Check for text/image blocks (real user input) + const hasUserContent = content.some( + (block: ContentBlock) => block.type === 'text' || block.type === 'image' + ); + + if (hasUserContent) { + // Filter user interruption messages - but these are still displayable + if ( + content.length === 1 && + content[0].type === 'text' && + typeof content[0].text === 'string' && + content[0].text.startsWith('[Request interrupted by user') + ) { + // Interruptions are part of AI flow, still displayable + return true; + } + + // Check text blocks for hard noise tags + for (const block of content) { + if (block.type === 'text') { + const textBlock = block; + for (const tag of HARD_NOISE_TAGS) { + const closeTag = tag.replace('<', ' { + const results: SearchResult[] = []; + let sessionsSearched = 0; + + if (!query || query.trim().length === 0) { + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + const normalizedQuery = query.toLowerCase().trim(); + + try { + const baseDir = extractBaseDir(projectId); + const projectPath = path.join(this.projectsDir, baseDir); + const sessionFilter = subprojectRegistry.getSessionFilter(projectId); + + try { + await fs.promises.access(projectPath, fs.constants.R_OK); + } catch { + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + + // Get all session files + const entries = await fs.promises.readdir(projectPath, { withFileTypes: true }); + const sessionFilesWithTime = await Promise.all( + entries + .filter((entry) => { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) return false; + // Filter to only sessions belonging to this subproject + if (sessionFilter) { + const sessionId = extractSessionId(entry.name); + return sessionFilter.has(sessionId); + } + return true; + }) + .map(async (entry) => { + const filePath = path.join(projectPath, entry.name); + try { + const stats = await fs.promises.stat(filePath); + return { name: entry.name, filePath, mtimeMs: stats.mtimeMs }; + } catch { + return null; + } + }) + ); + const sessionFiles = sessionFilesWithTime + .filter((entry): entry is { name: string; filePath: string; mtimeMs: number } => !!entry) + .sort((a, b) => b.mtimeMs - a.mtimeMs); + + // Search each session file + for (const file of sessionFiles) { + if (results.length >= maxResults) break; + + const sessionId = extractSessionId(file.name); + const filePath = file.filePath; + sessionsSearched++; + + try { + const sessionResults = await this.searchSessionFile( + projectId, + sessionId, + filePath, + normalizedQuery, + maxResults - results.length + ); + results.push(...sessionResults); + } catch { + // Skip files we can't read + continue; + } + } + + return { + results, + totalMatches: results.length, + sessionsSearched, + query, + }; + } catch (error) { + logger.error(`Error searching sessions for project ${projectId}:`, error); + return { results: [], totalMatches: 0, sessionsSearched: 0, query }; + } + } + + /** + * Searches a single session file for a query string. + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @param filePath - Path to the session file + * @param query - Normalized search query (lowercase) + * @param maxResults - Maximum number of results to return + * @returns Array of search results + */ + async searchSessionFile( + projectId: string, + sessionId: string, + filePath: string, + query: string, + maxResults: number + ): Promise { + const results: SearchResult[] = []; + let sessionTitle: string | undefined; + const messages = await parseJsonlFile(filePath); + const chunks = this.chunkBuilder.buildChunks(messages, []); + + for (const chunk of chunks) { + if (results.length >= maxResults) { + break; + } + + if (isUserChunk(chunk)) { + const userText = this.extractUserSearchableText(chunk.userMessage); + if (!sessionTitle && userText) { + sessionTitle = userText.slice(0, 100); + } + if (!userText) { + continue; + } + const searchableEntry: SearchableEntry = { + text: userText, + groupId: chunk.id, + messageType: 'user', + itemType: 'user', + timestamp: chunk.userMessage.timestamp.getTime(), + messageUuid: chunk.userMessage.uuid, + }; + this.collectMatchesForEntry( + searchableEntry, + query, + results, + maxResults, + projectId, + sessionId, + sessionTitle + ); + continue; + } + + if (isEnhancedAIChunk(chunk)) { + const lastOutputStep = this.findLastOutputTextStep(chunk.semanticSteps); + const outputText = lastOutputStep?.content.outputText; + if (!lastOutputStep || !outputText) { + continue; + } + + const searchableEntry: SearchableEntry = { + text: outputText, + groupId: chunk.id, + messageType: 'assistant', + itemType: 'ai', + timestamp: lastOutputStep.startTime.getTime(), + messageUuid: lastOutputStep.sourceMessageId ?? chunk.responses[0]?.uuid ?? '', + }; + this.collectMatchesForEntry( + searchableEntry, + query, + results, + maxResults, + projectId, + sessionId, + sessionTitle + ); + } + } + + return results; + } + + private collectMatchesForEntry( + entry: SearchableEntry, + query: string, + results: SearchResult[], + maxResults: number, + projectId: string, + sessionId: string, + sessionTitle?: string + ): void { + const mdMatches = findMarkdownSearchMatches(entry.text, query); + if (mdMatches.length === 0) return; + + // Build plain text once for context snippet extraction + const plainText = extractMarkdownPlainText(entry.text); + const lowerPlain = plainText.toLowerCase(); + + for (const mdMatch of mdMatches) { + if (results.length >= maxResults) return; + + // Find approximate position in plain text for context extraction + let pos = 0; + for (let i = 0; i < mdMatch.matchIndexInItem; i++) { + const idx = lowerPlain.indexOf(query, pos); + if (idx === -1) break; + pos = idx + query.length; + } + const matchPos = lowerPlain.indexOf(query, pos); + const effectivePos = matchPos >= 0 ? matchPos : 0; + + const contextStart = Math.max(0, effectivePos - 50); + const contextEnd = Math.min(plainText.length, effectivePos + query.length + 50); + const context = plainText.slice(contextStart, contextEnd); + const matchedText = + matchPos >= 0 ? plainText.slice(matchPos, matchPos + query.length) : query; + + results.push({ + sessionId, + projectId, + sessionTitle: sessionTitle ?? 'Untitled Session', + matchedText, + context: + (contextStart > 0 ? '...' : '') + context + (contextEnd < plainText.length ? '...' : ''), + messageType: entry.messageType, + timestamp: entry.timestamp, + groupId: entry.groupId, + itemType: entry.itemType, + matchIndexInItem: mdMatch.matchIndexInItem, + matchStartOffset: effectivePos, + messageUuid: entry.messageUuid, + }); + } + } + + private extractUserSearchableText(message: ParsedMessage): string { + let rawText = ''; + if (typeof message.content === 'string') { + rawText = message.content; + } else if (Array.isArray(message.content)) { + rawText = message.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(''); + } + return sanitizeDisplayContent(rawText); + } + + private findLastOutputTextStep(steps: SemanticStep[]): SemanticStep | null { + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'output' && step.content.outputText) { + return step; + } + } + return null; + } +} diff --git a/src/main/services/discovery/SubagentLocator.ts b/src/main/services/discovery/SubagentLocator.ts new file mode 100644 index 00000000..94100497 --- /dev/null +++ b/src/main/services/discovery/SubagentLocator.ts @@ -0,0 +1,203 @@ +/** + * SubagentLocator - Locates and manages subagent files. + * + * Responsibilities: + * - Check if sessions have subagent files + * - List subagent files for a session + * - Handle both NEW and OLD subagent directory structures: + * - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl + * - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported) + * - Determine subagent ownership for OLD structure + */ + +import { buildSubagentsPath, extractBaseDir } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('Discovery:SubagentLocator'); + +/** + * SubagentLocator provides methods for locating subagent files. + */ +export class SubagentLocator { + private readonly projectsDir: string; + + constructor(projectsDir: string) { + this.projectsDir = projectsDir; + } + + /** + * Checks if a session has subagent files (async). + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @returns Promise resolving to true if subagents exist + */ + async hasSubagents(projectId: string, sessionId: string): Promise { + return this.hasSubagentsSync(projectId, sessionId); + } + + /** + * Checks if a session has subagent files (session-specific only). + * Only checks the NEW structure: {projectId}/{sessionId}/subagents/ + * Verifies that at least one subagent file has non-empty content. + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @returns true if subagents exist + */ + hasSubagentsSync(projectId: string, sessionId: string): boolean { + // Check NEW structure: {projectId}/{sessionId}/subagents/ + const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + if (fs.existsSync(newSubagentsPath)) { + try { + const entries = fs.readdirSync(newSubagentsPath); + const subagentFiles = entries.filter( + (name) => name.startsWith('agent-') && name.endsWith('.jsonl') + ); + + // Check if at least one subagent file has content (not empty) + for (const fileName of subagentFiles) { + const filePath = path.join(newSubagentsPath, fileName); + try { + const stats = fs.statSync(filePath); + // File must have size > 0 and contain at least one line + if (stats.size > 0) { + const content = fs.readFileSync(filePath, 'utf8'); + if (content.trim().length > 0) { + return true; + } + } + } catch (error) { + // Skip this file if we can't read it - log for debugging + logger.debug(`SubagentLocator: Could not read file ${filePath}:`, error); + continue; + } + } + } catch { + // Ignore errors + } + } + + return false; + } + + /** + * Lists all subagent files for a session from both NEW and OLD structures. + * Returns NEW structure files first, then OLD structure files. + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @returns Promise resolving to array of file paths + */ + async listSubagentFiles(projectId: string, sessionId: string): Promise { + const allFiles: string[] = []; + + try { + // Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl + const newSubagentsPath = this.getSubagentsPath(projectId, sessionId); + if (fs.existsSync(newSubagentsPath)) { + const entries = fs.readdirSync(newSubagentsPath, { withFileTypes: true }); + const newFiles = entries + .filter( + (entry) => + entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl') + ) + .map((entry) => path.join(newSubagentsPath, entry.name)); + allFiles.push(...newFiles); + } + } catch (error) { + logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error); + } + + try { + // Scan OLD structure: {projectId}/agent-*.jsonl + // Must filter by sessionId since all sessions share the same project root + const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId); + allFiles.push(...oldFiles); + } catch (error) { + logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error); + } + + return allFiles; + } + + /** + * Gets subagent files from project root (OLD structure). + * Scans {projectId}/agent-*.jsonl files and filters by sessionId. + * + * In the OLD structure, all subagent files are in the project root, + * so we must read each file's first line to check if it belongs to the session. + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @returns Promise resolving to array of file paths + */ + async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise { + try { + const projectPath = path.join(this.projectsDir, extractBaseDir(projectId)); + + if (!fs.existsSync(projectPath)) { + return []; + } + + const files = fs.readdirSync(projectPath); + const agentFiles = files + .filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl')) + .map((f) => path.join(projectPath, f)); + + // Filter files by checking if their sessionId matches + const matchingFiles: string[] = []; + for (const filePath of agentFiles) { + if (await this.subagentBelongsToSession(filePath, sessionId)) { + matchingFiles.push(filePath); + } + } + + return matchingFiles; + } catch (error) { + logger.error(`Error reading project root for subagent files:`, error); + return []; + } + } + + /** + * Checks if a subagent file belongs to a specific session by reading its first line. + * Subagent files have a sessionId field that points to the parent session. + * + * @param filePath - Path to the subagent file + * @param sessionId - The session ID to check + * @returns Promise resolving to true if the subagent belongs to the session + */ + async subagentBelongsToSession(filePath: string, sessionId: string): Promise { + try { + // Read just the first line to check sessionId + const content = fs.readFileSync(filePath, 'utf-8'); + const firstNewline = content.indexOf('\n'); + const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content; + + if (!firstLine.trim()) { + return false; + } + + const entry = JSON.parse(firstLine) as { sessionId?: string }; + return entry.sessionId === sessionId; + } catch (error) { + // If we can't read or parse the file, don't include it - log for debugging + logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error); + return false; + } + } + + /** + * Gets the path to the subagents directory. + * + * @param projectId - The project ID + * @param sessionId - The session ID + * @returns Path to the subagents directory + */ + getSubagentsPath(projectId: string, sessionId: string): string { + return buildSubagentsPath(this.projectsDir, projectId, sessionId); + } +} diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts new file mode 100644 index 00000000..66b11391 --- /dev/null +++ b/src/main/services/discovery/SubagentResolver.ts @@ -0,0 +1,547 @@ +/** + * SubagentResolver service - Links Task calls to subagent files and detects parallelism. + * + * Responsibilities: + * - Find subagent JSONL files in {sessionId}/subagents/ directory + * - Parse each subagent file + * - Calculate start/end times and metrics + * - Detect parallel execution (100ms overlap threshold) + * - Link subagents to parent Task tool calls + */ + +import { type ParsedMessage, type Process, type SessionMetrics, type ToolCall } from '@main/types'; +import { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl'; +import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; + +import { type ProjectScanner } from './ProjectScanner'; + +const logger = createLogger('Discovery:SubagentResolver'); + +/** Parallel detection window in milliseconds */ +const PARALLEL_WINDOW_MS = 100; + +export class SubagentResolver { + private projectScanner: ProjectScanner; + + constructor(projectScanner: ProjectScanner) { + this.projectScanner = projectScanner; + } + + // =========================================================================== + // Main Resolution + // =========================================================================== + + /** + * Resolve all subagents for a session. + */ + async resolveSubagents( + projectId: string, + sessionId: string, + taskCalls: ToolCall[], + messages?: ParsedMessage[] + ): Promise { + // Get subagent files + const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId); + + if (subagentFiles.length === 0) { + return []; + } + + // Parse all subagent files + const subagents = await Promise.all( + subagentFiles.map((filePath) => this.parseSubagentFile(filePath)) + ); + + // Filter out failed parses + const validSubagents = subagents.filter((s): s is Process => s !== null); + + // Link to Task calls using tool result data from parent session messages + this.linkToTaskCalls(validSubagents, taskCalls, messages ?? []); + + // Propagate team metadata to continuation files via parentUuid chain + this.propagateTeamMetadata(validSubagents); + + // Detect parallel execution + this.detectParallelExecution(validSubagents); + + // Enrich team metadata colors from messages + if (messages) { + this.enrichTeamColors(validSubagents, messages); + } + + // Sort by start time + validSubagents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + return validSubagents; + } + + // =========================================================================== + // Subagent Parsing + // =========================================================================== + + /** + * Parse a single subagent file. + */ + private async parseSubagentFile(filePath: string): Promise { + try { + const messages = await parseJsonlFile(filePath); + + if (messages.length === 0) { + return null; + } + + // Filter out warmup subagents - these are pre-warming agents spawned by Claude Code + // that have "Warmup" as the first user message and should not be displayed + if (this.isWarmupSubagent(messages)) { + return null; + } + + // Extract agent ID from filename (agent-{id}.jsonl) + const filename = path.basename(filePath); + const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, ''); + + // Filter out compact files (context compaction artifacts, not real subagents) + if (agentId.startsWith('acompact')) { + return null; + } + + // Calculate timing + const { startTime, endTime, durationMs } = this.calculateTiming(messages); + + // Calculate metrics + const metrics = calculateMetrics(messages); + + // Check if subagent is still in progress + const isOngoing = checkMessagesOngoing(messages); + + return { + id: agentId, + filePath, + messages, + startTime, + endTime, + durationMs, + metrics, + isParallel: false, // Will be set by detectParallelExecution + isOngoing, + }; + } catch (error) { + logger.error(`Error parsing subagent file ${filePath}:`, error); + return null; + } + } + + /** + * Check if this is a warmup subagent that should be filtered out. + * Warmup subagents are pre-warming agents spawned by Claude Code that have: + * - First user message with content exactly "Warmup" + * - isSidechain: true (all subagents have this) + */ + private isWarmupSubagent(messages: ParsedMessage[]): boolean { + // Find the first user message + const firstUserMessage = messages.find((m) => m.type === 'user'); + if (!firstUserMessage) { + return false; + } + + // Check if content is exactly "Warmup" (string, not array) + return firstUserMessage.content === 'Warmup'; + } + + /** + * Extract the summary attribute from the first tag in a subagent's messages. + * Returns the summary string if found, undefined otherwise. + * Used to match team member files to their spawning Task calls. + */ + private extractTeamMessageSummary(messages: ParsedMessage[]): string | undefined { + const firstUserMessage = messages.find((m) => m.type === 'user'); + if (!firstUserMessage) return undefined; + + const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; + const match = /]*\bsummary="([^"]+)"/.exec(text); + return match?.[1]; + } + + /** + * Calculate timing from messages. + */ + private calculateTiming(messages: ParsedMessage[]): { + startTime: Date; + endTime: Date; + durationMs: number; + } { + const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); + + if (timestamps.length === 0) { + const now = new Date(); + return { startTime: now, endTime: now, durationMs: 0 }; + } + + const minTime = Math.min(...timestamps); + const maxTime = Math.max(...timestamps); + + return { + startTime: new Date(minTime), + endTime: new Date(maxTime), + durationMs: maxTime - minTime, + }; + } + + // =========================================================================== + // Task Call Linking + // =========================================================================== + + /** + * Link subagents to their parent Task tool calls. + * + * Uses result-based matching: reads tool_result messages from the parent session + * to find agentId values, then matches subagent files by their ID. Falls back to + * positional matching (without wrap-around) for any remaining unmatched subagents. + * + * After matching, enriches subagents with Task call metadata (description, subagentType). + */ + private linkToTaskCalls( + subagents: Process[], + taskCalls: ToolCall[], + messages: ParsedMessage[] + ): void { + // Filter to only Task calls + const taskCallsOnly = taskCalls.filter((tc) => tc.isTask); + + if (taskCallsOnly.length === 0 || subagents.length === 0) { + return; + } + + // Build a map: agentId → taskCallId from tool result messages + // Tool results for Task calls contain an agentId field linking to the subagent file + const agentIdToTaskId = new Map(); + for (const msg of messages) { + if (!msg.toolUseResult) continue; + const result = msg.toolUseResult; + // Check both camelCase (regular subagents) and snake_case (team spawns) field names + const agentId = (result.agentId ?? result.agent_id) as string | undefined; + if (!agentId) continue; + + // Find the Task call ID from sourceToolUseID or toolResults[0].toolUseId + const taskCallId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId; + if (taskCallId) { + agentIdToTaskId.set(agentId, taskCallId); + } + } + + // Build a lookup from task call ID → ToolCall for enrichment + const taskCallById = new Map(taskCallsOnly.map((tc) => [tc.id, tc])); + + // Track which subagents and tasks got matched + const matchedSubagentIds = new Set(); + const matchedTaskIds = new Set(); + + // Phase 1: Result-based matching (agentId from tool results) + // Works for regular subagents (Explore, etc.) where agentId = file UUID + for (const subagent of subagents) { + const taskCallId = agentIdToTaskId.get(subagent.id); + if (!taskCallId) continue; + + const taskCall = taskCallById.get(taskCallId); + if (!taskCall) continue; + + this.enrichSubagentFromTask(subagent, taskCall); + matchedSubagentIds.add(subagent.id); + matchedTaskIds.add(taskCallId); + } + + // Phase 2: Description-based matching for team members + // Team spawns use agent_id = "name@team_name" (not a file UUID), so Phase 1 can't match them. + // Instead, match by comparing the Task description to the summary attribute in the + // subagent file's first tag. + const teamTaskCalls = taskCallsOnly.filter( + (tc) => !matchedTaskIds.has(tc.id) && tc.input?.team_name && tc.input?.name + ); + + if (teamTaskCalls.length > 0) { + // Pre-extract summaries from unmatched subagent files + const subagentSummaries = new Map(); + for (const subagent of subagents) { + if (matchedSubagentIds.has(subagent.id)) continue; + const summary = this.extractTeamMessageSummary(subagent.messages); + if (summary) { + subagentSummaries.set(subagent.id, summary); + } + } + + // Match each team Task call to the earliest subagent file with matching summary + for (const taskCall of teamTaskCalls) { + const description = taskCall.taskDescription; + if (!description) continue; + + let bestMatch: Process | undefined; + for (const subagent of subagents) { + if (matchedSubagentIds.has(subagent.id)) continue; + if (subagentSummaries.get(subagent.id) !== description) continue; + if (!bestMatch || subagent.startTime < bestMatch.startTime) { + bestMatch = subagent; + } + } + + if (bestMatch) { + this.enrichSubagentFromTask(bestMatch, taskCall); + matchedSubagentIds.add(bestMatch.id); + matchedTaskIds.add(taskCall.id); + } + } + } + + // Phase 3: Positional fallback for remaining unmatched non-team subagents (no wrap-around) + const unmatchedSubagents = [...subagents] + .filter((s) => !matchedSubagentIds.has(s.id)) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + const unmatchedTasks = taskCallsOnly.filter( + (tc) => !matchedTaskIds.has(tc.id) && !(tc.input?.team_name && tc.input?.name) + ); + + for (let i = 0; i < unmatchedSubagents.length && i < unmatchedTasks.length; i++) { + this.enrichSubagentFromTask(unmatchedSubagents[i], unmatchedTasks[i]); + } + } + + /** + * Enrich a subagent with metadata from its parent Task call. + * Intentionally mutates the subagent in place for consistency with other resolution methods. + */ + private enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void { + /* eslint-disable no-param-reassign -- Mutation is intentional; subagent is enriched in place */ + subagent.parentTaskId = taskCall.id; + subagent.description = taskCall.taskDescription; + subagent.subagentType = taskCall.taskSubagentType; + + // Extract team metadata from Task call input + const teamName = taskCall.input?.team_name as string | undefined; + const memberName = taskCall.input?.name as string | undefined; + if (teamName && memberName) { + subagent.team = { teamName, memberName, memberColor: '' }; + } + /* eslint-enable no-param-reassign -- End of intentional mutation block */ + } + + /** + * Enrich team member subagents with color information from tool results. + * Teammate spawned results contain color information. + */ + private enrichTeamColors(subagents: Process[], messages: ParsedMessage[]): void { + for (const msg of messages) { + if (!msg.toolUseResult) continue; + // sourceToolUseID may be absent on teammate_spawned results; + // fall back to toolResults[0].toolUseId + const sourceId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId; + if (!sourceId) continue; + const result = msg.toolUseResult; + if (result.status === 'teammate_spawned' && result.color) { + // Set color on ALL subagents sharing this parentTaskId + // (primary file + continuation files from parentUuid chain propagation) + for (const subagent of subagents) { + if (subagent.parentTaskId === sourceId && subagent.team) { + subagent.team.memberColor = result.color as string; + } + } + } + } + } + + /** + * Propagate team metadata to continuation files via parentUuid chain. + * + * Team members generate multiple JSONL files (one per activation/turn). + * Only the primary file is matched by linkToTaskCalls (Phase 2 description match). + * Continuation files (task assignments, shutdown responses) are linked to the + * same teammate by following the parentUuid chain: a continuation file's first + * message.parentUuid matches the last message.uuid of the previous file. + */ + private propagateTeamMetadata(subagents: Process[]): void { + // Build map: last message uuid → subagent (for chain lookups) + const lastUuidToSubagent = new Map(); + for (const subagent of subagents) { + if (subagent.messages.length === 0) continue; + const lastMsg = subagent.messages[subagent.messages.length - 1]; + if (lastMsg.uuid) { + lastUuidToSubagent.set(lastMsg.uuid, subagent); + } + } + + // For each subagent without team metadata, follow parentUuid chain + // to find an ancestor with team metadata and propagate it + const maxDepth = 10; + for (const subagent of subagents) { + if (subagent.team) continue; // Already has team metadata + if (subagent.messages.length === 0) continue; + + const firstMsg = subagent.messages[0]; + if (!firstMsg.parentUuid) continue; + + // Walk the chain upward + let ancestor: Process | undefined = lastUuidToSubagent.get(firstMsg.parentUuid); + let depth = 0; + + while (ancestor && !ancestor.team && depth < maxDepth) { + if (ancestor.messages.length === 0) break; + const parentUuid = ancestor.messages[0].parentUuid; + if (!parentUuid) break; + ancestor = lastUuidToSubagent.get(parentUuid); + depth++; + } + + if (ancestor?.team) { + subagent.team = { ...ancestor.team }; + subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId; + subagent.description = subagent.description ?? ancestor.description; + subagent.subagentType = subagent.subagentType ?? ancestor.subagentType; + } + } + } + + // =========================================================================== + // Parallel Detection + // =========================================================================== + + /** + * Detect parallel execution among subagents. + * Subagents with start times within PARALLEL_WINDOW_MS are marked as parallel. + */ + private detectParallelExecution(subagents: Process[]): void { + if (subagents.length < 2) return; + + // Sort by start time + const sorted = [...subagents].sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + // Group by start time buckets + const groups: Process[][] = []; + let currentGroup: Process[] = []; + let groupStartTime = 0; + + for (const agent of sorted) { + const startMs = agent.startTime.getTime(); + + if (currentGroup.length === 0) { + // Start new group + currentGroup.push(agent); + groupStartTime = startMs; + } else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) { + // Add to current group + currentGroup.push(agent); + } else { + // Finalize current group and start new one + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + currentGroup = [agent]; + groupStartTime = startMs; + } + } + + // Don't forget the last group + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + // Mark agents in groups with multiple members as parallel + for (const group of groups) { + if (group.length > 1) { + for (const agent of group) { + agent.isParallel = true; + } + } + } + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Get subagent by ID. + */ + findSubagentById(subagents: Process[], id: string): Process | undefined { + return subagents.find((s) => s.id === id); + } + + /** + * Get parallel subagent groups. + */ + getParallelGroups(subagents: Process[]): Process[][] { + const parallelAgents = subagents.filter((s) => s.isParallel); + if (parallelAgents.length === 0) return []; + + // Group by start time + const sorted = [...parallelAgents].sort( + (a, b) => a.startTime.getTime() - b.startTime.getTime() + ); + + const groups: Process[][] = []; + let currentGroup: Process[] = []; + let groupStartTime = 0; + + for (const agent of sorted) { + const startMs = agent.startTime.getTime(); + + if (currentGroup.length === 0) { + currentGroup.push(agent); + groupStartTime = startMs; + } else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) { + currentGroup.push(agent); + } else { + groups.push(currentGroup); + currentGroup = [agent]; + groupStartTime = startMs; + } + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups.filter((g) => g.length > 1); + } + + /** + * Calculate total metrics for all subagents. + */ + getTotalSubagentMetrics(subagents: Process[]): SessionMetrics { + if (subagents.length === 0) { + return { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 0, + }; + } + + let totalDuration = 0; + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + let messageCount = 0; + + for (const agent of subagents) { + totalDuration += agent.durationMs; + inputTokens += agent.metrics.inputTokens; + outputTokens += agent.metrics.outputTokens; + cacheReadTokens += agent.metrics.cacheReadTokens; + cacheCreationTokens += agent.metrics.cacheCreationTokens; + messageCount += agent.metrics.messageCount; + } + + return { + durationMs: totalDuration, + totalTokens: inputTokens + outputTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + messageCount, + }; + } +} diff --git a/src/main/services/discovery/SubprojectRegistry.ts b/src/main/services/discovery/SubprojectRegistry.ts new file mode 100644 index 00000000..823996d7 --- /dev/null +++ b/src/main/services/discovery/SubprojectRegistry.ts @@ -0,0 +1,98 @@ +/** + * SubprojectRegistry - Maps composite project IDs to their split data. + * + * When multiple sessions in the same encoded directory have different `cwd` values, + * they are split into separate "subprojects". Each subproject gets a composite ID + * of the form `{encodedDir}::{sha256(cwd).slice(0,8)}`. + * + * This singleton registry tracks: + * - Which base directory a composite ID maps to + * - Which cwd each subproject represents + * - Which session IDs belong to each subproject + */ + +import * as crypto from 'crypto'; + +interface SubprojectEntry { + baseDir: string; + cwd: string; + sessionIds: Set; +} + +class SubprojectRegistryImpl { + private readonly entries = new Map(); + + /** + * Register a subproject and return its composite ID. + * + * @param baseDir - The encoded directory name (e.g., "-Users-name-project") + * @param cwd - The actual working directory for this subproject + * @param sessionIds - Session IDs belonging to this subproject + * @returns Composite ID in the form `{baseDir}::{hash}` + */ + register(baseDir: string, cwd: string, sessionIds: string[]): string { + const hash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 8); + const compositeId = `${baseDir}::${hash}`; + this.entries.set(compositeId, { + baseDir, + cwd, + sessionIds: new Set(sessionIds), + }); + return compositeId; + } + + /** + * Extract the base directory from any project ID (composite or plain). + * For composite IDs (`{encoded}::{hash}`), returns the encoded part. + * For plain IDs, returns the ID as-is. + */ + getBaseDir(projectId: string): string { + const sep = projectId.indexOf('::'); + if (sep !== -1) { + return projectId.slice(0, sep); + } + return projectId; + } + + /** + * Check if a project ID is a composite (split) ID. + */ + isComposite(projectId: string): boolean { + return projectId.includes('::'); + } + + /** + * Get the session ID filter set for a composite project ID. + * Returns null for plain (non-composite) IDs. + */ + getSessionFilter(projectId: string): Set | null { + const entry = this.entries.get(projectId); + return entry?.sessionIds ?? null; + } + + /** + * Get the cwd for a composite project ID. + * Returns null for plain (non-composite) IDs. + */ + getCwd(projectId: string): string | null { + const entry = this.entries.get(projectId); + return entry?.cwd ?? null; + } + + /** + * Get the full entry for a composite project ID. + */ + getEntry(projectId: string): SubprojectEntry | undefined { + return this.entries.get(projectId); + } + + /** + * Clear all registered subprojects. Called at the start of a full re-scan. + */ + clear(): void { + this.entries.clear(); + } +} + +/** Module-level singleton */ +export const subprojectRegistry = new SubprojectRegistryImpl(); diff --git a/src/main/services/discovery/WorktreeGrouper.ts b/src/main/services/discovery/WorktreeGrouper.ts new file mode 100644 index 00000000..95bd0840 --- /dev/null +++ b/src/main/services/discovery/WorktreeGrouper.ts @@ -0,0 +1,200 @@ +/** + * WorktreeGrouper - Groups projects by git repository. + * + * Responsibilities: + * - Group projects that belong to the same git repository + * - Handle worktrees (main repo + worktrees grouped together) + * - Filter out empty worktrees (no visible sessions) + * - Sort worktrees by main first, then by most recent activity + */ + +import { + type Project, + type RepositoryGroup, + type RepositoryIdentity, + type Worktree, +} from '@main/types'; +import { extractBaseDir } from '@main/utils/pathDecoder'; +import * as path from 'path'; + +import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; + +import { SessionContentFilter } from './SessionContentFilter'; +import { subprojectRegistry } from './SubprojectRegistry'; + +/** + * WorktreeGrouper provides methods for grouping projects by git repository. + */ +export class WorktreeGrouper { + private readonly projectsDir: string; + + constructor(projectsDir: string) { + this.projectsDir = projectsDir; + } + + /** + * Groups projects by git repository. + * Projects belonging to the same git repository (main repo + worktrees) + * are grouped together under a single RepositoryGroup. + * Non-git projects are represented as single-worktree groups. + * + * Sessions are filtered to exclude noise-only sessions, so counts + * accurately reflect visible sessions in the UI. + * + * @param projects - List of projects to group + * @returns Promise resolving to RepositoryGroups sorted by most recent activity + */ + async groupByRepository(projects: Project[]): Promise { + if (projects.length === 0) { + return []; + } + + // 1. Resolve repository identity for each project + const projectIdentities = new Map(); + const projectBranches = new Map(); + + await Promise.all( + projects.map(async (project) => { + const identity = await gitIdentityResolver.resolveIdentity(project.path); + projectIdentities.set(project.id, identity); + + // Also get branch name for display + const branch = await gitIdentityResolver.getBranch(project.path); + projectBranches.set(project.id, branch); + }) + ); + + // 2. Filter sessions for each project to only include non-noise sessions + const projectFilteredSessions = new Map(); + await Promise.all( + projects.map(async (project) => { + const baseDir = extractBaseDir(project.id); + const projectPath = path.join(this.projectsDir, baseDir); + const sessionFilter = subprojectRegistry.getSessionFilter(project.id); + const filteredSessions: string[] = []; + + for (const sessionId of project.sessions) { + // Skip sessions that don't belong to this subproject + if (sessionFilter && !sessionFilter.has(sessionId)) { + continue; + } + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + if (await SessionContentFilter.hasNonNoiseMessages(sessionPath)) { + filteredSessions.push(sessionId); + } + } + + projectFilteredSessions.set(project.id, filteredSessions); + }) + ); + + // 3. Group projects by repository + const repoGroups = new Map< + string, + { + identity: RepositoryIdentity | null; + projects: Project[]; + branches: Map; + } + >(); + + for (const project of projects) { + const identity = projectIdentities.get(project.id) ?? null; + const branch = projectBranches.get(project.id) ?? null; + + // Use repository ID if available, otherwise use project ID (for non-git projects) + const groupId = identity?.id ?? project.id; + + if (!repoGroups.has(groupId)) { + repoGroups.set(groupId, { + identity, + projects: [], + branches: new Map(), + }); + } + + const group = repoGroups.get(groupId)!; + group.projects.push(project); + group.branches.set(project.id, branch); + } + + // 4. Convert to RepositoryGroup[] + const repositoryGroups: RepositoryGroup[] = []; + + for (const [groupId, group] of repoGroups) { + const worktrees: Worktree[] = group.projects.map((project) => { + const branch = group.branches.get(project.id) ?? null; + const isMainWorktree = !gitIdentityResolver.isWorktree(project.path); + // Use filtered sessions instead of raw sessions + const filteredSessions = projectFilteredSessions.get(project.id) ?? []; + // Detect worktree source for badge display + const source = gitIdentityResolver.detectWorktreeSource(project.path); + // Use source-aware display name generation + const displayName = gitIdentityResolver.getWorktreeDisplayName( + project.path, + source, + branch, + isMainWorktree + ); + + return { + id: project.id, + path: project.path, + name: displayName, + gitBranch: branch ?? undefined, + isMainWorktree, + source, + sessions: filteredSessions, + createdAt: project.createdAt, + mostRecentSession: project.mostRecentSession, + }; + }); + + // Filter out worktrees with 0 visible sessions + const nonEmptyWorktrees = worktrees.filter((wt) => wt.sessions.length > 0); + + // Skip this repository group if all worktrees are empty + if (nonEmptyWorktrees.length === 0) { + continue; + } + + // Sort worktrees: main first, then by most recent activity + nonEmptyWorktrees.sort((a, b) => { + if (a.isMainWorktree && !b.isMainWorktree) return -1; + if (!a.isMainWorktree && b.isMainWorktree) return 1; + return (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0); + }); + + const totalSessions = nonEmptyWorktrees.reduce((sum, wt) => sum + wt.sessions.length, 0); + const mostRecentSession = Math.max( + ...nonEmptyWorktrees.map((wt) => wt.mostRecentSession ?? 0) + ); + + repositoryGroups.push({ + id: groupId, + identity: group.identity, + worktrees: nonEmptyWorktrees, + name: group.identity?.name ?? group.projects[0].name, + mostRecentSession: mostRecentSession > 0 ? mostRecentSession : undefined, + totalSessions, + }); + } + + // 5. Sort repository groups by most recent activity + repositoryGroups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); + + return repositoryGroups; + } + + /** + * Lists sessions for a specific worktree. + * This is a convenience method that returns the worktree ID. + * + * @param worktreeId - The worktree ID (same as project ID) + * @returns The worktree ID for delegation to listSessions + */ + getWorktreeProjectId(worktreeId: string): string { + // Worktree ID is the same as project ID + return worktreeId; + } +} diff --git a/src/main/services/discovery/index.ts b/src/main/services/discovery/index.ts new file mode 100644 index 00000000..6c7b1897 --- /dev/null +++ b/src/main/services/discovery/index.ts @@ -0,0 +1,20 @@ +/** + * Discovery services - Scanning and locating session data. + * + * Exports: + * - ProjectScanner: Scans ~/.claude/projects/ for projects and sessions + * - SessionSearcher: Searches session content + * - SessionContentFilter: Filters session content for display + * - SubagentLocator: Locates subagent JSONL files + * - SubagentResolver: Resolves and links subagents to Task calls + * - WorktreeGrouper: Groups projects by git worktree + */ + +export * from './ProjectPathResolver'; +export * from './ProjectScanner'; +export * from './SessionContentFilter'; +export * from './SessionSearcher'; +export * from './SubagentLocator'; +export * from './SubagentResolver'; +export * from './SubprojectRegistry'; +export * from './WorktreeGrouper'; diff --git a/src/main/services/error/ErrorDetector.ts b/src/main/services/error/ErrorDetector.ts new file mode 100644 index 00000000..4bf67e24 --- /dev/null +++ b/src/main/services/error/ErrorDetector.ts @@ -0,0 +1,222 @@ +/** + * ErrorDetector service - Detects errors from parsed JSONL messages. + * + * This is the main orchestrator that coordinates between specialized modules: + * - ToolSummaryFormatter: Formats tool information for display + * - TriggerMatcher: Pattern matching utilities + * - ToolResultExtractor: Extracts tool results from messages + * - ErrorMessageBuilder: Builds error messages and DetectedError objects + * - ErrorTriggerChecker: Checks different trigger types + * - ErrorTriggerTester: Testing functionality for trigger preview + * + * Detection criteria: + * - Uses configurable triggers from ConfigManager + * - Supports tool_result triggers with requireError, toolName, and matchPattern + * - Supports tool_use triggers for future expansion + * - Supports token_threshold triggers for monitoring context usage + */ + +import { type ParsedMessage } from '@main/types'; + +import { + buildToolResultMap, + buildToolUseMap, + type ToolResultInfo, + type ToolUseInfo, +} from '../analysis/ToolResultExtractor'; +import { ConfigManager, type NotificationTrigger } from '../infrastructure/ConfigManager'; + +import { type DetectedError } from './ErrorMessageBuilder'; +import { + checkTokenThresholdTrigger, + checkToolResultTrigger, + checkToolUseTrigger, + matchesRepositoryScope, + preResolveRepositoryIds, +} from './ErrorTriggerChecker'; +import { testTrigger as testTriggerImpl } from './ErrorTriggerTester'; + +// ============================================================================= +// Error Detector Class +// ============================================================================= + +class ErrorDetector { + // =========================================================================== + // Main Detection Method + // =========================================================================== + + /** + * Detects errors from an array of parsed messages using configurable triggers. + * + * @param messages - Array of ParsedMessage objects from a session + * @param sessionId - The session ID + * @param projectId - The project ID (encoded directory name) + * @param filePath - Path to the JSONL file + * @returns Array of DetectedError objects + */ + async detectErrors( + messages: ParsedMessage[], + sessionId: string, + projectId: string, + filePath: string + ): Promise { + const errors: DetectedError[] = []; + + // Get enabled triggers from config + const configManager = ConfigManager.getInstance(); + const triggers = configManager.getEnabledTriggers(); + + if (triggers.length === 0) { + return errors; + } + + // Pre-resolve repository ID for this project to populate cache. + const cwdHint = + messages.find((message) => typeof message.cwd === 'string' && message.cwd.trim().length > 0) + ?.cwd ?? undefined; + await preResolveRepositoryIds([{ projectId, cwdHint }]); + + // Build tool_use map for linking results to calls + const toolUseMap = buildToolUseMap(messages); + // Build tool_result map for estimating output tokens + const toolResultMap = buildToolResultMap(messages); + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const lineNumber = i + 1; // 1-based line numbers for JSONL + + // Check each trigger against this message + for (const trigger of triggers) { + const triggerErrors = this.checkTrigger( + message, + trigger, + toolUseMap, + toolResultMap, + sessionId, + projectId, + filePath, + lineNumber + ); + + // Add all detected errors (can be multiple for token_threshold mode) + errors.push(...triggerErrors); + } + } + + return errors; + } + + // =========================================================================== + // Trigger Checking (Router) + // =========================================================================== + + /** + * Checks if a message matches a specific trigger. + * Routes to the appropriate trigger checker based on trigger configuration. + * + * @param message - The parsed message to check + * @param trigger - The trigger configuration + * @param toolUseMap - Map of tool_use_id to tool_use content for linking results to calls + * @param toolResultMap - Map of tool_use_id to tool_result content for token estimation + * @param sessionId - Session ID + * @param projectId - Project ID + * @param filePath - File path + * @param lineNumber - Line number in JSONL + * @returns Array of DetectedError (can be multiple for token_threshold mode) + */ + private checkTrigger( + message: ParsedMessage, + trigger: NotificationTrigger, + toolUseMap: Map, + toolResultMap: Map, + sessionId: string, + projectId: string, + filePath: string, + lineNumber: number + ): DetectedError[] { + // Check repository scope first - if repositoryIds is set, only trigger for matching repositories + if (!matchesRepositoryScope(projectId, trigger.repositoryIds)) { + return []; + } + + // Use the mode directly (mode is now required in NotificationTrigger) + const effectiveMode = trigger.mode; + + // Handle token_threshold mode - check each tool_use individually + if (effectiveMode === 'token_threshold') { + return checkTokenThresholdTrigger( + message, + trigger, + toolResultMap, + sessionId, + projectId, + filePath, + lineNumber + ); + } + + // Handle tool_result triggers + if (trigger.contentType === 'tool_result') { + const error = checkToolResultTrigger( + message, + trigger, + toolUseMap, + sessionId, + projectId, + filePath, + lineNumber + ); + return error ? [error] : []; + } + + // Handle tool_use triggers (for future expansion) + if (trigger.contentType === 'tool_use') { + const error = checkToolUseTrigger( + message, + trigger, + sessionId, + projectId, + filePath, + lineNumber + ); + return error ? [error] : []; + } + + return []; + } + + // =========================================================================== + // Trigger Testing (Preview Feature) + // =========================================================================== + + /** + * Tests a trigger configuration against historical session data. + * Returns a list of errors that would have been detected. + * + * Safety features (handled by ErrorTriggerTester): + * - Limits returned errors to 50 + * - Caps totalCount at 10,000 to prevent indefinite counting + * - Stops scanning after 100 sessions + * - Aborts after 30 seconds + * + * @param trigger - The trigger configuration to test + * @param limit - Maximum number of results to return (default 50) + */ + public async testTrigger( + trigger: NotificationTrigger, + limit: number = 50 + ): Promise<{ + totalCount: number; + errors: DetectedError[]; + /** True if results were truncated due to safety limits */ + truncated?: boolean; + }> { + return testTriggerImpl(trigger, limit); + } +} + +// ============================================================================= +// Singleton Export +// ============================================================================= + +export const errorDetector = new ErrorDetector(); diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts new file mode 100644 index 00000000..6f3ad900 --- /dev/null +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -0,0 +1,185 @@ +/** + * ErrorMessageBuilder service - Builds error messages and DetectedError objects. + * + * Provides utilities for: + * - Extracting error messages from tool results + * - Finding tool names by ID + * - Creating DetectedError objects + * - Truncating messages for display + */ + +import { type ContentBlock, type ParsedMessage } from '@main/types'; +import { randomUUID } from 'crypto'; + +import { type ExtractedToolResult } from '../analysis/ToolResultExtractor'; + +import type { TriggerColor } from '@shared/constants/triggerColors'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * Represents a detected error from a Claude Code session. + */ +export interface DetectedError { + /** UUID for unique identification */ + id: string; + /** Unix timestamp when error was detected */ + timestamp: number; + /** Session ID where error occurred */ + sessionId: string; + /** Project ID (encoded directory name) */ + projectId: string; + /** Path to the JSONL file */ + filePath: string; + /** Source of the error - tool name or 'assistant' */ + source: string; + /** Error message content */ + message: string; + /** Line number in JSONL for deep linking */ + lineNumber?: number; + /** Tool use ID for precise deep linking to the specific tool item */ + toolUseId?: string; + /** Subagent ID when error originates from a subagent session */ + subagentId?: string; + /** Trigger color key for notification dot and highlight */ + triggerColor?: TriggerColor; + /** ID of the trigger that produced this notification */ + triggerId?: string; + /** Human-readable name of the trigger that produced this notification */ + triggerName?: string; + /** Additional context about the error */ + context: { + /** Human-readable project name */ + projectName: string; + /** Current working directory when error occurred */ + cwd?: string; + }; +} + +/** + * Parameters for creating a DetectedError. + */ +export interface CreateDetectedErrorParams { + sessionId: string; + projectId: string; + filePath: string; + projectName: string; + lineNumber: number; + source: string; + message: string; + timestamp: Date; + cwd?: string; + toolUseId?: string; + subagentId?: string; + triggerColor?: TriggerColor; + triggerId?: string; + triggerName?: string; +} + +// ============================================================================= +// Error Message Extraction +// ============================================================================= + +/** + * Extracts error message from a tool result. + */ +export function extractErrorMessage(result: ExtractedToolResult): string { + if (typeof result.content === 'string') { + return result.content.trim() || 'Unknown error'; + } + + if (Array.isArray(result.content)) { + const texts: string[] = []; + for (const item of result.content) { + if (item && typeof item === 'object' && 'type' in item) { + const block = item as ContentBlock; + if (block.type === 'text' && 'text' in block) { + texts.push(block.text); + } + } + } + return texts.join('\n').trim() || 'Unknown error'; + } + + return 'Unknown error'; +} + +// ============================================================================= +// Tool Name Lookup +// ============================================================================= + +/** + * Finds tool name from message's tool calls by tool use ID. + */ +function findToolName(message: ParsedMessage, toolUseId: string): string | null { + if (message.toolCalls) { + const toolCall = message.toolCalls.find((tc) => tc.id === toolUseId); + if (toolCall) { + return toolCall.name; + } + } + return null; +} + +/** + * Finds tool name by searching tool_use_id in the message context. + */ +export function findToolNameByToolUseId(message: ParsedMessage, toolUseId: string): string | null { + // First check toolCalls + const fromToolCalls = findToolName(message, toolUseId); + if (fromToolCalls) return fromToolCalls; + + // Check sourceToolUseID if this message is a tool result + if (message.sourceToolUseID === toolUseId && message.toolUseResult) { + if (typeof message.toolUseResult.toolName === 'string') { + return message.toolUseResult.toolName; + } + } + + return null; +} + +// ============================================================================= +// Message Truncation +// ============================================================================= + +/** + * Truncates error message to a reasonable length for display. + */ +function truncateMessage(message: string, maxLength: number = 500): string { + if (message.length <= maxLength) { + return message; + } + return message.slice(0, maxLength) + '...'; +} + +// ============================================================================= +// DetectedError Creation +// ============================================================================= + +/** + * Creates a DetectedError object with all required fields. + */ +export function createDetectedError(params: CreateDetectedErrorParams): DetectedError { + return { + id: randomUUID(), + timestamp: params.timestamp.getTime(), + sessionId: params.sessionId, + projectId: params.projectId, + filePath: params.filePath, + source: params.source, + message: truncateMessage(params.message), + lineNumber: params.lineNumber, + toolUseId: params.toolUseId, + subagentId: params.subagentId, + triggerColor: params.triggerColor, + triggerId: params.triggerId, + triggerName: params.triggerName, + context: { + projectName: params.projectName, + cwd: params.cwd, + }, + }; +} diff --git a/src/main/services/error/ErrorTriggerChecker.ts b/src/main/services/error/ErrorTriggerChecker.ts new file mode 100644 index 00000000..9ddbf3de --- /dev/null +++ b/src/main/services/error/ErrorTriggerChecker.ts @@ -0,0 +1,461 @@ +/** + * ErrorTriggerChecker service - Checks different trigger types against messages. + * + * Provides utilities for: + * - Checking tool_result triggers + * - Checking tool_use triggers + * - Checking token threshold triggers + * - Validating project scope + */ + +import { type ParsedMessage } from '@main/types'; +import { extractProjectName } from '@main/utils/pathDecoder'; + +import { + estimateTokens, + extractToolResults, + type ToolResultInfo, + type ToolUseInfo, +} from '../analysis/ToolResultExtractor'; +import { formatTokens, getToolSummary } from '../analysis/ToolSummaryFormatter'; +import { projectPathResolver } from '../discovery/ProjectPathResolver'; +import { type NotificationTrigger } from '../infrastructure/ConfigManager'; +import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; + +import { + createDetectedError, + type DetectedError, + extractErrorMessage, + findToolNameByToolUseId, +} from './ErrorMessageBuilder'; +import { + extractToolUseField, + getContentBlocks, + matchesIgnorePatterns, + matchesPattern, +} from './TriggerMatcher'; + +// ============================================================================= +// Repository Scope Checking +// ============================================================================= + +// Cache for projectId -> repositoryId mapping to avoid repeated resolution +const repositoryIdCache = new Map(); + +interface RepositoryScopeTarget { + projectId: string; + cwdHint?: string; +} + +/** + * Resolves a projectId to its repositoryId using GitIdentityResolver. + * Results are cached for performance. + * @param projectId - The encoded project ID (e.g., "-Users-username-myproject") + * @returns Repository ID or null if not resolvable + */ +async function resolveRepositoryId(target: string | RepositoryScopeTarget): Promise { + const projectId = typeof target === 'string' ? target : target.projectId; + const cwdHint = typeof target === 'string' ? undefined : target.cwdHint; + + // Check cache first + if (repositoryIdCache.has(projectId)) { + return repositoryIdCache.get(projectId) ?? null; + } + + const projectPath = await projectPathResolver.resolveProjectPath(projectId, { cwdHint }); + + // Resolve repository identity + const identity = await gitIdentityResolver.resolveIdentity(projectPath); + const repositoryId = identity?.id ?? null; + + // Cache the result + repositoryIdCache.set(projectId, repositoryId); + + return repositoryId; +} + +/** + * Synchronous version of resolveRepositoryId using cached values only. + * If not cached, attempts synchronous resolution via path heuristics. + */ +function resolveRepositoryIdSync(projectId: string): string | null { + // Check cache first + if (repositoryIdCache.has(projectId)) { + return repositoryIdCache.get(projectId) ?? null; + } + + // For sync context, we can't do async resolution + // The async version should be called during initialization + return null; +} + +/** + * Checks if the project matches the trigger's repository scope. + * @param projectId - The encoded project ID (e.g., "-Users-username-myproject") + * @param repositoryIds - Optional list of repository group IDs to scope the trigger to + * @returns true if trigger should apply, false if it should be skipped + */ +export function matchesRepositoryScope(projectId: string, repositoryIds?: string[]): boolean { + // If no repository IDs specified, trigger applies to all repositories + if (!repositoryIds || repositoryIds.length === 0) { + return true; + } + + // Get the repository ID for this project (from cache) + const repositoryId = resolveRepositoryIdSync(projectId); + + // If we can't resolve the repository ID, don't match + if (!repositoryId) { + return false; + } + + // Check if the repository ID matches any of the configured IDs + return repositoryIds.includes(repositoryId); +} + +/** + * Pre-resolves repository IDs for a list of project IDs. + * Call this before checking triggers to populate the cache. + */ +export async function preResolveRepositoryIds( + targets: (string | RepositoryScopeTarget)[] +): Promise { + const uniqueTargets = new Map(); + + for (const target of targets) { + if (typeof target === 'string') { + if (!uniqueTargets.has(target)) { + uniqueTargets.set(target, { projectId: target }); + } + continue; + } + + const existing = uniqueTargets.get(target.projectId); + if (!existing) { + uniqueTargets.set(target.projectId, target); + continue; + } + + // Prefer a target with cwd hint if one was provided. + if (!existing.cwdHint && target.cwdHint) { + uniqueTargets.set(target.projectId, target); + } + } + + await Promise.all( + Array.from(uniqueTargets.values()).map((target) => resolveRepositoryId(target)) + ); +} + +// ============================================================================= +// Tool Result Trigger Checking +// ============================================================================= + +/** + * Checks if a tool_result matches a trigger. + */ +export function checkToolResultTrigger( + message: ParsedMessage, + trigger: NotificationTrigger, + toolUseMap: Map, + sessionId: string, + projectId: string, + filePath: string, + lineNumber: number +): DetectedError | null { + const toolResults = extractToolResults(message, findToolNameByToolUseId); + + for (const result of toolResults) { + // If requireError is true, only match when is_error is true + if (trigger.requireError) { + if (!result.isError) { + continue; + } + + // Extract error message for ignore pattern checking + const errorMessage = extractErrorMessage(result); + + // Check ignore patterns - if any match, skip this error + if (matchesIgnorePatterns(errorMessage, trigger.ignorePatterns)) { + continue; + } + + // Create detected error + return createDetectedError({ + sessionId, + projectId, + filePath, + projectName: extractProjectName(projectId), + lineNumber, + source: result.toolName ?? 'tool_result', + message: errorMessage, + timestamp: message.timestamp, + cwd: message.cwd, + toolUseId: result.toolUseId, + triggerColor: trigger.color, + triggerId: trigger.id, + triggerName: trigger.name, + }); + } + + // Non-error tool_result triggers (if toolName is specified) + if (trigger.toolName) { + const toolUse = toolUseMap.get(result.toolUseId); + if (toolUse?.name !== trigger.toolName) { + continue; + } + + // Match against content if matchField is 'content' + if (trigger.matchField === 'content' && trigger.matchPattern) { + const content = + typeof result.content === 'string' ? result.content : JSON.stringify(result.content); + if (!matchesPattern(content, trigger.matchPattern)) { + continue; + } + if (matchesIgnorePatterns(content, trigger.ignorePatterns)) { + continue; + } + + return createDetectedError({ + sessionId, + projectId, + filePath, + projectName: extractProjectName(projectId), + lineNumber, + source: trigger.toolName, + message: `Tool result matched: ${content.slice(0, 200)}`, + timestamp: message.timestamp, + cwd: message.cwd, + toolUseId: result.toolUseId, + triggerColor: trigger.color, + triggerId: trigger.id, + triggerName: trigger.name, + }); + } + } + } + + return null; +} + +// ============================================================================= +// Tool Use Trigger Checking +// ============================================================================= + +/** + * Checks if a tool_use matches a trigger. + */ +export function checkToolUseTrigger( + message: ParsedMessage, + trigger: NotificationTrigger, + sessionId: string, + projectId: string, + filePath: string, + lineNumber: number +): DetectedError | null { + if (message.type !== 'assistant') return null; + + const contentBlocks = getContentBlocks(message); + + for (const block of contentBlocks) { + if (block.type !== 'tool_use') continue; + + const toolUse = block as { + type: 'tool_use'; + id: string; + name: string; + input?: Record; + }; + + // Check tool name if specified + if (trigger.toolName && toolUse.name !== trigger.toolName) { + continue; + } + + // Extract the field to match based on matchField + // If no matchField specified (e.g., "Any Tool"), match against entire input JSON + const fieldValue = trigger.matchField + ? extractToolUseField(toolUse, trigger.matchField) + : toolUse.input + ? JSON.stringify(toolUse.input) + : null; + if (!fieldValue) continue; + + // Check match pattern + if (trigger.matchPattern && !matchesPattern(fieldValue, trigger.matchPattern)) { + continue; + } + + // Check ignore patterns + if (matchesIgnorePatterns(fieldValue, trigger.ignorePatterns)) { + continue; + } + + // Match found! + return createDetectedError({ + sessionId, + projectId, + filePath, + projectName: extractProjectName(projectId), + lineNumber, + source: toolUse.name, + message: `${trigger.matchField ?? 'tool_use'}: ${fieldValue.slice(0, 200)}`, + timestamp: message.timestamp, + cwd: message.cwd, + toolUseId: toolUse.id, + triggerColor: trigger.color, + triggerId: trigger.id, + triggerName: trigger.name, + }); + } + + return null; +} + +// ============================================================================= +// Token Threshold Trigger Checking +// ============================================================================= + +/** + * Check if individual tool_use blocks exceed the token threshold. + * Returns an array of DetectedError for each tool_use that exceeds the threshold. + * + * Token calculation (matches context window impact): + * - Tool call tokens: estimated from name + JSON.stringify(input) (what enters context) + * - Tool result tokens: estimated from tool_result.content (what Claude reads) + * - Total = call + result + */ +export function checkTokenThresholdTrigger( + message: ParsedMessage, + trigger: NotificationTrigger, + toolResultMap: Map, + sessionId: string, + projectId: string, + filePath: string, + lineNumber: number +): DetectedError[] { + const errors: DetectedError[] = []; + + // Only check for token_threshold mode + if (trigger.mode !== 'token_threshold' || !trigger.tokenThreshold) { + return errors; + } + + // Only check assistant messages that contain tool_use blocks + if (message.type !== 'assistant') { + return errors; + } + + const tokenType = trigger.tokenType ?? 'total'; + const threshold = trigger.tokenThreshold; + + // Collect all tool_use blocks from message + const toolUseBlocks: { id: string; name: string; input: Record }[] = []; + + // Check content array for tool_use blocks + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use') { + const toolUse = block; + toolUseBlocks.push({ + id: toolUse.id, + name: toolUse.name, + input: toolUse.input || {}, + }); + } + } + } + + // Also check toolCalls array if present + if (message.toolCalls) { + for (const toolCall of message.toolCalls) { + // Avoid duplicates + if (!toolUseBlocks.some((t) => t.id === toolCall.id)) { + toolUseBlocks.push({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input || {}, + }); + } + } + } + + if (toolUseBlocks.length === 0) { + return errors; + } + + // Check each tool_use block individually + for (const toolUse of toolUseBlocks) { + // Check tool name filter if specified + if (trigger.toolName && toolUse.name !== trigger.toolName) { + continue; + } + + // Calculate tool call tokens directly from name + input + // This reflects what actually enters the context window + const toolCallTokens = estimateTokens(toolUse.name + JSON.stringify(toolUse.input)); + + // Calculate tool result tokens (what Claude reads back) + let toolResultTokens = 0; + const toolResult = toolResultMap.get(toolUse.id); + if (toolResult) { + toolResultTokens = estimateTokens(toolResult.content); + } + + // Calculate token count based on tokenType + // Note: 'input' here means tool CALL tokens (what enters context) + // 'output' here means tool RESULT tokens (what Claude reads) + let tokenCount = 0; + switch (tokenType) { + case 'input': + // Tool call tokens (name + input that enters context) + tokenCount = toolCallTokens; + break; + case 'output': + // Tool result tokens (what Claude reads - success message, file content for Read, etc.) + tokenCount = toolResultTokens; + break; + case 'total': + // Both: full context impact of the tool operation + tokenCount = toolCallTokens + toolResultTokens; + break; + } + + // Check threshold + if (tokenCount <= threshold) { + continue; + } + + // Build summary for the tool + const toolSummary = getToolSummary(toolUse.name, toolUse.input); + + // Build message with tool info and token type for clarity + const tokenTypeLabel = tokenType === 'total' ? '' : ` ${tokenType}`; + const tokenMessage = `${toolUse.name} - ${toolSummary} : ~${formatTokens(tokenCount)}${tokenTypeLabel} tokens`; + + // Check ignore patterns + if (matchesIgnorePatterns(tokenMessage, trigger.ignorePatterns)) { + continue; + } + + errors.push( + createDetectedError({ + sessionId, + projectId, + filePath, + projectName: extractProjectName(projectId), + lineNumber, + source: toolUse.name, + message: tokenMessage, + timestamp: message.timestamp, + cwd: message.cwd, + toolUseId: toolUse.id, + triggerColor: trigger.color, + triggerId: trigger.id, + triggerName: trigger.name, + }) + ); + } + + return errors; +} diff --git a/src/main/services/error/ErrorTriggerTester.ts b/src/main/services/error/ErrorTriggerTester.ts new file mode 100644 index 00000000..e92329f9 --- /dev/null +++ b/src/main/services/error/ErrorTriggerTester.ts @@ -0,0 +1,329 @@ +/** + * ErrorTriggerTester service - Testing functionality for trigger preview. + * + * Provides utilities for: + * - Testing trigger configurations against historical session data + * - Running single trigger detection for preview functionality + */ + +import { type ParsedMessage } from '@main/types'; +import { parseJsonlFile } from '@main/utils/jsonl'; +import { createLogger } from '@shared/utils/logger'; +import * as path from 'path'; + +import { + buildToolResultMap, + buildToolUseMap, + type ToolResultInfo, + type ToolUseInfo, +} from '../analysis/ToolResultExtractor'; +import { ProjectScanner } from '../discovery/ProjectScanner'; +import { type NotificationTrigger } from '../infrastructure/ConfigManager'; + +const logger = createLogger('Service:ErrorTriggerTester'); + +import { type DetectedError } from './ErrorMessageBuilder'; +import { + checkTokenThresholdTrigger, + checkToolResultTrigger, + checkToolUseTrigger, + matchesRepositoryScope, + preResolveRepositoryIds, +} from './ErrorTriggerChecker'; + +// ============================================================================= +// Trigger Testing (Preview Feature) +// ============================================================================= + +/** + * Safety limits to prevent resource exhaustion from faulty triggers. + * + * Strategy: Stop as soon as we find enough results, not after scanning N sessions. + * This allows finding rare patterns (like .env) while still being fast for common patterns. + */ +const TEST_LIMITS = { + /** Maximum number of errors to return (primary stop condition) */ + MAX_ERRORS: 50, + /** Maximum totalCount to track (prevents indefinite counting) */ + MAX_TOTAL_COUNT: 10_000, + /** Maximum time in ms before aborting (30 seconds) - main safety limit */ + TIMEOUT_MS: 30_000, +} as const; + +/** + * State object used during trigger testing to track progress and limits. + */ +interface TestState { + errors: DetectedError[]; + totalCount: number; + sessionsScanned: number; + truncated: boolean; + startTime: number; + effectiveLimit: number; +} + +/** + * Checks if the test should stop due to hitting safety limits. + * Returns a reason string if should stop, null if should continue. + * + * Stop conditions (in order of priority): + * 1. Found enough errors (effectiveLimit) - success, no warning + * 2. Timeout (30s) - safety limit + * 3. Total count limit (10k) - prevent counting forever + */ +function shouldStopTest(state: TestState): string | null { + // Primary stop condition: found enough errors + if (state.errors.length >= state.effectiveLimit) { + return null; // Stop but don't log - we have enough errors (success case) + } + + // Safety limits + if (Date.now() - state.startTime > TEST_LIMITS.TIMEOUT_MS) { + return 'Trigger test timed out after 30 seconds'; + } + if (state.totalCount >= TEST_LIMITS.MAX_TOTAL_COUNT) { + return 'Trigger test stopped after reaching count limit'; + } + + return null; +} + +/** + * Tests a trigger configuration against historical session data. + * Returns a list of errors that would have been detected. + * + * Strategy: Scan sessions until we find enough results or hit safety limits. + * This allows finding rare patterns while staying fast for common patterns. + * + * Stop conditions: + * - Found enough errors (limit) - primary success condition + * - Timeout (30s) - safety limit + * - Total count reached (10k) - prevents infinite counting + * + * @param trigger - The trigger configuration to test + * @param limit - Maximum number of results to return (default 50, capped at MAX_ERRORS) + */ +export async function testTrigger( + trigger: NotificationTrigger, + limit: number = TEST_LIMITS.MAX_ERRORS +): Promise<{ + totalCount: number; + errors: DetectedError[]; + /** True if results were truncated due to safety limits */ + truncated?: boolean; +}> { + const projectScanner = new ProjectScanner(); + + const state: TestState = { + errors: [], + totalCount: 0, + sessionsScanned: 0, + truncated: false, + startTime: Date.now(), + effectiveLimit: Math.min(limit, TEST_LIMITS.MAX_ERRORS), + }; + + try { + // Get list of all projects + const projects = await projectScanner.scan(); + + // Process each project to find session files + for (const project of projects) { + // Check safety limits before processing project + const stopReason = shouldStopTest(state); + if (stopReason) { + logger.warn(stopReason); + state.truncated = true; + break; + } + + // Early exit if we have enough errors (no truncation warning needed) + if (state.errors.length >= state.effectiveLimit) break; + + const sessionFiles = await projectScanner.listSessionFiles(project.id); + + // Pre-resolve repository ID for this project. + await preResolveRepositoryIds([{ projectId: project.id, cwdHint: project.path }]); + + // Process each session file (most recent first) + const shouldBreakOuter = await processSessionFiles( + sessionFiles, + trigger, + project.id, + state, + parseJsonlFile + ); + + if (shouldBreakOuter) break; + } + + return { totalCount: state.totalCount, errors: state.errors, truncated: state.truncated }; + } catch (error) { + logger.error('Error testing trigger:', error); + return { totalCount: 0, errors: [] }; + } +} + +/** + * Processes session files for a single project. + * Returns true if outer loop should break, false otherwise. + */ +async function processSessionFiles( + sessionFiles: string[], + trigger: NotificationTrigger, + projectId: string, + state: TestState, + parseFile: (path: string) => Promise +): Promise { + for (const filePath of sessionFiles) { + // Check safety limits + const stopReason = shouldStopTest(state); + if (stopReason) { + logger.warn(stopReason); + state.truncated = true; + return true; // Break outer loop + } + + // Early exit if we have enough errors + if (state.errors.length >= state.effectiveLimit) return false; + + try { + state.sessionsScanned++; + + // Parse session file + const messages = await parseFile(filePath); + + // Extract sessionId from file path + const filename = path.basename(filePath); + const sessionId = filename.replace(/\.jsonl$/, ''); + + // Test the trigger against each message + const sessionErrors = detectErrorsWithTrigger( + messages, + trigger, + sessionId, + projectId, + filePath + ); + + // Update totalCount but cap it + const newTotal = state.totalCount + sessionErrors.length; + if (newTotal >= TEST_LIMITS.MAX_TOTAL_COUNT) { + state.totalCount = TEST_LIMITS.MAX_TOTAL_COUNT; + state.truncated = true; + } else { + state.totalCount = newTotal; + } + + // Add errors up to limit + for (const error of sessionErrors) { + if (state.errors.length >= state.effectiveLimit) break; + state.errors.push(error); + } + } catch (error) { + // Skip files that can't be parsed + logger.error(`Error parsing session file ${filePath}:`, error); + continue; + } + } + + return false; // Don't break outer loop +} + +/** + * Detects errors from messages using a single trigger. + * Used by testTrigger for preview functionality. + */ +function detectErrorsWithTrigger( + messages: ParsedMessage[], + trigger: NotificationTrigger, + sessionId: string, + projectId: string, + filePath: string +): DetectedError[] { + const errors: DetectedError[] = []; + + // Build tool_use map for linking results to calls + const toolUseMap = buildToolUseMap(messages); + // Build tool_result map for estimating output tokens + const toolResultMap = buildToolResultMap(messages); + + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const lineNumber = i + 1; // 1-based line numbers for JSONL + + const triggerErrors = checkTrigger( + message, + trigger, + toolUseMap, + toolResultMap, + sessionId, + projectId, + filePath, + lineNumber + ); + + // Add all detected errors (can be multiple for token_threshold mode) + errors.push(...triggerErrors); + } + + return errors; +} + +/** + * Checks if a message matches a specific trigger. + * Internal helper for detectErrorsWithTrigger. + */ +function checkTrigger( + message: ParsedMessage, + trigger: NotificationTrigger, + toolUseMap: Map, + toolResultMap: Map, + sessionId: string, + projectId: string, + filePath: string, + lineNumber: number +): DetectedError[] { + // Check repository scope first - if repositoryIds is set, only trigger for matching repositories + if (!matchesRepositoryScope(projectId, trigger.repositoryIds)) { + return []; + } + + // Use the mode directly (mode is now required in NotificationTrigger) + const effectiveMode = trigger.mode; + + // Handle token_threshold mode - check each tool_use individually + if (effectiveMode === 'token_threshold') { + return checkTokenThresholdTrigger( + message, + trigger, + toolResultMap, + sessionId, + projectId, + filePath, + lineNumber + ); + } + + // Handle tool_result triggers + if (trigger.contentType === 'tool_result') { + const error = checkToolResultTrigger( + message, + trigger, + toolUseMap, + sessionId, + projectId, + filePath, + lineNumber + ); + return error ? [error] : []; + } + + // Handle tool_use triggers (for future expansion) + if (trigger.contentType === 'tool_use') { + const error = checkToolUseTrigger(message, trigger, sessionId, projectId, filePath, lineNumber); + return error ? [error] : []; + } + + return []; +} diff --git a/src/main/services/error/TriggerMatcher.ts b/src/main/services/error/TriggerMatcher.ts new file mode 100644 index 00000000..7e06c47c --- /dev/null +++ b/src/main/services/error/TriggerMatcher.ts @@ -0,0 +1,82 @@ +/** + * TriggerMatcher service - Pattern matching utilities for trigger checking. + * + * Provides utilities for: + * - Regex pattern matching (with ReDoS protection) + * - Ignore pattern checking + * - Extracting fields from tool_use blocks + * - Getting content blocks from messages + */ + +import { type ContentBlock, type ParsedMessage } from '@main/types'; +import { createSafeRegExp } from '@main/utils/regexValidation'; + +// ============================================================================= +// Pattern Matching +// ============================================================================= + +/** + * Checks if content matches a pattern. + * Uses validated regex to prevent ReDoS attacks. + */ +export function matchesPattern(content: string, pattern: string): boolean { + const regex = createSafeRegExp(pattern, 'i'); + if (!regex) { + // Pattern is invalid or potentially dangerous, reject match + return false; + } + return regex.test(content); +} + +/** + * Checks if content matches any of the ignore patterns. + * Uses validated regex to prevent ReDoS attacks. + */ +export function matchesIgnorePatterns(content: string, ignorePatterns?: string[]): boolean { + if (!ignorePatterns || ignorePatterns.length === 0) { + return false; + } + + for (const pattern of ignorePatterns) { + const regex = createSafeRegExp(pattern, 'i'); + if (regex?.test(content)) { + return true; + } + // Invalid or potentially dangerous patterns are skipped + } + + return false; +} + +// ============================================================================= +// Field Extraction +// ============================================================================= + +/** + * Extracts the specified field from a tool_use block. + */ +export function extractToolUseField( + toolUse: { name: string; input?: Record }, + matchField?: string +): string | null { + if (!matchField || !toolUse.input) return null; + + const value = toolUse.input[matchField]; + if (typeof value === 'string') { + return value; + } + if (value !== undefined) { + return JSON.stringify(value); + } + return null; +} + +/** + * Gets content blocks from a message, handling both array and object formats. + */ +export function getContentBlocks(message: ParsedMessage): ContentBlock[] { + if (Array.isArray(message.content)) { + return message.content; + } + return []; +} diff --git a/src/main/services/error/index.ts b/src/main/services/error/index.ts new file mode 100644 index 00000000..87d5febe --- /dev/null +++ b/src/main/services/error/index.ts @@ -0,0 +1,16 @@ +/** + * Error services - Error detection and notification triggers. + * + * Exports: + * - ErrorDetector: Detects errors in parsed messages + * - ErrorTriggerChecker: Checks messages against notification triggers + * - ErrorTriggerTester: Tests triggers against historical data + * - ErrorMessageBuilder: Builds error notification messages + * - TriggerMatcher: Matches content against trigger patterns + */ + +export * from './ErrorDetector'; +export * from './ErrorMessageBuilder'; +export * from './ErrorTriggerChecker'; +export * from './ErrorTriggerTester'; +export * from './TriggerMatcher'; diff --git a/src/main/services/index.ts b/src/main/services/index.ts new file mode 100644 index 00000000..417b8e82 --- /dev/null +++ b/src/main/services/index.ts @@ -0,0 +1,16 @@ +/** + * Services barrel export - Re-exports all services for backward compatibility. + * + * Domain organization: + * - analysis/: Chunk building and session analysis + * - discovery/: Scanning and locating session data + * - error/: Error detection and notification triggers + * - infrastructure/: Core application infrastructure + * - parsing/: Parsing JSONL and configuration files + */ + +export * from './analysis'; +export * from './discovery'; +export * from './error'; +export * from './infrastructure'; +export * from './parsing'; diff --git a/src/main/services/infrastructure/ConfigManager.ts b/src/main/services/infrastructure/ConfigManager.ts new file mode 100644 index 00000000..4f6fa6c9 --- /dev/null +++ b/src/main/services/infrastructure/ConfigManager.ts @@ -0,0 +1,681 @@ +/** + * ConfigManager service - Manages app configuration stored at ~/.claude/claude-code-context-config.json. + * + * Responsibilities: + * - Load configuration from disk on initialization + * - Provide default values for all configuration fields + * - Save configuration changes to disk + * - Manage notification settings (ignore patterns, projects, snooze) + * - Handle JSON parse errors gracefully + */ + +import { validateRegexPattern } from '@main/utils/regexValidation'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { DEFAULT_TRIGGERS, TriggerManager } from './TriggerManager'; + +import type { TriggerColor } from '@shared/constants/triggerColors'; + +const logger = createLogger('Service:ConfigManager'); + +const CONFIG_DIR = path.join(os.homedir(), '.claude'); +const CONFIG_FILENAME = 'claude-code-context-config.json'; +const DEFAULT_CONFIG_PATH = path.join(CONFIG_DIR, CONFIG_FILENAME); + +// =========================================================================== +// Types +// =========================================================================== + +export interface NotificationConfig { + enabled: boolean; + soundEnabled: boolean; + ignoredRegex: string[]; + ignoredRepositories: string[]; // Repository group IDs to ignore + snoozedUntil: number | null; // Unix timestamp (ms) when snooze ends + snoozeMinutes: number; // Default snooze duration + /** Whether to include errors from subagent sessions */ + includeSubagentErrors: boolean; + /** Notification triggers - define when to generate notifications */ + triggers: NotificationTrigger[]; +} + +/** + * Content types that can trigger notifications. + */ +export type TriggerContentType = 'tool_result' | 'tool_use' | 'thinking' | 'text'; + +/** + * Known tool names that can be filtered for tool_use triggers. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used for type derivation only +const KNOWN_TOOL_NAMES = [ + 'Bash', + 'Task', + 'TodoWrite', + 'Read', + 'Write', + 'Edit', + 'Grep', + 'Glob', + 'WebFetch', + 'WebSearch', + 'LSP', + 'Skill', + 'NotebookEdit', + 'AskUserQuestion', + 'KillShell', + 'TaskOutput', +] as const; + +/** + * Tool names that can be filtered for tool_use triggers. + * Accepts known tool names or any custom tool name. + */ +export type TriggerToolName = (typeof KNOWN_TOOL_NAMES)[number] | (string & Record); + +/** + * Match fields available for different content types and tools. + */ +export type MatchFieldForToolResult = 'content'; +export type MatchFieldForBash = 'command' | 'description'; +export type MatchFieldForTask = 'description' | 'prompt' | 'subagent_type'; +export type MatchFieldForRead = 'file_path'; +export type MatchFieldForWrite = 'file_path' | 'content'; +export type MatchFieldForEdit = 'file_path' | 'old_string' | 'new_string'; +export type MatchFieldForGlob = 'pattern' | 'path'; +export type MatchFieldForGrep = 'pattern' | 'path' | 'glob'; +export type MatchFieldForWebFetch = 'url' | 'prompt'; +export type MatchFieldForWebSearch = 'query'; +export type MatchFieldForSkill = 'skill' | 'args'; +export type MatchFieldForThinking = 'thinking'; +export type MatchFieldForText = 'text'; + +/** + * Combined type for all possible match fields. + */ +export type TriggerMatchField = + | MatchFieldForToolResult + | MatchFieldForBash + | MatchFieldForTask + | MatchFieldForRead + | MatchFieldForWrite + | MatchFieldForEdit + | MatchFieldForGlob + | MatchFieldForGrep + | MatchFieldForWebFetch + | MatchFieldForWebSearch + | MatchFieldForSkill + | MatchFieldForThinking + | MatchFieldForText; + +/** + * Trigger mode determines how the trigger evaluates conditions. + * - 'error_status': Triggers when is_error is true (simple boolean check) + * - 'content_match': Triggers when content matches a regex pattern + * - 'token_threshold': Triggers when token count exceeds threshold + */ +export type TriggerMode = 'error_status' | 'content_match' | 'token_threshold'; + +/** + * Token type for threshold triggers. + */ +export type TriggerTokenType = 'input' | 'output' | 'total'; + +/** + * Notification trigger configuration. + * Defines when notifications should be generated. + */ +export interface NotificationTrigger { + /** Unique identifier for this trigger */ + id: string; + /** Human-readable name for this trigger */ + name: string; + /** Whether this trigger is enabled */ + enabled: boolean; + /** Content type to match */ + contentType: TriggerContentType; + /** For tool_use/tool_result: specific tool name to match */ + toolName?: TriggerToolName; + /** Whether this is a built-in trigger (cannot be deleted) */ + isBuiltin?: boolean; + /** Regex patterns to IGNORE (skip notification if content matches any of these) */ + ignorePatterns?: string[]; + + // === Discriminated Union Mode === + /** Trigger evaluation mode */ + mode: TriggerMode; + + // === Mode: error_status === + /** For error_status mode: always triggers on is_error=true */ + requireError?: boolean; + + // === Mode: content_match === + /** For content_match mode: field to match against */ + matchField?: TriggerMatchField; + /** For content_match mode: regex pattern to match */ + matchPattern?: string; + + // === Mode: token_threshold === + /** For token_threshold mode: minimum token count to trigger */ + tokenThreshold?: number; + /** For token_threshold mode: which token type to check */ + tokenType?: TriggerTokenType; + + // === Repository Scope === + /** If set, this trigger only applies to these repository group IDs */ + repositoryIds?: string[]; + + // === Display === + /** Color for notification dot and navigation highlight (preset key or hex string) */ + color?: TriggerColor; +} + +export interface GeneralConfig { + launchAtLogin: boolean; + showDockIcon: boolean; + theme: 'dark' | 'light' | 'system'; + defaultTab: 'dashboard' | 'last-session'; +} + +export interface DisplayConfig { + showTimestamps: boolean; + compactMode: boolean; + syntaxHighlighting: boolean; +} + +export interface SessionsConfig { + pinnedSessions: Record; +} + +export interface AppConfig { + notifications: NotificationConfig; + general: GeneralConfig; + display: DisplayConfig; + sessions: SessionsConfig; +} + +// Config section keys for type-safe updates +export type ConfigSection = keyof AppConfig; + +// =========================================================================== +// Default Configuration +// =========================================================================== + +// Default regex patterns for common non-actionable notifications +const DEFAULT_IGNORED_REGEX = ["The user doesn't want to proceed with this tool use\\."]; + +const DEFAULT_CONFIG: AppConfig = { + notifications: { + enabled: true, + soundEnabled: true, + ignoredRegex: [...DEFAULT_IGNORED_REGEX], + ignoredRepositories: [], + snoozedUntil: null, + snoozeMinutes: 30, + includeSubagentErrors: true, + triggers: DEFAULT_TRIGGERS, + }, + general: { + launchAtLogin: false, + showDockIcon: true, + theme: 'dark', + defaultTab: 'dashboard', + }, + display: { + showTimestamps: true, + compactMode: false, + syntaxHighlighting: true, + }, + sessions: { + pinnedSessions: {}, + }, +}; + +// =========================================================================== +// ConfigManager Class +// =========================================================================== + +export class ConfigManager { + private config: AppConfig; + private readonly configPath: string; + private static instance: ConfigManager | null = null; + private triggerManager: TriggerManager; + + constructor(configPath?: string) { + this.configPath = configPath ?? DEFAULT_CONFIG_PATH; + this.config = this.loadConfig(); + this.triggerManager = new TriggerManager(this.config.notifications.triggers, () => + this.saveConfig() + ); + } + + // =========================================================================== + // Singleton Pattern + // =========================================================================== + + /** + * Gets the singleton instance of ConfigManager. + */ + static getInstance(): ConfigManager { + ConfigManager.instance ??= new ConfigManager(); + return ConfigManager.instance; + } + + /** + * Resets the singleton instance (useful for testing). + */ + static resetInstance(): void { + ConfigManager.instance = null; + } + + // =========================================================================== + // Config Loading & Saving + // =========================================================================== + + /** + * Loads configuration from disk. + * Returns default config if file doesn't exist or is invalid. + */ + private loadConfig(): AppConfig { + try { + if (!fs.existsSync(this.configPath)) { + logger.info('No config file found, using defaults'); + return this.deepClone(DEFAULT_CONFIG); + } + + const content = fs.readFileSync(this.configPath, 'utf8'); + const parsed = JSON.parse(content) as Partial; + + // Merge with defaults to ensure all fields exist + return this.mergeWithDefaults(parsed); + } catch (error) { + logger.error('Error loading config, using defaults:', error); + return this.deepClone(DEFAULT_CONFIG); + } + } + + /** + * Saves the current configuration to disk. + */ + private saveConfig(): void { + try { + this.persistConfig(this.config); + logger.info('Config saved'); + } catch (error) { + logger.error('Error saving config:', error); + } + } + + /** + * Persists configuration to the canonical path. + */ + private persistConfig(config: AppConfig): void { + const configDir = path.dirname(this.configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const content = JSON.stringify(config, null, 2); + fs.writeFileSync(this.configPath, content, 'utf8'); + } + + /** + * Merges loaded config with defaults to ensure all fields exist. + * Special handling for triggers array to preserve existing triggers + * and add any missing builtin triggers. + */ + private mergeWithDefaults(loaded: Partial): AppConfig { + const loadedNotifications = loaded.notifications ?? ({} as Partial); + const loadedTriggers = loadedNotifications.triggers ?? []; + + // Merge triggers: preserve existing triggers, add missing builtin ones + const mergedTriggers = TriggerManager.mergeTriggers(loadedTriggers, DEFAULT_TRIGGERS); + + return { + notifications: { + ...DEFAULT_CONFIG.notifications, + ...loadedNotifications, + triggers: mergedTriggers, + }, + general: { + ...DEFAULT_CONFIG.general, + ...(loaded.general ?? {}), + }, + display: { + ...DEFAULT_CONFIG.display, + ...(loaded.display ?? {}), + }, + sessions: { + ...DEFAULT_CONFIG.sessions, + ...(loaded.sessions ?? {}), + }, + }; + } + + /** + * Deep clones an object. + */ + private deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; + } + + // =========================================================================== + // Config Access + // =========================================================================== + + /** + * Gets the full configuration object. + */ + getConfig(): AppConfig { + return this.deepClone(this.config); + } + + /** + * Gets the configuration file path. + */ + getConfigPath(): string { + return this.configPath; + } + + // =========================================================================== + // Config Updates + // =========================================================================== + + /** + * Updates a section of the configuration. + * @param section - The config section to update ('notifications', 'general', 'display') + * @param data - Partial data to merge into the section + */ + updateConfig(section: K, data: Partial): AppConfig { + this.config[section] = { + ...this.config[section], + ...data, + }; + this.saveConfig(); + return this.getConfig(); + } + + // =========================================================================== + // Notification Ignore Regex Management + // =========================================================================== + + /** + * Adds a regex pattern to the ignore list. + * Validates pattern for safety to prevent ReDoS attacks. + * @param pattern - Regex pattern string to add + * @returns Updated config + */ + addIgnoreRegex(pattern: string): AppConfig { + if (!pattern || pattern.trim().length === 0) { + return this.getConfig(); + } + + const trimmedPattern = pattern.trim(); + + // Validate regex pattern (includes ReDoS protection) + const validation = validateRegexPattern(trimmedPattern); + if (!validation.valid) { + logger.error(`ConfigManager: Invalid regex pattern: ${validation.error ?? 'Unknown error'}`); + return this.getConfig(); + } + + // Check for duplicates + if (this.config.notifications.ignoredRegex.includes(trimmedPattern)) { + return this.getConfig(); + } + + this.config.notifications.ignoredRegex.push(trimmedPattern); + this.saveConfig(); + return this.getConfig(); + } + + /** + * Removes a regex pattern from the ignore list. + * @param pattern - Regex pattern string to remove + * @returns Updated config + */ + removeIgnoreRegex(pattern: string): AppConfig { + const index = this.config.notifications.ignoredRegex.indexOf(pattern); + if (index !== -1) { + this.config.notifications.ignoredRegex.splice(index, 1); + this.saveConfig(); + } + return this.getConfig(); + } + + // =========================================================================== + // Notification Ignore Repository Management + // =========================================================================== + + /** + * Adds a repository to the ignore list. + * @param repositoryId - Repository group ID to add + * @returns Updated config + */ + addIgnoreRepository(repositoryId: string): AppConfig { + if (!repositoryId || repositoryId.trim().length === 0) { + return this.getConfig(); + } + + const trimmedRepositoryId = repositoryId.trim(); + + // Check for duplicates + if (this.config.notifications.ignoredRepositories.includes(trimmedRepositoryId)) { + return this.getConfig(); + } + + this.config.notifications.ignoredRepositories.push(trimmedRepositoryId); + this.saveConfig(); + return this.getConfig(); + } + + /** + * Removes a repository from the ignore list. + * @param repositoryId - Repository group ID to remove + * @returns Updated config + */ + removeIgnoreRepository(repositoryId: string): AppConfig { + const index = this.config.notifications.ignoredRepositories.indexOf(repositoryId); + if (index !== -1) { + this.config.notifications.ignoredRepositories.splice(index, 1); + this.saveConfig(); + } + return this.getConfig(); + } + + // =========================================================================== + // Trigger Management (delegated to TriggerManager) + // =========================================================================== + + /** + * Adds a new notification trigger. + * @param trigger - The trigger configuration to add + * @returns Updated config + */ + addTrigger(trigger: NotificationTrigger): AppConfig { + this.config.notifications.triggers = this.triggerManager.add(trigger); + return this.deepClone(this.config); + } + + /** + * Updates an existing notification trigger. + * @param triggerId - ID of the trigger to update + * @param updates - Partial trigger configuration to apply + * @returns Updated config + */ + updateTrigger(triggerId: string, updates: Partial): AppConfig { + this.config.notifications.triggers = this.triggerManager.update(triggerId, updates); + return this.deepClone(this.config); + } + + /** + * Removes a notification trigger. + * Built-in triggers cannot be removed. + * @param triggerId - ID of the trigger to remove + * @returns Updated config + */ + removeTrigger(triggerId: string): AppConfig { + this.config.notifications.triggers = this.triggerManager.remove(triggerId); + return this.deepClone(this.config); + } + + /** + * Gets all notification triggers. + * @returns Array of notification triggers + */ + getTriggers(): NotificationTrigger[] { + return this.triggerManager.getAll(); + } + + /** + * Gets enabled notification triggers only. + * @returns Array of enabled notification triggers + */ + getEnabledTriggers(): NotificationTrigger[] { + return this.triggerManager.getEnabled(); + } + + // =========================================================================== + // Snooze Management + // =========================================================================== + + /** + * Sets the snooze period for notifications. + * Alias: snooze() + * @param minutes - Number of minutes to snooze (uses config default if not provided) + * @returns Updated config + */ + setSnooze(minutes?: number): AppConfig { + const snoozeMinutes = minutes ?? this.config.notifications.snoozeMinutes; + const snoozedUntil = Date.now() + snoozeMinutes * 60 * 1000; + + this.config.notifications.snoozedUntil = snoozedUntil; + this.saveConfig(); + + logger.info( + `ConfigManager: Notifications snoozed until ${new Date(snoozedUntil).toISOString()}` + ); + return this.getConfig(); + } + + /** + * Alias for setSnooze() for convenience. + */ + snooze(minutes?: number): AppConfig { + return this.setSnooze(minutes); + } + + /** + * Clears the snooze period, re-enabling notifications. + * @returns Updated config + */ + clearSnooze(): AppConfig { + this.config.notifications.snoozedUntil = null; + this.saveConfig(); + + logger.info('Snooze cleared'); + return this.getConfig(); + } + + /** + * Checks if notifications are currently snoozed. + * Automatically clears expired snooze. + * @returns true if currently snoozed, false otherwise + */ + isSnoozed(): boolean { + const snoozedUntil = this.config.notifications.snoozedUntil; + + if (snoozedUntil === null) { + return false; + } + + // Check if snooze has expired + if (Date.now() >= snoozedUntil) { + // Auto-clear expired snooze + this.config.notifications.snoozedUntil = null; + this.saveConfig(); + return false; + } + + return true; + } + + // =========================================================================== + // Session Pin Management + // =========================================================================== + + /** + * Pins a session for a project. + * @param projectId - The project ID + * @param sessionId - The session ID to pin + */ + pinSession(projectId: string, sessionId: string): void { + const pins = this.config.sessions.pinnedSessions[projectId] ?? []; + + // Check for duplicates + if (pins.some((p) => p.sessionId === sessionId)) { + return; + } + + // Prepend (most recently pinned first) + this.config.sessions.pinnedSessions[projectId] = [{ sessionId, pinnedAt: Date.now() }, ...pins]; + this.saveConfig(); + } + + /** + * Unpins a session for a project. + * @param projectId - The project ID + * @param sessionId - The session ID to unpin + */ + unpinSession(projectId: string, sessionId: string): void { + const pins = this.config.sessions.pinnedSessions[projectId]; + if (!pins) return; + + this.config.sessions.pinnedSessions[projectId] = pins.filter((p) => p.sessionId !== sessionId); + + // Clean up empty arrays + if (this.config.sessions.pinnedSessions[projectId].length === 0) { + delete this.config.sessions.pinnedSessions[projectId]; + } + + this.saveConfig(); + } + + // =========================================================================== + // Utility Methods + // =========================================================================== + + /** + * Resets configuration to defaults. + * @returns Updated config + */ + resetToDefaults(): AppConfig { + this.config = this.deepClone(DEFAULT_CONFIG); + this.triggerManager.setTriggers(this.config.notifications.triggers); + this.saveConfig(); + logger.info('Config reset to defaults'); + return this.getConfig(); + } + + /** + * Reloads configuration from disk. + * Useful if config was modified externally. + * @returns Updated config + */ + reload(): AppConfig { + this.config = this.loadConfig(); + this.triggerManager.setTriggers(this.config.notifications.triggers); + logger.info('Config reloaded from disk'); + return this.getConfig(); + } +} + +// =========================================================================== +// Singleton Export +// =========================================================================== + +/** Singleton instance for convenience */ +export const configManager = ConfigManager.getInstance(); diff --git a/src/main/services/infrastructure/DataCache.ts b/src/main/services/infrastructure/DataCache.ts new file mode 100644 index 00000000..250039bd --- /dev/null +++ b/src/main/services/infrastructure/DataCache.ts @@ -0,0 +1,356 @@ +/** + * DataCache service - LRU cache for parsed session data. + * + * Responsibilities: + * - Cache parsed SessionDetail objects to avoid re-parsing + * - LRU eviction policy with configurable max size + * - TTL-based expiration + * - Provide cache invalidation for file changes + */ + +import { type SessionDetail, type SubagentDetail } from '@main/types'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Service:DataCache'); + +interface CacheEntry { + value: T; + + timestamp: number; + version: number; // Cache schema version +} + +// Union type for cached values + +type CachedValue = SessionDetail | SubagentDetail; + +export class DataCache { + private cache: Map>; + private maxSize: number; + private ttl: number; // Time-to-live in milliseconds + private enabled: boolean; // Whether caching is enabled + private static readonly CURRENT_VERSION = 2; // Increment when cache structure changes + + constructor(maxSize: number = 50, ttlMinutes: number = 10, enabled: boolean = true) { + this.cache = new Map(); + this.maxSize = maxSize; + this.ttl = ttlMinutes * 60 * 1000; + this.enabled = enabled; + } + + /** + * Enable or disable caching. + */ + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) { + // Clear cache when disabling + this.cache.clear(); + } + } + + /** + * Check if caching is enabled. + */ + isEnabled(): boolean { + return this.enabled; + } + + // =========================================================================== + // Cache Operations + // =========================================================================== + + /** + * Gets a cached session detail. + * @param key - Cache key in format "projectId/sessionId" + * @returns The cached SessionDetail, or undefined if not found or expired + */ + get(key: string): SessionDetail | undefined { + if (!this.enabled) { + return undefined; + } + + const entry = this.cache.get(key); + + if (!entry) { + return undefined; + } + + // Check if entry version is outdated + if (entry.version !== DataCache.CURRENT_VERSION) { + logger.info(`DataCache: Invalidating outdated cache entry (v${entry.version}): ${key}`); + this.cache.delete(key); + return undefined; + } + + // Check if entry has expired + const now = Date.now(); + if (now - entry.timestamp > this.ttl) { + this.cache.delete(key); + return undefined; + } + + // Move to end (mark as recently used) + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.value as SessionDetail; + } + + /** + * Gets a cached subagent detail. + * @param key - Cache key in format "subagent-projectId-sessionId-subagentId" + * @returns The cached SubagentDetail, or undefined if not found or expired + */ + getSubagent(key: string): SubagentDetail | undefined { + if (!this.enabled) { + return undefined; + } + + const entry = this.cache.get(key); + + if (!entry) { + return undefined; + } + + // Check if entry version is outdated + if (entry.version !== DataCache.CURRENT_VERSION) { + logger.info( + `DataCache: Invalidating outdated subagent cache entry (v${entry.version}): ${key}` + ); + this.cache.delete(key); + return undefined; + } + + // Check if entry has expired + const now = Date.now(); + if (now - entry.timestamp > this.ttl) { + this.cache.delete(key); + return undefined; + } + + // Move to end (mark as recently used) + this.cache.delete(key); + this.cache.set(key, entry); + + return entry.value as SubagentDetail; + } + + /** + * Internal method to set a value in the cache. + * Handles LRU eviction and cache entry creation. + */ + private setInternal(key: string, value: CachedValue): void { + if (!this.enabled) { + return; + } + + // If at max size, remove least recently used (first entry) + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey) { + this.cache.delete(firstKey); + } + } + + this.cache.set(key, { + value, + timestamp: Date.now(), + version: DataCache.CURRENT_VERSION, + }); + } + + /** + * Sets a value in the cache. + * @param key - Cache key in format "projectId/sessionId" + * @param value - The SessionDetail to cache + */ + set(key: string, value: SessionDetail): void { + this.setInternal(key, value); + } + + /** + * Sets a subagent detail value in the cache. + * @param key - Cache key in format "subagent-projectId-sessionId-subagentId" + * @param value - The SubagentDetail to cache + */ + setSubagent(key: string, value: SubagentDetail): void { + this.setInternal(key, value); + } + + /** + * Checks if a key exists in the cache and is not expired. + * @param key - Cache key to check + * @returns true if key exists and is valid, false otherwise + */ + has(key: string): boolean { + return this.get(key) !== undefined; + } + + // =========================================================================== + // Key Building + // =========================================================================== + + /** + * Build a cache key from project and session IDs. + */ + static buildKey(projectId: string, sessionId: string): string { + return `${projectId}/${sessionId}`; + } + + /** + * Parse a cache key into project and session IDs. + */ + static parseKey(key: string): { projectId: string; sessionId: string } | null { + const parts = key.split('/'); + if (parts.length !== 2) return null; + return { projectId: parts[0], sessionId: parts[1] }; + } + + // =========================================================================== + // Invalidation + // =========================================================================== + + /** + * Invalidates a specific cache entry. + * @param key - Cache key to invalidate + */ + invalidate(key: string): void { + this.cache.delete(key); + } + + /** + * Invalidates a cache entry by project and session IDs. + */ + invalidateSession(projectId: string, sessionId: string): void { + this.invalidate(DataCache.buildKey(projectId, sessionId)); + this.invalidateSubagentSession(projectId, sessionId); + } + + /** + * Invalidates all cached subagent details for a session. + */ + invalidateSubagentSession(projectId: string, sessionId: string): void { + const prefix = `subagent-${projectId}-${sessionId}-`; + const keysToDelete: string[] = []; + for (const key of this.cache.keys()) { + if (key.startsWith(prefix)) { + keysToDelete.push(key); + } + } + for (const key of keysToDelete) { + this.cache.delete(key); + } + } + + /** + * Invalidates all cache entries for a project. + * @param projectId - The project ID + */ + invalidateProject(projectId: string): void { + const keysToDelete: string[] = []; + + for (const key of this.cache.keys()) { + if (key.startsWith(`${projectId}/`)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.cache.delete(key); + } + } + + /** + * Clears the entire cache. + */ + clear(): void { + this.cache.clear(); + } + + // =========================================================================== + // Cache Management + // =========================================================================== + + /** + * Gets current cache size. + * @returns Number of entries in the cache + */ + size(): number { + return this.cache.size; + } + + /** + * Gets cache statistics. + * @returns Object with cache stats + */ + stats(): { + size: number; + maxSize: number; + ttlMinutes: number; + keys: string[]; + } { + return { + size: this.cache.size, + maxSize: this.maxSize, + ttlMinutes: this.ttl / 60000, + keys: Array.from(this.cache.keys()), + }; + } + + /** + * Removes expired and outdated entries from the cache. + * Should be called periodically to prevent memory bloat. + */ + cleanExpired(): number { + const now = Date.now(); + const keysToDelete: string[] = []; + + for (const [key, entry] of this.cache.entries()) { + // Remove if expired OR outdated version + if (now - entry.timestamp > this.ttl || entry.version !== DataCache.CURRENT_VERSION) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + this.cache.delete(key); + } + + if (keysToDelete.length > 0) { + logger.info(`DataCache: Cleaned ${keysToDelete.length} expired/outdated entries`); + } + + return keysToDelete.length; + } + + /** + * Starts automatic cleanup of expired entries. + * @param intervalMinutes - How often to run cleanup (default: 5 minutes) + * @returns Timer handle that can be used to stop cleanup + */ + startAutoCleanup(intervalMinutes: number = 5): NodeJS.Timeout { + const intervalMs = intervalMinutes * 60 * 1000; + return setInterval(() => { + this.cleanExpired(); + }, intervalMs); + } + + /** + * Gets all cached session IDs for a project. + */ + getProjectSessionIds(projectId: string): string[] { + const sessionIds: string[] = []; + + for (const key of this.cache.keys()) { + if (key.startsWith(`${projectId}/`)) { + const parsed = DataCache.parseKey(key); + if (parsed) { + sessionIds.push(parsed.sessionId); + } + } + } + + return sessionIds; + } +} diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts new file mode 100644 index 00000000..3263db84 --- /dev/null +++ b/src/main/services/infrastructure/FileWatcher.ts @@ -0,0 +1,697 @@ +/** + * FileWatcher service - Watches for changes in Claude Code project files. + * + * Responsibilities: + * - Watch ~/.claude/projects/ directory for session changes + * - Watch ~/.claude/todos/ directory for todo changes + * - Detect new/modified/deleted files + * - Emit events to notify renderer process + * - Invalidate cache entries when files change + * - Detect errors in changed session files and notify NotificationManager + */ + +import { type FileChangeEvent, type ParsedMessage } from '@main/types'; +import { parseJsonlFile, parseJsonlLine } from '@main/utils/jsonl'; +import { getProjectsBasePath, getTodosBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { projectPathResolver } from '../discovery/ProjectPathResolver'; +import { errorDetector } from '../error/ErrorDetector'; + +import { ConfigManager } from './ConfigManager'; +import { type DataCache } from './DataCache'; +import { type NotificationManager } from './NotificationManager'; + +const logger = createLogger('Service:FileWatcher'); + +/** Debounce window for file change events */ +const DEBOUNCE_MS = 100; +/** Retry delay when watched directories are unavailable or watcher errors occur */ +const WATCHER_RETRY_MS = 2000; +/** Interval for periodic catch-up scan to detect missed fs.watch events */ +const CATCH_UP_INTERVAL_MS = 30_000; +/** Only catch-up scan files modified within this window */ +const CATCH_UP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour + +interface AppendedParseResult { + messages: ParsedMessage[]; + parsedLineCount: number; + consumedBytes: number; +} + +interface ActiveSessionFile { + projectId: string; + sessionId: string; + subagentId?: string; +} + +export class FileWatcher extends EventEmitter { + private projectsWatcher: fs.FSWatcher | null = null; + private todosWatcher: fs.FSWatcher | null = null; + private retryTimer: NodeJS.Timeout | null = null; + private projectsPath: string; + private todosPath: string; + private dataCache: DataCache; + private notificationManager: NotificationManager | null = null; + private isWatching: boolean = false; + private debounceTimers = new Map(); + /** Track last processed line count per file for incremental error detection */ + private lastProcessedLineCount = new Map(); + /** Track last processed file size in bytes for append-only parsing optimization */ + private lastProcessedSize = new Map(); + /** Active session files tracked for periodic catch-up scan */ + private activeSessionFiles = new Map(); + /** Timer for periodic catch-up scan */ + private catchUpTimer: NodeJS.Timeout | null = null; + /** Files currently being processed (concurrency guard) */ + private processingInProgress = new Set(); + /** Files that need reprocessing after current processing completes */ + private pendingReprocess = new Set(); + + constructor(dataCache: DataCache, projectsPath?: string, todosPath?: string) { + super(); + this.projectsPath = projectsPath ?? getProjectsBasePath(); + this.todosPath = todosPath ?? getTodosBasePath(); + this.dataCache = dataCache; + } + + /** + * Sets the NotificationManager for error detection integration. + * Must be called before start() to enable error notifications. + */ + setNotificationManager(manager: NotificationManager): void { + this.notificationManager = manager; + } + + // =========================================================================== + // Watcher Control + // =========================================================================== + + /** + * Starts watching the projects and todos directories. + */ + start(): void { + if (this.isWatching) { + logger.warn('Already watching'); + return; + } + + this.isWatching = true; + this.ensureWatchers(); + this.startCatchUpTimer(); + } + + /** + * Stops all watchers. + */ + stop(): void { + this.isWatching = false; + + if (this.retryTimer) { + clearTimeout(this.retryTimer); + this.retryTimer = null; + } + + if (this.projectsWatcher) { + this.projectsWatcher.close(); + this.projectsWatcher = null; + } + + if (this.todosWatcher) { + this.todosWatcher.close(); + this.todosWatcher = null; + } + + // Clear any pending debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + + // Clear catch-up timer + if (this.catchUpTimer) { + clearInterval(this.catchUpTimer); + this.catchUpTimer = null; + } + + // Clear error detection tracking + this.lastProcessedLineCount.clear(); + this.lastProcessedSize.clear(); + this.activeSessionFiles.clear(); + this.processingInProgress.clear(); + this.pendingReprocess.clear(); + + logger.info('Stopped watching'); + } + + /** + * Starts the projects directory watcher. + */ + private startProjectsWatcher(): void { + if (this.projectsWatcher) { + return; + } + + try { + if (!fs.existsSync(this.projectsPath)) { + logger.warn(`FileWatcher: Projects directory does not exist: ${this.projectsPath}`); + this.scheduleWatcherRetry(); + return; + } + + this.projectsWatcher = fs.watch( + this.projectsPath, + { recursive: true }, + (eventType, filename) => { + if (filename) { + this.handleProjectsChange(eventType, filename); + } + } + ); + this.attachWatcherRecovery(this.projectsWatcher, 'projects'); + + logger.info(`FileWatcher: Started watching projects at ${this.projectsPath}`); + } catch (error) { + logger.error('Error starting projects watcher:', error); + this.projectsWatcher = null; + this.scheduleWatcherRetry(); + } + } + + /** + * Starts the todos directory watcher. + */ + private startTodosWatcher(): void { + if (this.todosWatcher) { + return; + } + + try { + if (!fs.existsSync(this.todosPath)) { + // Todos directory may not exist yet - that's OK + this.scheduleWatcherRetry(); + return; + } + + this.todosWatcher = fs.watch(this.todosPath, (eventType, filename) => { + if (filename) { + this.handleTodosChange(eventType, filename); + } + }); + this.attachWatcherRecovery(this.todosWatcher, 'todos'); + + logger.info(`FileWatcher: Started watching todos at ${this.todosPath}`); + } catch (error) { + logger.error('Error starting todos watcher:', error); + this.todosWatcher = null; + this.scheduleWatcherRetry(); + } + } + + private ensureWatchers(): void { + if (!this.isWatching) { + return; + } + + this.startProjectsWatcher(); + this.startTodosWatcher(); + + if (!this.projectsWatcher || !this.todosWatcher) { + this.scheduleWatcherRetry(); + } + } + + private scheduleWatcherRetry(): void { + if (!this.isWatching || this.retryTimer) { + return; + } + + this.retryTimer = setTimeout(() => { + this.retryTimer = null; + this.ensureWatchers(); + }, WATCHER_RETRY_MS); + } + + private attachWatcherRecovery(watcher: fs.FSWatcher, watcherType: 'projects' | 'todos'): void { + watcher.on('error', (error) => { + logger.error(`FileWatcher: ${watcherType} watcher error:`, error); + if (watcherType === 'projects') { + this.projectsWatcher = null; + } else { + this.todosWatcher = null; + } + this.scheduleWatcherRetry(); + }); + + watcher.on('close', () => { + if (!this.isWatching) { + return; + } + if (watcherType === 'projects') { + this.projectsWatcher = null; + } else { + this.todosWatcher = null; + } + this.scheduleWatcherRetry(); + }); + } + + // =========================================================================== + // Event Handling + // =========================================================================== + + /** + * Handles file change events in the projects directory. + */ + private handleProjectsChange(eventType: string, filename: string): void { + try { + // Ignore non-JSONL files + if (!filename.endsWith('.jsonl')) { + return; + } + + // Debounce rapid changes to the same file + this.debounce(filename, () => this.processProjectsChange(eventType, filename)); + } catch (error) { + logger.error('Error handling projects change:', error); + } + } + + /** + * Process a debounced projects change. + */ + private processProjectsChange(eventType: string, filename: string): void { + const parts = filename.split(path.sep); + const projectId = parts[0]; + + if (!projectId) return; + + const fullPath = path.join(this.projectsPath, filename); + const fileExists = fs.existsSync(fullPath); + + // Determine change type + let changeType: FileChangeEvent['type']; + if (eventType === 'rename') { + changeType = fileExists ? 'add' : 'unlink'; + } else { + changeType = 'change'; + } + + // Parse session ID and check if it's a subagent + let sessionId: string | undefined; + let isSubagent = false; + + // Session file at project root: projectId/sessionId.jsonl + if (parts.length === 2) { + sessionId = path.basename(parts[1], '.jsonl'); + } + // Subagent file: projectId/sessionId/subagents/agent-hash.jsonl + else if (parts.length === 4 && parts[2] === 'subagents') { + sessionId = parts[1]; + isSubagent = true; + } + + if (sessionId) { + // Invalidate cache + this.dataCache.invalidateSession(projectId, sessionId); + projectPathResolver.invalidateProject(projectId); + if (changeType === 'unlink') { + this.clearErrorTracking(fullPath); + } + + // Emit event + const event: FileChangeEvent = { + type: changeType, + path: fullPath, + projectId, + sessionId, + isSubagent, + }; + + this.emit('file-change', event); + logger.info( + `FileWatcher: ${changeType} ${isSubagent ? 'subagent' : 'session'} - ${filename}` + ); + + // Detect errors in changed session files (not deleted files) + if (changeType !== 'unlink' && this.notificationManager) { + if (isSubagent) { + // Only process subagent files if config allows + const config = ConfigManager.getInstance().getConfig(); + if (config.notifications.includeSubagentErrors) { + const subagentFilename = path.basename(parts[3], '.jsonl'); + const subagentId = subagentFilename.replace(/^agent-/, ''); + this.activeSessionFiles.set(fullPath, { projectId, sessionId, subagentId }); + this.detectErrorsInSessionFile(projectId, sessionId, fullPath, subagentId).catch( + (err) => { + logger.error('Error detecting errors in subagent file:', err); + } + ); + } + } else { + this.activeSessionFiles.set(fullPath, { projectId, sessionId }); + this.detectErrorsInSessionFile(projectId, sessionId, fullPath).catch((err) => { + logger.error('Error detecting errors in session file:', err); + }); + } + } + } + } + + // =========================================================================== + // Error Detection + // =========================================================================== + + /** + * Detects errors in a session file and sends notifications. + * Uses incremental processing to only check new lines since last check. + */ + private async detectErrorsInSessionFile( + projectId: string, + sessionId: string, + filePath: string, + subagentId?: string + ): Promise { + if (!this.notificationManager) { + return; + } + + // Concurrency guard: if already processing this file, mark for reprocessing + if (this.processingInProgress.has(filePath)) { + this.pendingReprocess.add(filePath); + return; + } + + this.processingInProgress.add(filePath); + try { + // Get the last processed line count for this file + const lastLineCount = this.lastProcessedLineCount.get(filePath) ?? 0; + const lastSize = this.lastProcessedSize.get(filePath) ?? 0; + const fileStats = await fs.promises.stat(filePath); + const currentSize = fileStats.size; + + // Fast path: no size change means no new data + if (currentSize === lastSize && lastLineCount > 0) { + return; + } + + const canUseIncrementalAppend = lastLineCount > 0 && currentSize > lastSize; + let newMessages: ParsedMessage[] = []; + let currentLineCount: number; + let processedSize: number; + + if (canUseIncrementalAppend) { + const appended = await this.parseAppendedMessages(filePath, lastSize); + newMessages = appended.messages; + currentLineCount = lastLineCount + appended.parsedLineCount; + processedSize = lastSize + appended.consumedBytes; + } else { + // Fallback for first-read, truncation, or rewrite scenarios + const messages = await parseJsonlFile(filePath); + currentLineCount = messages.length; + newMessages = messages.slice(lastLineCount); + // Re-stat after full parse to capture bytes written during the parse + const postParseStats = await fs.promises.stat(filePath); + processedSize = postParseStats.size; + } + + // If no new lines, skip processing + if (currentLineCount <= lastLineCount) { + this.lastProcessedSize.set(filePath, processedSize); + return; + } + + // Detect errors in new messages + // Note: We pass the offset-adjusted line numbers to errorDetector + const errors = await errorDetector.detectErrors(newMessages, sessionId, projectId, filePath); + + // Adjust line numbers to account for the offset and annotate subagent errors + for (const error of errors) { + if (error.lineNumber !== undefined) { + error.lineNumber = error.lineNumber + lastLineCount; + } + if (subagentId) { + error.subagentId = subagentId; + } + } + + // Notify for each detected error + for (const error of errors) { + await this.notificationManager.addError(error); + } + + // Update the last processed line count + this.lastProcessedLineCount.set(filePath, currentLineCount); + this.lastProcessedSize.set(filePath, processedSize); + + if (errors.length > 0) { + logger.info(`FileWatcher: Detected ${errors.length} errors in ${filePath}`); + } + } catch (err) { + logger.error(`FileWatcher: Error processing session file for errors: ${filePath}`, err); + } finally { + this.processingInProgress.delete(filePath); + + // If a reprocess was requested while we were processing, run again + if (this.pendingReprocess.has(filePath)) { + this.pendingReprocess.delete(filePath); + this.detectErrorsInSessionFile(projectId, sessionId, filePath, subagentId).catch((e) => { + logger.error('Error during reprocessing of session file:', e); + }); + } + } + } + + /** + * Clears the error detection tracking for a specific file. + * Call this when a file is deleted or to force re-processing. + */ + clearErrorTracking(filePath: string): void { + this.lastProcessedLineCount.delete(filePath); + this.lastProcessedSize.delete(filePath); + this.activeSessionFiles.delete(filePath); + } + + /** + * Clears all error detection tracking. + */ + clearAllErrorTracking(): void { + this.lastProcessedLineCount.clear(); + this.lastProcessedSize.clear(); + this.activeSessionFiles.clear(); + } + + /** + * Parse only newly appended JSONL lines from the given byte offset. + */ + private async parseAppendedMessages( + filePath: string, + startOffset: number + ): Promise { + const parsedMessages: ParsedMessage[] = []; + const stream = fs.createReadStream(filePath, { start: startOffset, encoding: 'utf8' }); + + let buffer = ''; + let consumedBytes = 0; + let parsedLineCount = 0; + for await (const chunk of stream) { + buffer += chunk; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const rawLine of lines) { + consumedBytes += Buffer.byteLength(`${rawLine}\n`, 'utf8'); + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine; + if (!line.trim()) { + continue; + } + try { + const parsed = parseJsonlLine(line); + if (parsed) { + parsedMessages.push(parsed); + parsedLineCount++; + } + } catch { + // Ignore malformed appended lines; full parse path will recover on next rewrite. + } + } + } + + // Handle final line without trailing newline + if (buffer.trim()) { + try { + const parsed = parseJsonlLine(buffer); + if (parsed) { + parsedMessages.push(parsed); + parsedLineCount++; + consumedBytes += Buffer.byteLength(buffer, 'utf8'); + } + } catch { + // Keep offset pinned until this trailing partial becomes a complete line. + } + } + + return { + messages: parsedMessages, + parsedLineCount, + consumedBytes, + }; + } + + /** + * Handles file change events in the todos directory. + */ + private handleTodosChange(eventType: string, filename: string): void { + try { + // Only handle JSON files + if (!filename.endsWith('.json')) { + return; + } + + // Debounce rapid changes + this.debounce(`todos/${filename}`, () => this.processTodosChange(eventType, filename)); + } catch (error) { + logger.error('Error handling todos change:', error); + } + } + + /** + * Process a debounced todos change. + */ + private processTodosChange(eventType: string, filename: string): void { + // Session ID is the filename without extension + const sessionId = path.basename(filename, '.json'); + const fullPath = path.join(this.todosPath, filename); + const fileExists = fs.existsSync(fullPath); + + // Determine change type + let changeType: FileChangeEvent['type']; + if (eventType === 'rename') { + changeType = fileExists ? 'add' : 'unlink'; + } else { + changeType = 'change'; + } + + // Emit event (we don't have projectId for todos) + const event: FileChangeEvent = { + type: changeType, + path: fullPath, + sessionId, + isSubagent: false, + }; + + this.emit('todo-change', event); + logger.info(`FileWatcher: ${changeType} todo - ${filename}`); + } + + // =========================================================================== + // Catch-Up Scan + // =========================================================================== + + /** + * Starts the periodic catch-up timer to detect file growth missed by fs.watch. + * FSEvents on macOS can coalesce, delay, or drop events. This timer polls + * tracked active session files every CATCH_UP_INTERVAL_MS to detect unprocessed growth. + */ + private startCatchUpTimer(): void { + if (this.catchUpTimer) { + return; + } + + this.catchUpTimer = setInterval(() => { + this.runCatchUpScan().catch((err) => { + logger.error('Error during catch-up scan:', err); + }); + }, CATCH_UP_INTERVAL_MS); + } + + /** + * Scans active session files for unprocessed growth. + * Only checks files modified within the last hour. + */ + private async runCatchUpScan(): Promise { + if (!this.notificationManager || this.activeSessionFiles.size === 0) { + return; + } + + const now = Date.now(); + + for (const [filePath, info] of this.activeSessionFiles) { + try { + const stats = await fs.promises.stat(filePath); + + // Skip files not modified recently + if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) { + this.activeSessionFiles.delete(filePath); + continue; + } + + const lastSize = this.lastProcessedSize.get(filePath) ?? 0; + if (stats.size > lastSize) { + logger.info(`FileWatcher: Catch-up scan detected growth in ${filePath}`); + await this.detectErrorsInSessionFile( + info.projectId, + info.sessionId, + filePath, + info.subagentId + ); + } + } catch (err) { + // File may have been deleted between iterations + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.activeSessionFiles.delete(filePath); + this.clearErrorTracking(filePath); + } else { + logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err); + } + } + } + } + + // =========================================================================== + // Debouncing + // =========================================================================== + + /** + * Debounce a function call for a specific key. + */ + private debounce(key: string, fn: () => void): void { + // Clear existing timer for this key + const existingTimer = this.debounceTimers.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + + // Set new timer + const timer = setTimeout(() => { + this.debounceTimers.delete(key); + fn(); + }, DEBOUNCE_MS); + + this.debounceTimers.set(key, timer); + } + + // =========================================================================== + // Status + // =========================================================================== + + /** + * Returns whether the watcher is currently active. + */ + isActive(): boolean { + return this.isWatching; + } + + /** + * Returns watched paths. + */ + getWatchedPaths(): { projects: string; todos: string } { + return { + projects: this.projectsPath, + todos: this.todosPath, + }; + } +} diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts new file mode 100644 index 00000000..c37f1f0b --- /dev/null +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -0,0 +1,657 @@ +/** + * NotificationManager service - Manages native macOS notifications and error history. + * + * Responsibilities: + * - Store error history at ~/.claude/claude-code-context-notifications.json (max 100 entries) + * - Show native macOS notifications using Electron's Notification API + * - Implement throttling (5 seconds per unique error hash) + * - Respect config.notifications.enabled and snoozedUntil + * - Filter errors matching ignoredRegex patterns + * - Filter errors from ignoredProjects + * - Auto-prune notifications over 100 on startup + * - Emit IPC events to renderer: notification:new, notification:updated + */ + +import { createLogger } from '@shared/utils/logger'; +import { type BrowserWindow, Notification } from 'electron'; +import { EventEmitter } from 'events'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { type DetectedError } from '../error/ErrorMessageBuilder'; + +const logger = createLogger('Service:NotificationManager'); +import { projectPathResolver } from '../discovery/ProjectPathResolver'; +import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; + +import { ConfigManager } from './ConfigManager'; + +// Re-export DetectedError for backward compatibility +export type { DetectedError }; + +/** + * Stored notification with read status. + */ +export interface StoredNotification extends DetectedError { + /** Whether the notification has been read */ + isRead: boolean; + /** When the notification was created (may differ from error timestamp) */ + createdAt: number; +} + +/** + * Pagination options for getNotifications. + */ +export interface GetNotificationsOptions { + /** Number of notifications to return */ + limit?: number; + /** Number of notifications to skip */ + offset?: number; +} + +/** + * Result of getNotifications call. + */ +export interface GetNotificationsResult { + /** Notifications for this page */ + notifications: StoredNotification[]; + /** Total number of notifications */ + total: number; + /** Total count (alias for IPC compatibility) */ + totalCount: number; + /** Number of unread notifications */ + unreadCount: number; + /** Whether there are more notifications to load */ + hasMore: boolean; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** Maximum number of notifications to store */ +const MAX_NOTIFICATIONS = 100; + +/** Throttle window in milliseconds (5 seconds) */ +const THROTTLE_MS = 5000; + +/** Path to notifications storage file */ +const NOTIFICATIONS_PATH = path.join( + os.homedir(), + '.claude', + 'claude-code-context-notifications.json' +); + +// ============================================================================= +// NotificationManager Class +// ============================================================================= + +export class NotificationManager extends EventEmitter { + private static instance: NotificationManager | null = null; + private notifications: StoredNotification[] = []; + private configManager: ConfigManager; + private mainWindow: BrowserWindow | null = null; + private throttleMap = new Map(); + private isInitialized: boolean = false; + + constructor(configManager?: ConfigManager) { + super(); + this.configManager = configManager ?? ConfigManager.getInstance(); + } + + // =========================================================================== + // Singleton Pattern + // =========================================================================== + + /** + * Gets the singleton instance of NotificationManager. + */ + static getInstance(): NotificationManager { + if (!NotificationManager.instance) { + NotificationManager.instance = new NotificationManager(); + NotificationManager.instance.initialize(); + } + return NotificationManager.instance; + } + + /** + * Resets the singleton instance (useful for testing). + */ + static resetInstance(): void { + NotificationManager.instance = null; + } + + /** + * Sets the singleton instance (useful for dependency injection). + */ + static setInstance(instance: NotificationManager): void { + NotificationManager.instance = instance; + } + + // =========================================================================== + // Initialization + // =========================================================================== + + /** + * Initializes the notification manager. + * Loads existing notifications and prunes if needed. + */ + initialize(): void { + if (this.isInitialized) { + return; + } + + this.loadNotifications(); + this.pruneNotifications(); + this.isInitialized = true; + + logger.info(`NotificationManager: Initialized with ${this.notifications.length} notifications`); + } + + /** + * Sets the main window reference for sending IPC events. + */ + setMainWindow(window: BrowserWindow | null): void { + this.mainWindow = window; + } + + // =========================================================================== + // Persistence + // =========================================================================== + + /** + * Loads notifications from disk. + */ + private loadNotifications(): void { + try { + if (fs.existsSync(NOTIFICATIONS_PATH)) { + const data = fs.readFileSync(NOTIFICATIONS_PATH, 'utf8'); + const parsed = JSON.parse(data) as unknown; + + if (Array.isArray(parsed)) { + this.notifications = parsed as StoredNotification[]; + } else { + logger.warn('Invalid notifications file format, starting fresh'); + this.notifications = []; + } + } + } catch (error) { + logger.error('Error loading notifications:', error); + this.notifications = []; + } + } + + /** + * Saves notifications to disk. + */ + private saveNotifications(): void { + try { + // Ensure directory exists + const dir = path.dirname(NOTIFICATIONS_PATH); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(NOTIFICATIONS_PATH, JSON.stringify(this.notifications, null, 2), 'utf8'); + } catch (error) { + logger.error('Error saving notifications:', error); + } + } + + /** + * Prunes notifications to MAX_NOTIFICATIONS entries. + * Removes oldest notifications first. + */ + private pruneNotifications(): void { + if (this.notifications.length > MAX_NOTIFICATIONS) { + // Sort by createdAt descending (newest first) + this.notifications.sort((a, b) => b.createdAt - a.createdAt); + + // Keep only the newest MAX_NOTIFICATIONS + const removed = this.notifications.length - MAX_NOTIFICATIONS; + this.notifications = this.notifications.slice(0, MAX_NOTIFICATIONS); + this.saveNotifications(); + + logger.info(`NotificationManager: Pruned ${removed} old notifications`); + } + } + + // =========================================================================== + // Error Filtering + // =========================================================================== + + /** + * Generates a unique hash for throttling based on projectId + message. + */ + private generateErrorHash(error: DetectedError): string { + return `${error.projectId}:${error.message}`; + } + + /** + * Checks if an error should be throttled. + */ + private isThrottled(error: DetectedError): boolean { + const hash = this.generateErrorHash(error); + const lastSeen = this.throttleMap.get(hash); + + if (lastSeen && Date.now() - lastSeen < THROTTLE_MS) { + return true; + } + + // Update throttle map + this.throttleMap.set(hash, Date.now()); + + // Clean up old entries periodically + this.cleanupThrottleMap(); + + return false; + } + + /** + * Cleans up old entries from the throttle map. + */ + private cleanupThrottleMap(): void { + const now = Date.now(); + const expiredThreshold = now - THROTTLE_MS * 2; + + const keysToDelete: string[] = []; + this.throttleMap.forEach((timestamp, hash) => { + if (timestamp < expiredThreshold) { + keysToDelete.push(hash); + } + }); + + for (const key of keysToDelete) { + this.throttleMap.delete(key); + } + } + + /** + * Checks if notifications are currently enabled based on config. + */ + private areNotificationsEnabled(): boolean { + const config = this.configManager.getConfig(); + + // Check if notifications are globally disabled + if (!config.notifications.enabled) { + return false; + } + + // Check if notifications are snoozed + if (config.notifications.snoozedUntil) { + if (Date.now() < config.notifications.snoozedUntil) { + return false; + } else { + // Snooze has expired, clear it + this.configManager.clearSnooze(); + } + } + + return true; + } + + /** + * Checks if an error matches any ignored regex patterns. + */ + private matchesIgnoredRegex(error: DetectedError): boolean { + const config = this.configManager.getConfig(); + const patterns = config.notifications.ignoredRegex; + + if (!patterns || patterns.length === 0) { + return false; + } + + for (const pattern of patterns) { + try { + const regex = new RegExp(pattern, 'i'); + if (regex.test(error.message)) { + return true; + } + } catch { + // Invalid regex pattern, skip + logger.warn(`NotificationManager: Invalid regex pattern: ${pattern}`); + } + } + + return false; + } + + /** + * Checks if the error is from an ignored repository. + * Resolves the project path to a repository ID and checks against ignored list. + */ + private async isFromIgnoredRepository(error: DetectedError): Promise { + const config = this.configManager.getConfig(); + const ignoredRepositories = config.notifications.ignoredRepositories; + + if (!ignoredRepositories || ignoredRepositories.length === 0) { + return false; + } + + // Resolve project ID to repository ID using canonical path resolution. + const projectPath = await projectPathResolver.resolveProjectPath(error.projectId, { + cwdHint: error.context.cwd, + }); + const identity = await gitIdentityResolver.resolveIdentity(projectPath); + + if (!identity) { + return false; + } + + return ignoredRepositories.includes(identity.id); + } + + /** + * Determines if an error should generate a notification. + */ + private async shouldNotify(error: DetectedError): Promise { + // Check if notifications are enabled + if (!this.areNotificationsEnabled()) { + return false; + } + + // Check if error is from an ignored repository + if (await this.isFromIgnoredRepository(error)) { + return false; + } + + // Check if error matches an ignored regex + if (this.matchesIgnoredRegex(error)) { + return false; + } + + // Check throttling (for native toast dedup only — storage is unconditional) + if (this.isThrottled(error)) { + return false; + } + + return true; + } + + // =========================================================================== + // Native Notifications + // =========================================================================== + + /** + * Shows a native macOS notification for an error. + */ + private showNativeNotification(error: DetectedError): void { + // Check if Notification is supported + if (!Notification.isSupported()) { + logger.warn('Native notifications not supported'); + return; + } + + const config = this.configManager.getConfig(); + + const notification = new Notification({ + title: 'Claude Code Error', + subtitle: error.context.projectName, + body: error.message.slice(0, 200), + sound: config.notifications.soundEnabled ? 'default' : undefined, + }); + + notification.on('click', () => { + // Focus app window + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.show(); + this.mainWindow.focus(); + + // Send deep link to renderer + this.mainWindow.webContents.send('notification:clicked', error); + } + + // Emit event for other listeners + this.emit('notification-clicked', error); + }); + + notification.show(); + } + + // =========================================================================== + // IPC Event Emission + // =========================================================================== + + /** + * Emits a notification:new event to the renderer. + */ + private emitNewNotification(notification: StoredNotification): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('notification:new', notification); + } + + this.emit('notification-new', notification); + } + + /** + * Emits a notification:updated event to the renderer. + */ + private emitNotificationUpdated(): void { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send('notification:updated', { + total: this.notifications.length, + unreadCount: this.getUnreadCountSync(), + }); + } + + this.emit('notification-updated', { + total: this.notifications.length, + unreadCount: this.getUnreadCountSync(), + }); + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Adds an error and shows a notification if enabled. + * @param error - The detected error to add + * @returns The stored notification, or null if filtered/throttled + */ + async addError(error: DetectedError): Promise { + // Deduplicate by toolUseId: the same tool call can appear in both the + // subagent JSONL file and the parent session JSONL (as a progress event). + // Keep the subagent-annotated version (with subagentId) when possible. + if (error.toolUseId) { + const existingIndex = this.notifications.findIndex((n) => n.toolUseId === error.toolUseId); + if (existingIndex !== -1) { + const existing = this.notifications[existingIndex]; + if (!existing.subagentId && error.subagentId) { + // Replace: prefer the subagent-annotated version + this.notifications.splice(existingIndex, 1); + } else { + // Already have a (better or equal) version — skip + return null; + } + } + } + + const storedNotification: StoredNotification = { + ...error, + isRead: false, + createdAt: Date.now(), + }; + + // Add to the beginning of the list (newest first) + this.notifications.unshift(storedNotification); + + // Prune if needed + this.pruneNotifications(); + + // Save to disk + this.saveNotifications(); + + // Emit new notification event + this.emitNewNotification(storedNotification); + // Emit authoritative counters (total/unread) so renderer badge stays in sync. + this.emitNotificationUpdated(); + + // Show native notification if enabled and not filtered + if (await this.shouldNotify(error)) { + this.showNativeNotification(error); + } + + return storedNotification; + } + + /** + * Gets a paginated list of notifications. + * @param options - Pagination options + * @returns Paginated notifications result + */ + async getNotifications(options?: GetNotificationsOptions): Promise { + const limit = options?.limit ?? 20; + const offset = options?.offset ?? 0; + + // Notifications are already sorted newest first + const notifications = this.notifications.slice(offset, offset + limit); + const total = this.notifications.length; + const hasMore = offset + notifications.length < total; + + return { + notifications, + total, + totalCount: total, + unreadCount: this.getUnreadCountSync(), + hasMore, + }; + } + + /** + * Marks a notification as read. + * @param id - The notification ID to mark as read + * @returns true if found and marked, false otherwise + */ + async markRead(id: string): Promise { + const notification = this.notifications.find((n) => n.id === id); + + if (!notification) { + return false; + } + + if (!notification.isRead) { + notification.isRead = true; + this.saveNotifications(); + this.emitNotificationUpdated(); + } + + return true; + } + + /** + * Marks all notifications as read. + * @returns true on success + */ + async markAllRead(): Promise { + let changed = false; + + for (const notification of this.notifications) { + if (!notification.isRead) { + notification.isRead = true; + changed = true; + } + } + + if (changed) { + this.saveNotifications(); + this.emitNotificationUpdated(); + } + + return true; + } + + /** + * Clears all notifications. + */ + clear(): void { + this.notifications = []; + this.saveNotifications(); + this.emitNotificationUpdated(); + } + + /** + * Clears all notifications (async version for IPC). + * @returns true on success + */ + async clearAll(): Promise { + this.clear(); + return true; + } + + /** + * Gets the count of unread notifications. + * @returns Number of unread notifications (Promise for IPC compatibility) + */ + async getUnreadCount(): Promise { + return this.notifications.filter((n) => !n.isRead).length; + } + + /** + * Gets the count of unread notifications (sync version). + * @returns Number of unread notifications + */ + getUnreadCountSync(): number { + return this.notifications.filter((n) => !n.isRead).length; + } + + /** + * Gets a specific notification by ID. + * @param id - The notification ID + * @returns The notification or undefined if not found + */ + getNotification(id: string): StoredNotification | undefined { + return this.notifications.find((n) => n.id === id); + } + + /** + * Deletes a specific notification. + * @param id - The notification ID to delete + * @returns true if found and deleted, false otherwise + */ + deleteNotification(id: string): boolean { + const index = this.notifications.findIndex((n) => n.id === id); + + if (index === -1) { + return false; + } + + this.notifications.splice(index, 1); + this.saveNotifications(); + this.emitNotificationUpdated(); + + return true; + } + + // =========================================================================== + // Stats + // =========================================================================== + + /** + * Gets statistics about notifications. + */ + getStats(): { + total: number; + unread: number; + byProject: Record; + bySource: Record; + } { + const byProject: Record = {}; + const bySource: Record = {}; + + for (const notification of this.notifications) { + const projectName = notification.context.projectName; + byProject[projectName] = (byProject[projectName] || 0) + 1; + + bySource[notification.source] = (bySource[notification.source] || 0) + 1; + } + + return { + total: this.notifications.length, + unread: this.getUnreadCountSync(), + byProject, + bySource, + }; + } +} diff --git a/src/main/services/infrastructure/TriggerManager.ts b/src/main/services/infrastructure/TriggerManager.ts new file mode 100644 index 00000000..3b781382 --- /dev/null +++ b/src/main/services/infrastructure/TriggerManager.ts @@ -0,0 +1,318 @@ +/** + * TriggerManager - Manages notification triggers. + * + * Handles CRUD operations for notification triggers including: + * - Adding, updating, and removing triggers + * - Validating trigger configurations (with ReDoS protection) + * - Managing builtin vs custom triggers + */ + +import { validateRegexPattern } from '@main/utils/regexValidation'; + +import type { NotificationTrigger } from './ConfigManager'; + +// =========================================================================== +// Types +// =========================================================================== + +export interface TriggerValidationResult { + valid: boolean; + errors: string[]; +} + +// =========================================================================== +// Default Triggers +// =========================================================================== + +/** + * Default built-in notification triggers. + */ +export const DEFAULT_TRIGGERS: NotificationTrigger[] = [ + { + id: 'builtin-tool-result-error', + name: 'Tool Result Error', + enabled: true, + contentType: 'tool_result', + mode: 'error_status', + requireError: true, + ignorePatterns: [ + "The user doesn't want to proceed with this tool use\\.", + '\\[Request interrupted by user for tool use\\]', + ], + isBuiltin: true, + color: 'red', + }, + { + id: 'builtin-bash-command', + name: '.env File Access Alert', + enabled: true, + contentType: 'tool_use', + toolName: 'Bash', + mode: 'content_match', + matchField: 'command', + matchPattern: '/.env', + isBuiltin: true, + color: 'red', + }, + { + id: 'builtin-high-token-usage', + name: 'High Token Usage', + enabled: true, + contentType: 'tool_result', + mode: 'token_threshold', + tokenThreshold: 8000, + tokenType: 'total', + color: 'orange', + isBuiltin: true, + }, +]; + +// =========================================================================== +// TriggerManager Class +// =========================================================================== + +export class TriggerManager { + private triggers: NotificationTrigger[]; + private readonly onSave: () => void; + + constructor(triggers: NotificationTrigger[], onSave: () => void) { + this.triggers = triggers; + this.onSave = onSave; + } + + // =========================================================================== + // CRUD Operations + // =========================================================================== + + /** + * Gets all notification triggers. + */ + getAll(): NotificationTrigger[] { + return this.deepClone(this.triggers); + } + + /** + * Gets enabled notification triggers only. + */ + getEnabled(): NotificationTrigger[] { + return this.deepClone(this.triggers.filter((t) => t.enabled)); + } + + /** + * Gets a trigger by ID. + */ + getById(triggerId: string): NotificationTrigger | undefined { + const trigger = this.triggers.find((t) => t.id === triggerId); + return trigger ? this.deepClone(trigger) : undefined; + } + + /** + * Adds a new notification trigger. + * @throws Error if trigger with same ID already exists + */ + add(trigger: NotificationTrigger): NotificationTrigger[] { + // Check if trigger with same ID already exists + if (this.triggers.some((t) => t.id === trigger.id)) { + throw new Error(`Trigger with ID "${trigger.id}" already exists`); + } + + // Validate trigger + const validation = this.validate(trigger); + if (!validation.valid) { + throw new Error(`Invalid trigger: ${validation.errors.join(', ')}`); + } + + this.triggers = [...this.triggers, trigger]; + this.onSave(); + return this.getAll(); + } + + /** + * Updates an existing notification trigger. + * @throws Error if trigger not found + */ + update(triggerId: string, updates: Partial): NotificationTrigger[] { + const index = this.triggers.findIndex((t) => t.id === triggerId); + + if (index === -1) { + throw new Error(`Trigger with ID "${triggerId}" not found`); + } + + // Extract allowedUpdates without isBuiltin (which cannot be changed) + const allowedUpdates = Object.fromEntries( + Object.entries(updates).filter(([key]) => key !== 'isBuiltin') + ) as Partial; + + const updated = { ...this.triggers[index], ...allowedUpdates }; + + // Ensure mode is set (for backward compatibility with old triggers) + if (!updated.mode) { + updated.mode = this.inferMode(updated); + } + + // Validate updated trigger + const validation = this.validate(updated); + if (!validation.valid) { + throw new Error(`Invalid trigger update: ${validation.errors.join(', ')}`); + } + + this.triggers = this.triggers.map((t, i) => (i === index ? updated : t)); + this.onSave(); + return this.getAll(); + } + + /** + * Infers trigger mode from trigger properties for backward compatibility. + */ + private inferMode( + trigger: Partial + ): 'error_status' | 'content_match' | 'token_threshold' { + if (trigger.requireError) return 'error_status'; + if (trigger.matchPattern || trigger.matchField) return 'content_match'; + if (trigger.tokenThreshold !== undefined) return 'token_threshold'; + return 'error_status'; // default fallback + } + + /** + * Removes a notification trigger. + * Built-in triggers cannot be removed. + * @throws Error if trigger not found or is builtin + */ + remove(triggerId: string): NotificationTrigger[] { + const trigger = this.triggers.find((t) => t.id === triggerId); + + if (!trigger) { + throw new Error(`Trigger with ID "${triggerId}" not found`); + } + + if (trigger.isBuiltin) { + throw new Error('Cannot remove built-in triggers. Disable them instead.'); + } + + this.triggers = this.triggers.filter((t) => t.id !== triggerId); + this.onSave(); + return this.getAll(); + } + + // =========================================================================== + // Validation + // =========================================================================== + + /** + * Validates a trigger configuration. + */ + validate(trigger: NotificationTrigger): TriggerValidationResult { + const errors: string[] = []; + + // Required fields + if (!trigger.id || trigger.id.trim() === '') { + errors.push('Trigger ID is required'); + } + + if (!trigger.name || trigger.name.trim() === '') { + errors.push('Trigger name is required'); + } + + if (!trigger.contentType) { + errors.push('Content type is required'); + } + + if (!trigger.mode) { + errors.push('Trigger mode is required'); + } + + // Mode-specific validation + if (trigger.mode === 'content_match') { + // matchField is required unless it's tool_use with "Any Tool" (no toolName) + // In that case, we match against the entire JSON input + if (!trigger.matchField && !(trigger.contentType === 'tool_use' && !trigger.toolName)) { + errors.push('Match field is required for content_match mode'); + } + // Validate regex pattern if provided (with ReDoS protection) + if (trigger.matchPattern) { + const validation = validateRegexPattern(trigger.matchPattern); + if (!validation.valid) { + errors.push(validation.error ?? 'Invalid regex pattern'); + } + } + } + + if (trigger.mode === 'token_threshold') { + if (trigger.tokenThreshold === undefined || trigger.tokenThreshold < 0) { + errors.push('Token threshold must be a non-negative number'); + } + if (!trigger.tokenType) { + errors.push('Token type is required for token_threshold mode'); + } + } + + // Validate ignore patterns (with ReDoS protection) + if (trigger.ignorePatterns) { + for (const pattern of trigger.ignorePatterns) { + const validation = validateRegexPattern(pattern); + if (!validation.valid) { + errors.push( + `Invalid ignore pattern "${pattern}": ${validation.error ?? 'Unknown error'}` + ); + } + } + } + + return { + valid: errors.length === 0, + errors, + }; + } + + // =========================================================================== + // Trigger Merging + // =========================================================================== + + /** + * Merges loaded triggers with default triggers. + * - Preserves all existing triggers (including user-modified builtin triggers) + * - Adds any missing builtin triggers from defaults + * - Removes deprecated builtin triggers that are no longer in defaults + */ + static mergeTriggers( + loaded: NotificationTrigger[], + defaults: NotificationTrigger[] = DEFAULT_TRIGGERS + ): NotificationTrigger[] { + // Get IDs of current builtin triggers + const builtinIds = new Set(defaults.filter((t) => t.isBuiltin).map((t) => t.id)); + + // Filter out deprecated builtin triggers (builtin triggers not in current defaults) + const filtered = loaded.filter((t) => !t.isBuiltin || builtinIds.has(t.id)); + + // Add any missing builtin triggers from defaults + for (const defaultTrigger of defaults) { + if (defaultTrigger.isBuiltin) { + const existsInFiltered = filtered.some((t) => t.id === defaultTrigger.id); + if (!existsInFiltered) { + filtered.push(defaultTrigger); + } + } + } + + return filtered; + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Updates the internal triggers array. + * Used by ConfigManager when loading config. + */ + setTriggers(triggers: NotificationTrigger[]): void { + this.triggers = triggers; + } + + /** + * Deep clones an object. + */ + private deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) as T; + } +} diff --git a/src/main/services/infrastructure/index.ts b/src/main/services/infrastructure/index.ts new file mode 100644 index 00000000..8a928c7d --- /dev/null +++ b/src/main/services/infrastructure/index.ts @@ -0,0 +1,16 @@ +/** + * Infrastructure services - Core application infrastructure. + * + * Exports: + * - DataCache: LRU cache with TTL for parsed session data + * - FileWatcher: Watches for file changes with debouncing + * - ConfigManager: App configuration management + * - TriggerManager: Notification trigger management (used internally by ConfigManager) + * - NotificationManager: Notification handling and persistence + */ + +export * from './ConfigManager'; +export * from './DataCache'; +export * from './FileWatcher'; +export * from './NotificationManager'; +export * from './TriggerManager'; diff --git a/src/main/services/parsing/ClaudeMdReader.ts b/src/main/services/parsing/ClaudeMdReader.ts new file mode 100644 index 00000000..6c0ca859 --- /dev/null +++ b/src/main/services/parsing/ClaudeMdReader.ts @@ -0,0 +1,293 @@ +/** + * ClaudeMdReader service - Reads CLAUDE.md files and calculates token counts. + * + * Responsibilities: + * - Read CLAUDE.md files from various locations + * - Calculate character counts and estimate token counts + * - Handle file not found gracefully + * - Support tilde (~) expansion to home directory + */ + +import { encodePath } from '@main/utils/pathDecoder'; +import { countTokens } from '@main/utils/tokenizer'; +import { createLogger } from '@shared/utils/logger'; +import { app } from 'electron'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('Service:ClaudeMdReader'); + +// =========================================================================== +// Types +// =========================================================================== + +export interface ClaudeMdFileInfo { + path: string; + exists: boolean; + charCount: number; + estimatedTokens: number; // charCount / 4 +} + +export interface ClaudeMdReadResult { + files: Map; +} + +// =========================================================================== +// Helper Functions +// =========================================================================== + +/** + * Expands tilde (~) in a path to the actual home directory. + * @param filePath - Path that may contain ~ + * @returns Expanded path with ~ replaced by home directory + */ +function expandTilde(filePath: string): string { + if (filePath.startsWith('~')) { + const homeDir = app.getPath('home'); + return path.join(homeDir, filePath.slice(1)); + } + return filePath; +} + +// =========================================================================== +// Main Functions +// =========================================================================== + +/** + * Reads a single CLAUDE.md file and returns its info. + * @param filePath - Path to the CLAUDE.md file (supports ~ expansion) + * @returns ClaudeMdFileInfo with file details + */ +function readClaudeMdFile(filePath: string): ClaudeMdFileInfo { + const expandedPath = expandTilde(filePath); + + try { + if (!fs.existsSync(expandedPath)) { + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } + + const content = fs.readFileSync(expandedPath, 'utf8'); + const charCount = content.length; + const estimatedTokens = countTokens(content); + + return { + path: expandedPath, + exists: true, + charCount, + estimatedTokens, + }; + } catch (error) { + // Handle permission denied, file not readable, etc. + logger.error(`Error reading CLAUDE.md file at ${expandedPath}:`, error); + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } +} + +/** + * Reads all .md files in a directory and returns combined info. + * Used for project rules directory. + * @param dirPath - Path to the directory (supports ~ expansion) + * @returns ClaudeMdFileInfo with combined stats from all .md files + */ +function readDirectoryMdFiles(dirPath: string): ClaudeMdFileInfo { + const expandedPath = expandTilde(dirPath); + + try { + if (!fs.existsSync(expandedPath)) { + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } + + const stats = fs.statSync(expandedPath); + if (!stats.isDirectory()) { + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } + + const mdFiles = collectMdFiles(expandedPath); + + if (mdFiles.length === 0) { + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } + + let totalCharCount = 0; + const allContent: string[] = []; + + for (const filePath of mdFiles) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + totalCharCount += content.length; + allContent.push(content); + } catch { + // Skip files we can't read + continue; + } + } + + // Count tokens on combined content for accuracy + const estimatedTokens = countTokens(allContent.join('\n')); + + return { + path: expandedPath, + exists: true, + charCount: totalCharCount, + estimatedTokens, + }; + } catch (error) { + logger.error(`Error reading directory ${expandedPath}:`, error); + return { + path: expandedPath, + exists: false, + charCount: 0, + estimatedTokens: 0, + }; + } +} + +/** + * Recursively collect all .md files in a directory tree. + */ +function collectMdFiles(dir: string): string[] { + const mdFiles: string[] = []; + try { + const entries = fs.readdirSync(dir); + for (const entry of entries) { + const fullPath = path.join(dir, entry); + try { + const stats = fs.statSync(fullPath); + if (stats.isFile() && entry.endsWith('.md')) { + mdFiles.push(fullPath); + } else if (stats.isDirectory()) { + mdFiles.push(...collectMdFiles(fullPath)); + } + } catch { + continue; + } + } + } catch { + // Directory not readable + } + return mdFiles; +} + +/** + * Returns the platform-specific enterprise CLAUDE.md path. + */ +function getEnterprisePath(): string { + switch (process.platform) { + case 'win32': + return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md'; + case 'darwin': + return '/Library/Application Support/ClaudeCode/CLAUDE.md'; + default: + return '/etc/claude-code/CLAUDE.md'; + } +} + +/** + * Reads auto memory MEMORY.md file for a project. + * Only reads the first 200 lines, matching Claude Code behavior. + */ +function readAutoMemoryFile(projectRoot: string): ClaudeMdFileInfo { + const expandedRoot = expandTilde(projectRoot); + const encoded = encodePath(expandedRoot); + const homeDir = app.getPath('home'); + const memoryPath = path.join(homeDir, '.claude', 'projects', encoded, 'memory', 'MEMORY.md'); + + try { + if (!fs.existsSync(memoryPath)) { + return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 }; + } + + const content = fs.readFileSync(memoryPath, 'utf8'); + // Only first 200 lines, matching Claude Code behavior + const lines = content.split('\n'); + const truncated = lines.slice(0, 200).join('\n'); + const charCount = truncated.length; + const estimatedTokens = countTokens(truncated); + + return { path: memoryPath, exists: true, charCount, estimatedTokens }; + } catch (error) { + logger.error(`Error reading auto memory at ${memoryPath}:`, error); + return { path: memoryPath, exists: false, charCount: 0, estimatedTokens: 0 }; + } +} + +/** + * Reads all potential CLAUDE.md locations for a project. + * @param projectRoot - The root directory of the project + * @returns ClaudeMdReadResult with Map of path -> ClaudeMdFileInfo + */ +export function readAllClaudeMdFiles(projectRoot: string): ClaudeMdReadResult { + const files = new Map(); + const expandedProjectRoot = expandTilde(projectRoot); + + // 1. Enterprise CLAUDE.md (platform-specific path) + const enterprisePath = getEnterprisePath(); + files.set('enterprise', readClaudeMdFile(enterprisePath)); + + // 2. User memory: ~/.claude/CLAUDE.md + const userMemoryPath = '~/.claude/CLAUDE.md'; + files.set('user', readClaudeMdFile(userMemoryPath)); + + // 3. Project memory: ${projectRoot}/CLAUDE.md + const projectMemoryPath = path.join(expandedProjectRoot, 'CLAUDE.md'); + files.set('project', readClaudeMdFile(projectMemoryPath)); + + // 4. Project memory alt: ${projectRoot}/.claude/CLAUDE.md + const projectMemoryAltPath = path.join(expandedProjectRoot, '.claude', 'CLAUDE.md'); + files.set('project-alt', readClaudeMdFile(projectMemoryAltPath)); + + // 5. Project rules: ${projectRoot}/.claude/rules/*.md + const projectRulesPath = path.join(expandedProjectRoot, '.claude', 'rules'); + files.set('project-rules', readDirectoryMdFiles(projectRulesPath)); + + // 6. Project local: ${projectRoot}/CLAUDE.local.md + const projectLocalPath = path.join(expandedProjectRoot, 'CLAUDE.local.md'); + files.set('project-local', readClaudeMdFile(projectLocalPath)); + + // 7. User rules: ~/.claude/rules/**/*.md + const homeDir = app.getPath('home'); + const userRulesPath = path.join(homeDir, '.claude', 'rules'); + files.set('user-rules', readDirectoryMdFiles(userRulesPath)); + + // 8. Auto memory: ~/.claude/projects//memory/MEMORY.md + files.set('auto-memory', readAutoMemoryFile(projectRoot)); + + return { files }; +} + +/** + * Reads a specific directory's CLAUDE.md file. + * Used for directory-specific CLAUDE.md detected from file reads. + * @param dirPath - Path to the directory (supports ~ expansion) + * @returns ClaudeMdFileInfo for the CLAUDE.md file in that directory + */ +export function readDirectoryClaudeMd(dirPath: string): ClaudeMdFileInfo { + const expandedDirPath = expandTilde(dirPath); + const claudeMdPath = path.join(expandedDirPath, 'CLAUDE.md'); + return readClaudeMdFile(claudeMdPath); +} diff --git a/src/main/services/parsing/GitIdentityResolver.ts b/src/main/services/parsing/GitIdentityResolver.ts new file mode 100644 index 00000000..80608dd6 --- /dev/null +++ b/src/main/services/parsing/GitIdentityResolver.ts @@ -0,0 +1,674 @@ +/** + * GitIdentityResolver service - Resolves git repository identity from project paths. + * + * Responsibilities: + * - Detect if a path is inside a git worktree vs main repository + * - Extract the main repository path from worktree's .git file + * - Get git remote URL for repository identity + * - Build consistent repository identity across all worktrees + * + * Git worktree detection: + * - Main repo: .git is a directory + * - Worktree: .git is a file containing "gitdir: /path/to/main/.git/worktrees/" + */ + +import { + AUTO_CLAUDE_DIR, + CCSWITCH_DIR, + CLAUDE_WORKTREES_DIR, + CONDUCTOR_DIR, + CURSOR_DIR, + TASKS_DIR, + TWENTYFIRST_DIR, + VIBE_KANBAN_DIR, + WORKSPACES_DIR, + WORKTREES_DIR, +} from '@main/constants/worktreePatterns'; +import { type RepositoryIdentity, type WorktreeSource } from '@main/types'; +import { createLogger } from '@shared/utils/logger'; +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +const logger = createLogger('Service:GitIdentityResolver'); + +class GitIdentityResolver { + /** + * Resolve repository identity from a project path. + * + * Algorithm: + * 1. Check if path/.git exists on filesystem + * 2. If .git is a file (worktree), read gitdir to find main repo + * 3. If .git is a directory (main repo), use it directly + * 4. Extract remote URL from .git/config + * 5. Build RepositoryIdentity with consistent ID + * 6. FALLBACK: If path doesn't exist, use heuristics based on path patterns + * + * @param projectPath - The filesystem path to check + * @returns RepositoryIdentity or null if not a git repo + */ + async resolveIdentity(projectPath: string): Promise { + try { + const gitPath = path.join(projectPath, '.git'); + + // First, try filesystem-based resolution + if (fs.existsSync(gitPath)) { + const stats = fs.statSync(gitPath); + + let mainGitDir: string; + + if (stats.isFile()) { + // This is a worktree - parse the .git file to find main repo + const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim(); + const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/m.exec(gitFileContent); + + if (!gitDirMatch) { + logger.warn(`Invalid .git file format at ${gitPath}`); + return this.resolveIdentityFromPath(projectPath); + } + + let worktreeGitDir = gitDirMatch[1].trim(); + + // Handle relative paths in gitdir (resolve relative to the .git file location) + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(projectPath, worktreeGitDir); + } + + mainGitDir = this.extractMainGitDir(worktreeGitDir); + } else if (stats.isDirectory()) { + mainGitDir = gitPath; + } else { + return this.resolveIdentityFromPath(projectPath); + } + + // Normalize the path to handle symlinks (e.g., /tmp -> /private/var/folders) + // This ensures all worktrees of the same repo get the same ID + try { + mainGitDir = fs.realpathSync(mainGitDir); + } catch { + // If realpath fails (e.g., path doesn't exist), use as-is + } + + // Extract remote URL from config + const remoteUrl = this.getRemoteUrl(mainGitDir); + + // Generate consistent repository ID based on the CANONICAL main git directory + const repoId = this.generateRepoId(remoteUrl, mainGitDir); + + // Extract repository name from path or remote URL + const repoName = this.extractRepoName(remoteUrl, mainGitDir); + + return { + id: repoId, + remoteUrl: remoteUrl ?? undefined, + mainGitDir, + name: repoName, + }; + } + + // Fallback: path doesn't exist, use heuristic resolution + return this.resolveIdentityFromPath(projectPath); + } catch (error) { + logger.error(`Error resolving git identity for ${projectPath}:`, error); + // Try fallback even on error + return this.resolveIdentityFromPath(projectPath); + } + } + + /** + * Fallback: Resolve repository identity from path patterns when filesystem is unavailable. + * Uses heuristics to detect common worktree path patterns. + * + * Patterns supported: + * - /.cursor/worktrees/{repo}/{worktree-name} + * - /vibe-kanban/worktrees/{issue-branch}/{repo} + * - /T/vibe-kanban/worktrees/{issue-branch}/{repo} + * - Regular paths: use last component as repo name + */ + private resolveIdentityFromPath(projectPath: string): RepositoryIdentity | null { + const repoName = this.extractRepoNameFromPath(projectPath); + + if (!repoName) { + return null; + } + + // Generate ID from full path (since no remote URL, avoids colliding same-named repos) + const repoId = this.generateRepoId(null, projectPath); + + return { + id: repoId, + remoteUrl: undefined, + mainGitDir: repoName, // Use repo name as placeholder + name: repoName, + }; + } + + /** + * Extract repository name from path using heuristics. + * Works for both existing and deleted worktrees based on path patterns. + * + * Patterns: + * - /.cursor/worktrees/{repo}/{worktree} → repo + * - /vibe-kanban/worktrees/{issue-branch}/{repo} → repo (last component) + * - /conductor/workspaces/{repo}/{subpath} → repo + * - /.auto-claude/worktrees/tasks/{task-id} → parent repo (2 levels up from .auto-claude) + * - /.21st/worktrees/{id}/{name} → parent repo + * - /.claude-worktrees/{repo}/{name} → repo + * - /.ccswitch/worktrees/{repo}/{name} → repo + * - Default: last path component + */ + private extractRepoNameFromPath(projectPath: string): string | null { + const parts = projectPath.split(path.sep).filter(Boolean); + + if (parts.length === 0) { + return null; + } + + // Pattern 1: /.cursor/worktrees/{repo}/{worktree-name} + const cursorWorktreeIdx = parts.indexOf(CURSOR_DIR); + if (cursorWorktreeIdx >= 0 && parts[cursorWorktreeIdx + 1] === WORKTREES_DIR) { + if (parts[cursorWorktreeIdx + 2]) { + return parts[cursorWorktreeIdx + 2]; + } + } + + // Pattern 2: /vibe-kanban/worktrees/{issue-branch}/{repo} + const vibeKanbanIdx = parts.indexOf(VIBE_KANBAN_DIR); + if (vibeKanbanIdx >= 0 && parts[vibeKanbanIdx + 1] === WORKTREES_DIR) { + // The repo name is the LAST component (after issue-branch) + return parts[parts.length - 1]; + } + + // Pattern 3: /conductor/workspaces/{repo}/{subpath} + const conductorIdx = parts.indexOf(CONDUCTOR_DIR); + if (conductorIdx >= 0 && parts[conductorIdx + 1] === WORKSPACES_DIR) { + if (parts[conductorIdx + 2]) { + return parts[conductorIdx + 2]; + } + } + + // Pattern 4: /.auto-claude/worktrees/tasks/{task-id} + // Repo is typically the directory containing .auto-claude + const autoClaudeIdx = parts.indexOf(AUTO_CLAUDE_DIR); + if (autoClaudeIdx > 0 && parts[autoClaudeIdx + 1] === WORKTREES_DIR) { + return parts[autoClaudeIdx - 1]; // Parent directory is the repo + } + + // Pattern 5: /.21st/worktrees/{id}/{name} + const twentyFirstIdx = parts.indexOf(TWENTYFIRST_DIR); + if (twentyFirstIdx > 0 && parts[twentyFirstIdx + 1] === WORKTREES_DIR) { + return parts[twentyFirstIdx - 1]; // Parent directory is the repo + } + + // Pattern 6: /.claude-worktrees/{repo}/{name} + const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR); + if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 1]) { + return parts[claudeWorktreesIdx + 1]; + } + + // Pattern 7: /.ccswitch/worktrees/{repo}/{name} + const ccswitchIdx = parts.indexOf(CCSWITCH_DIR); + if (ccswitchIdx >= 0 && parts[ccswitchIdx + 1] === WORKTREES_DIR) { + if (parts[ccswitchIdx + 2]) { + return parts[ccswitchIdx + 2]; + } + } + + // Default: use the last component + return parts[parts.length - 1]; + } + + /** + * Determine if a path is a worktree (vs main repo). + * Worktrees have a .git file, main repos have a .git directory. + * Uses path heuristics if filesystem is not available (for deleted worktrees). + */ + isWorktree(projectPath: string): boolean { + // First, try path-based heuristics (works for deleted worktrees) + const parts = projectPath.split(path.sep).filter(Boolean); + + // Check for known worktree patterns - these are ALWAYS worktrees + if (parts.includes(CURSOR_DIR) && parts.includes(WORKTREES_DIR)) { + return true; + } + if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { + return true; + } + if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) { + return true; + } + if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) { + return true; + } + if (parts.includes(CLAUDE_WORKTREES_DIR)) { + return true; + } + if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) { + return true; + } + if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) { + // Subpaths in conductor/workspaces are worktrees + const conductorIdx = parts.indexOf(CONDUCTOR_DIR); + if (conductorIdx >= 0 && parts.length > conductorIdx + 3) { + return true; // Has subpath after workspaces/{repo} + } + } + + // Fallback: check filesystem if available + try { + const gitPath = path.join(projectPath, '.git'); + if (fs.existsSync(gitPath)) { + const stats = fs.statSync(gitPath); + return stats.isFile(); + } + } catch { + // Ignore errors - filesystem might not be available + } + + return false; + } + + /** + * Extract the main .git directory path from a worktree's gitdir. + * + * @param worktreeGitDir - Path like "/path/to/main/.git/worktrees/" + * @returns Path to main .git directory like "/path/to/main/.git" + */ + private extractMainGitDir(worktreeGitDir: string): string { + // worktreeGitDir is typically: /path/to/main/.git/worktrees/ + // We need to go up two levels to get to .git + const parts = worktreeGitDir.split(path.sep); + const worktreesIndex = parts.lastIndexOf(WORKTREES_DIR); + + if (worktreesIndex > 0) { + // Return everything up to and including .git + return parts.slice(0, worktreesIndex).join(path.sep); + } + + // Fallback: try to find .git in the path + const gitIndex = worktreeGitDir.lastIndexOf('.git'); + if (gitIndex > 0) { + return worktreeGitDir.substring(0, gitIndex + 4); // +4 for ".git" + } + + // Last resort: return as-is + return worktreeGitDir; + } + + /** + * Get git remote URL from a repository's config file. + * + * @param gitDir - Path to the .git directory + * @returns Remote URL or null if not found + */ + private getRemoteUrl(gitDir: string): string | null { + try { + const configPath = path.join(gitDir, 'config'); + if (!fs.existsSync(configPath)) { + return null; + } + + const configContent = fs.readFileSync(configPath, 'utf-8'); + + // Parse git config to find [remote "origin"] section + const lines = configContent.split(/\r?\n/); + let inOriginRemote = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Check for remote "origin" section + if (/^\[remote\s+"origin"\]$/.exec(trimmed)) { + inOriginRemote = true; + continue; + } + + // Check for new section (exit origin remote) + if (trimmed.startsWith('[') && inOriginRemote) { + break; + } + + // Look for url = ... in origin remote section + if (inOriginRemote && trimmed.startsWith('url')) { + const urlMatch = /^url\s*=\s*(\S[^\r\n]*)$/.exec(trimmed); + if (urlMatch) { + return urlMatch[1].trim(); + } + } + } + + return null; + } catch (error) { + logger.error(`Error reading git config at ${gitDir}:`, error); + return null; + } + } + + /** + * Generate consistent repository ID. + * Uses the LOCAL DIRECTORY NAME as the primary identifier to ensure consistent grouping + * across filesystem-based and path-based resolution. + * + * IMPORTANT: We prioritize local directory name over remote URL repo name because: + * 1. Path-based resolution (for deleted worktrees) can only use directory names + * 2. Users may clone repos with different local names than remote names + * 3. We need consistent grouping regardless of whether filesystem exists + * + * @param _remoteUrl - Git remote URL (unused, kept for API compatibility) + * @param mainGitDirOrName - Path to main .git directory, or repo name for path-based resolution + * @returns Consistent hash-based ID + */ + private generateRepoId(remoteUrl: string | null, mainGitDirOrName: string): string { + // When a remote URL is available, use directory name for grouping + // (worktrees of the same repo have same dir name but different paths) + // When NO remote URL, use the full path to avoid colliding repos with same name + let identity: string; + + if (mainGitDirOrName.includes(path.sep) || mainGitDirOrName.includes('/')) { + if (remoteUrl) { + // Has remote → use dir name (allows worktree grouping) + const parentDir = path.dirname(mainGitDirOrName); + identity = path.basename(parentDir); + } else { + // No remote → use full path to distinguish same-named repos. + // For filesystem-based calls, mainGitDirOrName is like /path/.git → strip .git + // For fallback calls, mainGitDirOrName is the project path itself + identity = mainGitDirOrName.endsWith('.git') + ? path.dirname(mainGitDirOrName) + : mainGitDirOrName; + } + } else { + // It's already just a name (from path-based resolution fallback) + identity = mainGitDirOrName; + } + + // Normalize and generate hash + const normalized = identity.toLowerCase().trim(); + + // Generate SHA-256 hash and take first 12 characters + const hash = crypto.createHash('sha256').update(normalized).digest('hex'); + return hash.substring(0, 12); + } + + /** + * Extract repository name from git directory path. + * Always uses the LOCAL directory name for consistency with path-based resolution. + * + * @param _remoteUrl - Git remote URL (unused, kept for API compatibility) + * @param mainGitDir - Path to main .git directory + * @returns Repository name for display + */ + private extractRepoName(_remoteUrl: string | null, mainGitDir: string): string { + // Always use local directory name for consistency + // /Users/username/projectname/.git -> projectname + // /Users/username/projectname/.git -> projectname + const parentDir = path.dirname(mainGitDir); + return path.basename(parentDir); + } + + /** + * Get the git branch for a worktree. + * + * @param projectPath - The filesystem path to check + * @returns Branch name or null + */ + async getBranch(projectPath: string): Promise { + try { + const gitPath = path.join(projectPath, '.git'); + + if (!fs.existsSync(gitPath)) { + return null; + } + + const stats = fs.statSync(gitPath); + let headPath: string; + + if (stats.isFile()) { + // Worktree - read .git file to find the HEAD location + const gitFileContent = fs.readFileSync(gitPath, 'utf-8').trim(); + const gitDirMatch = /^gitdir:\s*(\S[^\r\n]*)$/.exec(gitFileContent); + + if (!gitDirMatch) { + return null; + } + + headPath = path.join(gitDirMatch[1], 'HEAD'); + } else { + // Main repo + headPath = path.join(gitPath, 'HEAD'); + } + + if (!fs.existsSync(headPath)) { + return null; + } + + const headContent = fs.readFileSync(headPath, 'utf-8').trim(); + + // Check if HEAD is a symbolic ref (branch) + const refMatch = /^ref:\s*refs\/heads\/(.+)$/.exec(headContent); + if (refMatch) { + return refMatch[1]; + } + + // HEAD is detached (commit hash) + return 'detached HEAD'; + } catch (error) { + logger.error(`Error reading git branch for ${projectPath}:`, error); + return null; + } + } + + /** + * Detect the worktree source based on path patterns. + * This method works purely on path patterns and does NOT require filesystem access, + * ensuring detection works even for deleted worktrees. + * + * Supported patterns: + * - vibe-kanban: /tmp/vibe-kanban/worktrees/{issue-branch}/{repo} + * - conductor: /Users/.../conductor/workspaces/{repo}/{workspace} + * - auto-claude: /Users/.../.auto-claude/worktrees/tasks/{task-id} + * - 21st: /Users/.../.21st/worktrees/{id}/{name} + * - claude-desktop: /Users/.../.claude-worktrees/{repo}/{name} + * - ccswitch: /Users/.../.ccswitch/worktrees/{repo}/{name} + * - git: Standard git worktree (fallback if none of the above match) + * - unknown: Non-git or undetectable + * + * @param projectPath - The filesystem path to check + * @returns WorktreeSource identifier + */ + detectWorktreeSource(projectPath: string): WorktreeSource { + const parts = projectPath.split(path.sep).filter(Boolean); + + // Pattern: vibe-kanban + // /tmp/vibe-kanban/worktrees/{issue-branch}/{repo} + // /private/var/folders/.../vibe-kanban/worktrees/{issue-branch}/{repo} + if (parts.includes(VIBE_KANBAN_DIR) && parts.includes(WORKTREES_DIR)) { + return 'vibe-kanban'; + } + + // Pattern: conductor + // /Users/.../conductor/workspaces/{repo}/{workspace} + if (parts.includes(CONDUCTOR_DIR) && parts.includes(WORKSPACES_DIR)) { + return 'conductor'; + } + + // Pattern: auto-claude + // /Users/.../.auto-claude/worktrees/tasks/{task-id} + if (parts.includes(AUTO_CLAUDE_DIR) && parts.includes(WORKTREES_DIR)) { + return 'auto-claude'; + } + + // Pattern: 21st (1code) + // /Users/.../.21st/worktrees/{id}/{name} + if (parts.includes(TWENTYFIRST_DIR) && parts.includes(WORKTREES_DIR)) { + return '21st'; + } + + // Pattern: claude-desktop + // /Users/.../.claude-worktrees/{repo}/{name} + if (parts.includes(CLAUDE_WORKTREES_DIR)) { + return 'claude-desktop'; + } + + // Pattern: ccswitch + // /Users/.../.ccswitch/worktrees/{repo}/{name} + if (parts.includes(CCSWITCH_DIR) && parts.includes(WORKTREES_DIR)) { + return 'ccswitch'; + } + + // Check if it's a standard git repo (only if filesystem exists) + // For deleted repos, we'll return 'git' as fallback since we can't verify + try { + const gitPath = path.join(projectPath, '.git'); + if (fs.existsSync(gitPath)) { + return 'git'; + } + } catch { + // Ignore errors - filesystem might not be available + } + + // Default to 'git' for paths that don't match known patterns + // This is a reasonable default since most worktrees come from git + return 'git'; + } + + /** + * Get the display name for a worktree based on its source. + * Extracts the meaningful identifier from the path based on the pattern. + * + * @param projectPath - The filesystem path + * @param source - The detected worktree source + * @param branch - The git branch (if available) + * @param isMainWorktree - Whether this is the main worktree + * @returns Display name for the worktree + */ + getWorktreeDisplayName( + projectPath: string, + source: WorktreeSource, + branch: string | null, + isMainWorktree: boolean + ): string { + const parts = projectPath.split(path.sep).filter(Boolean); + + switch (source) { + case 'vibe-kanban': { + // Pattern: vibe-kanban/worktrees/{issue-branch}/{repo} + // Display: {issue-branch} (e.g., "92a6-kanban-extension") + const worktreesIdx = parts.indexOf(WORKTREES_DIR); + if (worktreesIdx >= 0 && parts[worktreesIdx + 1]) { + return parts[worktreesIdx + 1]; + } + break; + } + + case 'conductor': { + // Pattern: conductor/workspaces/{repo}/{workspace} + // Display: {workspace} (e.g., "san-francisco") + const workspacesIdx = parts.indexOf(WORKSPACES_DIR); + if (workspacesIdx >= 0 && parts[workspacesIdx + 2]) { + return parts[workspacesIdx + 2]; + } + break; + } + + case 'auto-claude': { + // Pattern: .auto-claude/worktrees/tasks/{task-id} + // Display: {task-id} (e.g., "002-hjell") + const tasksIdx = parts.indexOf(TASKS_DIR); + if (tasksIdx >= 0 && parts[tasksIdx + 1]) { + return parts[tasksIdx + 1]; + } + // Fallback: last component + return parts[parts.length - 1]; + } + + case '21st': { + // Pattern: .21st/worktrees/{id}/{name with [bracket-id]} + // e.g., "mkp2f9a3y7x1s2nr 3b06478 [colonial-swordfish-fcad5f]" + // Display: Extract from brackets (e.g., "colonial-swordfish-fcad5f") + const lastPart = parts[parts.length - 1]; + // Extract content from square brackets using indexOf to avoid regex backtracking + const bracketStart = lastPart.indexOf('['); + const bracketEnd = lastPart.indexOf(']', bracketStart); + if (bracketStart !== -1 && bracketEnd !== -1 && bracketEnd > bracketStart + 1) { + return lastPart.slice(bracketStart + 1, bracketEnd); + } + // Fallback: use the last part as-is + return lastPart; + } + + case 'claude-desktop': { + // Pattern: .claude-worktrees/{repo}/{name} + // Display: {name} (e.g., "keen-sinoussi") + const claudeWorktreesIdx = parts.indexOf(CLAUDE_WORKTREES_DIR); + if (claudeWorktreesIdx >= 0 && parts[claudeWorktreesIdx + 2]) { + return parts[claudeWorktreesIdx + 2]; + } + break; + } + + case 'ccswitch': { + // Pattern: .ccswitch/worktrees/{repo}/{name} + // Display: {name} (e.g., "just-explain-my-repo-briefly") + const ccswitchWorktreesIdx = parts.indexOf(CCSWITCH_DIR); + if (ccswitchWorktreesIdx >= 0) { + const worktreesIdx = parts.indexOf(WORKTREES_DIR, ccswitchWorktreesIdx); + if (worktreesIdx >= 0 && parts[worktreesIdx + 2]) { + return parts[worktreesIdx + 2]; + } + } + break; + } + + case 'git': + // Standard git worktree - use branch or path-based name + if (isMainWorktree) { + return branch ?? 'main'; + } + // For non-main git worktrees, try to get the worktree name from .git file + return this.getGitWorktreeName(projectPath) ?? branch ?? parts[parts.length - 1]; + + case 'unknown': + default: + // Non-git project - use last path component + return parts[parts.length - 1] ?? 'unknown'; + } + + // Fallback for any case that didn't return + return branch ?? parts[parts.length - 1] ?? 'unknown'; + } + + /** + * Get the worktree name from git's internal tracking. + * Reads .git file to find the worktree name in .git/worktrees/{name} + * + * @param projectPath - The filesystem path + * @returns Worktree name or null + */ + private getGitWorktreeName(projectPath: string): string | null { + try { + const gitPath = path.join(projectPath, '.git'); + if (!fs.existsSync(gitPath)) return null; + + const stats = fs.statSync(gitPath); + if (!stats.isFile()) return null; + + const content = fs.readFileSync(gitPath, 'utf-8'); + const match = /gitdir:\s*(\S[^\r\n]*)/.exec(content); + if (!match) return null; + + // gitdir: /main/.git/worktrees/my-worktree-name + const gitdirParts = match[1].trim().split(path.sep); + const worktreesIdx = gitdirParts.lastIndexOf(WORKTREES_DIR); + if (worktreesIdx >= 0 && gitdirParts[worktreesIdx + 1]) { + return gitdirParts[worktreesIdx + 1]; + } + return null; + } catch { + return null; + } + } +} + +// Export singleton instance +export const gitIdentityResolver = new GitIdentityResolver(); diff --git a/src/main/services/parsing/MessageClassifier.ts b/src/main/services/parsing/MessageClassifier.ts new file mode 100644 index 00000000..fd1aab93 --- /dev/null +++ b/src/main/services/parsing/MessageClassifier.ts @@ -0,0 +1,65 @@ +/** + * MessageClassifier service - Classifies messages into categories for chunk building. + * + * Categories: + * - User: Genuine user input (creates UserChunk, renders RIGHT) + * - System: Command output (creates SystemChunk, renders LEFT) + * - Compact: Summary messages from conversation compaction + * - Hard Noise: Filtered out entirely (system metadata, caveats, reminders) + * - AI: All other messages grouped into AIChunks (renders LEFT) + */ + +import { + isParsedCompactMessage, + isParsedHardNoiseMessage, + isParsedSystemChunkMessage, + isParsedUserChunkMessage, + type MessageCategory, + type ParsedMessage, +} from '@main/types'; + +/** + * Result of classifying a message. + */ +export interface ClassifiedMessage { + message: ParsedMessage; + category: MessageCategory; +} + +/** + * Classify all messages into categories. + */ +export function classifyMessages(messages: ParsedMessage[]): ClassifiedMessage[] { + return messages.map((message) => ({ + message, + category: categorizeMessage(message), + })); +} + +/** + * Categorize a single message into one of five categories. + */ +function categorizeMessage(message: ParsedMessage): MessageCategory { + // Check hard noise first (filtered out) + if (isParsedHardNoiseMessage(message)) { + return 'hardNoise'; + } + + // Check compact summary (before system/user to catch it early) + if (isParsedCompactMessage(message)) { + return 'compact'; + } + + // Check system (command output) + if (isParsedSystemChunkMessage(message)) { + return 'system'; + } + + // Check user (real user input) + if (isParsedUserChunkMessage(message)) { + return 'user'; + } + + // Everything else is AI (assistant messages, tool results, etc.) + return 'ai'; +} diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts new file mode 100644 index 00000000..e9c00547 --- /dev/null +++ b/src/main/services/parsing/SessionParser.ts @@ -0,0 +1,378 @@ +/** + * SessionParser service - Parses Claude Code session JSONL files. + * + * Responsibilities: + * - Parse JSONL files into structured messages + * - Extract all message metadata + * - Identify tool calls and tool results + * - Calculate session metrics + */ + +import { + isParsedInternalUserMessage, + isParsedRealUserMessage, + type ParsedMessage, + type SessionMetrics, + type ToolCall, + type ToolResult, +} from '@main/types'; +import { + calculateMetrics, + extractTextContent, + getTaskCalls, + parseJsonlFile, +} from '@main/utils/jsonl'; +import * as path from 'path'; + +import { type ProjectScanner } from '../discovery/ProjectScanner'; + +/** + * Result of parsing a session file. + */ +export interface ParsedSession { + /** All parsed messages */ + messages: ParsedMessage[]; + /** Session metrics */ + metrics: SessionMetrics; + /** All Task calls found in the session */ + taskCalls: ToolCall[]; + /** Messages grouped by type */ + byType: { + user: ParsedMessage[]; // All user messages + realUser: ParsedMessage[]; // Only real user messages (not tool results) + internalUser: ParsedMessage[]; // Only tool result messages + assistant: ParsedMessage[]; + system: ParsedMessage[]; + other: ParsedMessage[]; + }; + /** Sidechain messages */ + sidechainMessages: ParsedMessage[]; + /** Main thread messages (non-sidechain) */ + mainMessages: ParsedMessage[]; +} + +export class SessionParser { + private projectScanner: ProjectScanner; + + constructor(projectScanner: ProjectScanner) { + this.projectScanner = projectScanner; + } + + // =========================================================================== + // Core Parsing + // =========================================================================== + + /** + * Parse a session JSONL file and return structured data. + */ + async parseSession(projectId: string, sessionId: string): Promise { + const sessionPath = this.projectScanner.getSessionPath(projectId, sessionId); + return this.parseSessionFile(sessionPath); + } + + /** + * Parse a JSONL file at the given path. + */ + async parseSessionFile(filePath: string): Promise { + const messages = await parseJsonlFile(filePath); + return this.processMessages(messages); + } + + /** + * Process parsed messages into structured data. + */ + private processMessages(messages: ParsedMessage[]): ParsedSession { + // Group by type + const byType = { + user: messages.filter((m) => m.type === 'user'), + realUser: messages.filter(isParsedRealUserMessage), + internalUser: messages.filter(isParsedInternalUserMessage), + assistant: messages.filter((m) => m.type === 'assistant'), + system: messages.filter((m) => m.type === 'system'), + other: messages.filter( + (m) => m.type !== 'user' && m.type !== 'assistant' && m.type !== 'system' + ), + }; + + // Separate sidechain and main messages + const sidechainMessages = messages.filter((m) => m.isSidechain); + const mainMessages = messages.filter((m) => !m.isSidechain); + + // Calculate metrics + const metrics = calculateMetrics(messages); + + // Extract all Task calls + const taskCalls = getTaskCalls(messages); + + return { + messages, + metrics, + taskCalls, + byType, + sidechainMessages, + mainMessages, + }; + } + + // =========================================================================== + // Message Queries + // =========================================================================== + + /** + * Get user messages from a parsed session. + */ + getUserMessages(session: ParsedSession): ParsedMessage[] { + return session.byType.user; + } + + /** + * Get assistant messages from a parsed session. + */ + getAssistantMessages(session: ParsedSession): ParsedMessage[] { + return session.byType.assistant; + } + + /** + * Get messages in a time range. + */ + getMessagesInRange(messages: ParsedMessage[], startTime: Date, endTime: Date): ParsedMessage[] { + return messages.filter((m) => m.timestamp >= startTime && m.timestamp <= endTime); + } + + /** + * Get responses to a specific user message. + * Finds all assistant messages that follow the user message until the next user message. + */ + getResponses(messages: ParsedMessage[], userMessageUuid: string): ParsedMessage[] { + const userMsgIndex = messages.findIndex((m) => m.uuid === userMessageUuid); + if (userMsgIndex === -1) return []; + + const responses: ParsedMessage[] = []; + + for (let i = userMsgIndex + 1; i < messages.length; i++) { + const msg = messages[i]; + + // Stop at next user message + if (msg.type === 'user') break; + + // Include assistant responses + if (msg.type === 'assistant') { + responses.push(msg); + } + } + + return responses; + } + + // =========================================================================== + // Tool Call Analysis + // =========================================================================== + + /** + * Get all Task (subagent) calls from messages. + */ + getTaskCalls(messages: ParsedMessage[]): ToolCall[] { + return getTaskCalls(messages); + } + + /** + * Get all tool calls of a specific type. + */ + getToolCallsByName(messages: ParsedMessage[], toolName: string): ToolCall[] { + return messages.flatMap((m) => m.toolCalls.filter((tc) => tc.name === toolName)); + } + + /** + * Find the tool result for a specific tool call. + */ + findToolResult( + messages: ParsedMessage[], + toolCallId: string + ): { message: ParsedMessage; result: ToolResult } | null { + for (const msg of messages) { + const result = msg.toolResults.find((tr) => tr.toolUseId === toolCallId); + if (result) { + return { message: msg, result }; + } + } + return null; + } + + // =========================================================================== + // Timing Analysis + // =========================================================================== + + /** + * Get the time range of messages. + */ + getTimeRange(messages: ParsedMessage[]): { start: Date; end: Date; durationMs: number } { + if (messages.length === 0) { + const now = new Date(); + return { start: now, end: now, durationMs: 0 }; + } + + const timestamps = messages.map((m) => m.timestamp.getTime()); + let min = timestamps[0]; + let max = timestamps[0]; + for (let i = 1; i < timestamps.length; i++) { + if (timestamps[i] < min) min = timestamps[i]; + if (timestamps[i] > max) max = timestamps[i]; + } + const start = new Date(min); + const end = new Date(max); + + return { + start, + end, + durationMs: end.getTime() - start.getTime(), + }; + } + + /** + * Calculate metrics for a subset of messages. + */ + calculateMetrics(messages: ParsedMessage[]): SessionMetrics { + return calculateMetrics(messages); + } + + // =========================================================================== + // Text Extraction + // =========================================================================== + + /** + * Extract text content from a message. + */ + extractText(message: ParsedMessage): string { + return extractTextContent(message); + } + + /** + * Get a preview of a message (first N characters). + */ + getMessagePreview(message: ParsedMessage, maxLength: number = 100): string { + const text = extractTextContent(message); + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + + // =========================================================================== + // Message Threading + // =========================================================================== + + /** + * Build a parent-child message tree. + */ + buildMessageTree(messages: ParsedMessage[]): Map { + const tree = new Map(); + + for (const msg of messages) { + const parentId = msg.parentUuid ?? 'root'; + if (!tree.has(parentId)) { + tree.set(parentId, []); + } + tree.get(parentId)!.push(msg); + } + + return tree; + } + + /** + * Get child messages of a specific message. + */ + getChildMessages(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] { + return messages.filter((m) => m.parentUuid === parentUuid); + } + + /** + * Get the conversation thread for a message (ancestors + descendants). + */ + getThread(messages: ParsedMessage[], messageUuid: string): ParsedMessage[] { + const thread: ParsedMessage[] = []; + const messageMap = new Map(messages.map((m) => [m.uuid, m])); + + // Get ancestors + let current = messageMap.get(messageUuid); + const ancestors: ParsedMessage[] = []; + + while (current) { + ancestors.unshift(current); + current = current.parentUuid ? messageMap.get(current.parentUuid) : undefined; + } + + thread.push(...ancestors); + + // Get descendants + const descendants = this.getDescendants(messages, messageUuid); + // Add descendants that aren't already in thread + for (const desc of descendants) { + if (!thread.find((m) => m.uuid === desc.uuid)) { + thread.push(desc); + } + } + + return thread; + } + + /** + * Get all descendants of a message. + */ + private getDescendants(messages: ParsedMessage[], parentUuid: string): ParsedMessage[] { + const result: ParsedMessage[] = []; + const children = messages.filter((m) => m.parentUuid === parentUuid); + + for (const child of children) { + result.push(child); + result.push(...this.getDescendants(messages, child.uuid)); + } + + return result; + } + + // =========================================================================== + // Subagent File Parsing + // =========================================================================== + + /** + * Parse a subagent JSONL file. + */ + async parseSubagentFile(filePath: string): Promise<{ + messages: ParsedMessage[]; + metrics: SessionMetrics; + }> { + const messages = await parseJsonlFile(filePath); + const metrics = calculateMetrics(messages); + + return { messages, metrics }; + } + + /** + * Parse all subagent files for a session. + */ + async parseAllSubagents( + projectId: string, + sessionId: string + ): Promise< + Map< + string, + { + filePath: string; + messages: ParsedMessage[]; + metrics: SessionMetrics; + } + > + > { + const subagentFiles = await this.projectScanner.listSubagentFiles(projectId, sessionId); + const results = new Map(); + + for (const filePath of subagentFiles) { + // Extract agent ID from filename (agent-{id}.jsonl) + const filename = path.basename(filePath); + const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, ''); + + const { messages, metrics } = await this.parseSubagentFile(filePath); + results.set(agentId, { filePath, messages, metrics }); + } + + return results; + } +} diff --git a/src/main/services/parsing/index.ts b/src/main/services/parsing/index.ts new file mode 100644 index 00000000..998fcdf8 --- /dev/null +++ b/src/main/services/parsing/index.ts @@ -0,0 +1,14 @@ +/** + * Parsing services - Parsing JSONL and configuration files. + * + * Exports: + * - SessionParser: Parses JSONL session files + * - MessageClassifier: Classifies messages into categories + * - ClaudeMdReader: Reads CLAUDE.md configuration files + * - GitIdentityResolver: Resolves git identities from sessions + */ + +export * from './ClaudeMdReader'; +export * from './GitIdentityResolver'; +export * from './MessageClassifier'; +export * from './SessionParser'; diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts new file mode 100644 index 00000000..063801ff --- /dev/null +++ b/src/main/types/chunks.ts @@ -0,0 +1,503 @@ +/** + * Chunk and visualization types for Claude Code Context. + * + * This module contains: + * - Chunk types (UserChunk, AIChunk, SystemChunk, CompactChunk) + * - Process/subagent execution types + * - Conversation grouping types + * - Semantic step types for detailed visualization + * - Enhanced chunk types with visualization data + * - Session detail types + * - Chunk type guards + * - Constants + */ + +import { type Session, type SessionMetrics } from './domain'; +import { type ToolUseResultData } from './jsonl'; +import { type ParsedMessage, type ToolCall, type ToolResult } from './messages'; + +// ============================================================================= +// Process Types (Subagent Execution) +// ============================================================================= + +/** + * Resolved subagent information. + */ +export interface Process { + /** Agent ID extracted from filename */ + id: string; + /** Path to the subagent JSONL file */ + filePath: string; + /** Parsed messages from the subagent session */ + messages: ParsedMessage[]; + /** When the subagent started */ + startTime: Date; + /** When the subagent ended */ + endTime: Date; + /** Duration in milliseconds */ + durationMs: number; + /** Aggregated metrics for the subagent */ + metrics: SessionMetrics; + /** Task description from parent Task call */ + description?: string; + /** Subagent type from Task call (e.g., "Explore", "Plan") */ + subagentType?: string; + /** Whether executed in parallel with other subagents */ + isParallel: boolean; + /** The tool_use ID of the Task call that spawned this */ + parentTaskId?: string; + /** Whether this subagent is still in progress */ + isOngoing?: boolean; + /** + * Main session impact tokens - the tokens the Task tool_call and tool_result + * consume in the main session's context window. This is different from the + * subagent's internal token usage (metrics/messages). + */ + mainSessionImpact?: { + /** Task tool_use input tokens (prompt, config) */ + callTokens: number; + /** Task tool_result output tokens (subagent's return value) */ + resultTokens: number; + /** Total tokens affecting main session */ + totalTokens: number; + }; + /** Team metadata - present when this subagent is a team member */ + team?: { + teamName: string; + memberName: string; + memberColor: string; + }; +} + +// ============================================================================= +// Chunk Types (for visualization) +// ============================================================================= + +/** + * Base chunk properties shared by all chunk types. + */ +interface BaseChunk { + /** Unique chunk identifier */ + id: string; + /** When the chunk started */ + startTime: Date; + /** When the chunk ended */ + endTime: Date; + /** Duration in milliseconds */ + durationMs: number; + /** Aggregated metrics for the chunk */ + metrics: SessionMetrics; +} + +/** + * User chunk - represents a single user input message. + * This is separate from AI responses to support independent visualization. + */ +export interface UserChunk extends BaseChunk { + /** Discriminator for chunk type */ + chunkType: 'user'; + /** The user message */ + userMessage: ParsedMessage; +} + +/** + * AI chunk - represents all assistant responses to a user message. + * Contains responses, tool executions, and subagent spawns. + * + * NOTE: AI chunks are independent - they no longer reference a parent user chunk. + */ +export interface AIChunk extends BaseChunk { + /** Discriminator for chunk type */ + chunkType: 'ai'; + /** All assistant responses and internal messages */ + responses: ParsedMessage[]; + /** Processes spawned during this chunk */ + processes: Process[]; + /** Sidechain messages within this chunk */ + sidechainMessages: ParsedMessage[]; + /** Tool executions in this chunk */ + toolExecutions: ToolExecution[]; +} + +/** + * System chunk - represents command output rendered like AI. + */ +export interface SystemChunk extends BaseChunk { + chunkType: 'system'; + message: ParsedMessage; + commandOutput: string; // Extracted from +} + +/** + * Compact boundary chunk - marks where conversation was compacted. + */ +export interface CompactChunk extends BaseChunk { + chunkType: 'compact'; + message: ParsedMessage; +} + +/** + * A chunk can be either a user input, AI response, system output, or compact boundary. + * This discriminated union enables separate visualization and processing. + */ +export type Chunk = UserChunk | AIChunk | SystemChunk | CompactChunk; + +/** + * Tool execution with timing information. + */ +export interface ToolExecution { + /** The tool call */ + toolCall: ToolCall; + /** The tool result if received */ + result?: ToolResult; + /** When the tool was called */ + startTime: Date; + /** When the result was received */ + endTime?: Date; + /** Duration in milliseconds */ + durationMs?: number; +} + +// ============================================================================= +// Conversation Group Types (Simplified Grouping Strategy) +// ============================================================================= + +/** + * Task execution links a Task tool call to its subagent execution. + * This provides a complete view of async subagent work initiated by Task tool. + */ +export interface TaskExecution { + /** The Task tool_use block that initiated the subagent */ + taskCall: ToolCall; + /** When the Task tool was called */ + taskCallTimestamp: Date; + /** The linked subagent execution */ + subagent: Process; + /** The isMeta:true tool_result message for this Task */ + toolResult: ParsedMessage; + /** When the tool result was received */ + resultTimestamp: Date; + /** Duration from task call to result */ + durationMs: number; +} + +/** + * ConversationGroup represents a natural grouping in the conversation flow: + * - One real user message (isMeta: false, string content) + * - All AI responses until the next user message (assistant messages + internal messages) + * - Subagents spawned during this group + * - Tool executions (excluding Task tools with subagents to avoid duplication) + * - Task executions (Task tools with their subagent results) + * + * This is a simplified alternative to Chunks that focuses on natural conversation boundaries. + */ +export interface ConversationGroup { + /** Unique group identifier */ + id: string; + /** Group type - currently only one type but extensible */ + type: 'user-ai-exchange'; + /** The real user message that starts this group (isMeta: false) */ + userMessage: ParsedMessage; + /** All AI responses: assistant messages and internal messages (tool results, etc.) */ + aiResponses: ParsedMessage[]; + /** Processes spawned during this group */ + processes: Process[]; + /** Tool executions (excluding Task tools that have matching processes) */ + toolExecutions: ToolExecution[]; + /** Task tool calls with their subagent executions */ + taskExecutions: TaskExecution[]; + /** When the group started (user message timestamp) */ + startTime: Date; + /** When the group ended (last AI response timestamp) */ + endTime: Date; + /** Duration in milliseconds */ + durationMs: number; + /** Aggregated metrics for the group */ + metrics: SessionMetrics; +} + +// ============================================================================= +// Semantic Step Types (Enhanced Chunk Visualization) +// ============================================================================= + +/** + * Semantic step types for breakdown within responses. + */ +export type SemanticStepType = + | 'thinking' // Extended thinking content + | 'tool_call' // Tool invocation + | 'tool_result' // Tool result received + | 'subagent' // Subagent execution + | 'output' // Main text output + | 'interruption'; // User interruption + +/** + * A semantic step represents a logical unit of work within a response. + * + * Note: Task tool_use blocks are filtered during extraction when corresponding + * subagents exist. Since Task calls spawn async subagents, the tool_call and + * subagent represent the same execution. Filtering prevents duplicate entries + * Orphaned Task calls (without matching subagents) are + * retained as fallback to ensure visibility of all work. + */ +export interface SemanticStep { + /** Unique step identifier */ + id: string; + /** Step type */ + type: SemanticStepType; + /** When the step started */ + startTime: Date; + /** When the step ended */ + endTime?: Date; + /** Duration in milliseconds */ + durationMs: number; + + /** Content varies by type */ + content: { + thinkingText?: string; // For thinking + toolName?: string; // For tool_call/result + toolInput?: unknown; // For tool_call + toolResultContent?: string; // For tool_result + isError?: boolean; // For tool_result + toolUseResult?: ToolUseResultData; // For tool_result - enriched data from message + tokenCount?: number; // For tool_result - pre-computed token count + subagentId?: string; // For subagent + subagentDescription?: string; + outputText?: string; // For output + sourceModel?: string; // For tool_call - model from source assistant message + interruptionText?: string; // For interruption - the interruption message text + }; + + /** Token attribution */ + tokens?: { + input: number; + output: number; + cached?: number; + }; + + /** Parallel execution */ + isParallel?: boolean; + groupId?: string; + + /** Context (main agent vs subagent) */ + context: 'main' | 'subagent'; + agentId?: string; + + /** Source message UUID (for grouping steps by assistant message) */ + sourceMessageId?: string; + + /** Effective end time after gap filling (extends to next step or chunk end) */ + effectiveEndTime?: Date; + + /** Effective duration including waiting time until next step */ + effectiveDurationMs?: number; + + /** Whether timing was gap-filled vs having original endTime */ + isGapFilled?: boolean; + + /** Context tokens for this step (cache_read + cache_creation + input) */ + contextTokens?: number; + + /** Cumulative context up to this step (session-wide accumulation) */ + accumulatedContext?: number; + + /** Token breakdown for step-level estimation */ + tokenBreakdown?: { + input: number; + output: number; + cacheRead: number; + cacheCreation: number; + }; +} + +/** + * Semantic step group for collapsible visualization. + * Groups multiple micro-steps by their source assistant message. + */ +export interface SemanticStepGroup { + /** Unique group ID */ + id: string; + /** Display label (e.g., "Assistant Response", "Tool: Read") */ + label: string; + /** Steps in this group */ + steps: SemanticStep[]; + /** true if multiple steps grouped, false if standalone */ + isGrouped: boolean; + /** Assistant message UUID if grouped */ + sourceMessageId?: string; + /** Earliest step start */ + startTime: Date; + /** Latest step end */ + endTime: Date; + /** Sum of all step durations */ + totalDuration: number; +} + +// ============================================================================= +// Enhanced Chunk Types +// ============================================================================= + +/** + * Enhanced AI chunk with semantic step breakdown. + * This extends AIChunk with additional visualization data. + */ +export interface EnhancedAIChunk extends AIChunk { + /** Semantic steps extracted from messages */ + semanticSteps: SemanticStep[]; + /** Semantic steps grouped for collapsible UI */ + semanticStepGroups?: SemanticStepGroup[]; + /** Raw messages for debug sidebar */ + rawMessages: ParsedMessage[]; +} + +/** + * Enhanced user chunk with additional metadata. + */ +export interface EnhancedUserChunk extends UserChunk { + /** Raw messages for debug sidebar */ + rawMessages: ParsedMessage[]; +} + +/** + * Enhanced system chunk with additional metadata. + */ +export interface EnhancedSystemChunk extends SystemChunk { + /** Raw messages for debug sidebar */ + rawMessages: ParsedMessage[]; +} + +/** + * Enhanced compact chunk with additional metadata. + */ +export interface EnhancedCompactChunk extends CompactChunk { + /** Raw messages for debug sidebar */ + rawMessages: ParsedMessage[]; +} + +/** + * Enhanced chunk can be user, AI, system, or compact type. + */ +export type EnhancedChunk = + | EnhancedUserChunk + | EnhancedAIChunk + | EnhancedSystemChunk + | EnhancedCompactChunk; + +// ============================================================================= +// Session Detail (complete parsed session) +// ============================================================================= + +/** + * Complete parsed session with all data. + */ +export interface SessionDetail { + /** Session metadata */ + session: Session; + /** All messages in the session */ + messages: ParsedMessage[]; + /** Messages grouped into chunks */ + chunks: Chunk[]; + /** All processes in the session */ + processes: Process[]; + /** Aggregated metrics for the entire session */ + metrics: SessionMetrics; +} + +/** + * Detailed subagent information for drill-down modal. + * Contains parsed execution data for a specific subagent. + */ +export interface SubagentDetail { + /** Agent ID */ + id: string; + /** Task description */ + description: string; + /** Subagent's chunks with semantic breakdown */ + chunks: EnhancedChunk[]; + /** Semantic step groups for visualization */ + semanticStepGroups?: SemanticStepGroup[]; + /** Start time */ + startTime: Date; + /** End time */ + endTime: Date; + /** Duration in milliseconds */ + duration: number; + /** Token and message metrics */ + metrics: { + inputTokens: number; + outputTokens: number; + thinkingTokens: number; + messageCount: number; + }; +} + +// ============================================================================= +// Utility Types +// ============================================================================= + +/** + * File watching event. + */ +export interface FileChangeEvent { + type: 'add' | 'change' | 'unlink'; + path: string; + projectId?: string; + sessionId?: string; + isSubagent: boolean; +} + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Empty metrics constant for initialization. + */ +export const EMPTY_METRICS: SessionMetrics = { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 0, +}; + +// ============================================================================= +// Chunk Type Guards +// ============================================================================= + +/** + * Type guard to check if a chunk is a UserChunk. + */ +export function isUserChunk(chunk: Chunk | EnhancedChunk): chunk is UserChunk { + return 'chunkType' in chunk && chunk.chunkType === 'user'; +} + +/** + * Type guard to check if a chunk is an AIChunk. + */ +export function isAIChunk(chunk: Chunk | EnhancedChunk): chunk is AIChunk { + return 'chunkType' in chunk && chunk.chunkType === 'ai'; +} + +/** + * Type guard to check if a chunk is an EnhancedAIChunk. + */ +export function isEnhancedAIChunk(chunk: Chunk | EnhancedChunk): chunk is EnhancedAIChunk { + return isAIChunk(chunk) && 'semanticSteps' in chunk; +} + +/** + * Type guard to check if a chunk is a SystemChunk. + */ +export function isSystemChunk(chunk: Chunk | EnhancedChunk): chunk is SystemChunk { + return 'chunkType' in chunk && chunk.chunkType === 'system'; +} + +/** + * Type guard to check if a chunk is a CompactChunk. + */ +export function isCompactChunk(chunk: Chunk | EnhancedChunk): chunk is CompactChunk { + return 'chunkType' in chunk && chunk.chunkType === 'compact'; +} diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts new file mode 100644 index 00000000..a884fe1b --- /dev/null +++ b/src/main/types/domain.ts @@ -0,0 +1,283 @@ +/** + * Domain/business entity types for Claude Code Context. + * + * These types represent the application's domain model: + * - Projects and sessions + * - Repository and worktree grouping + * - Search and pagination + * - Token usage and metrics + */ + +import { type UsageMetadata } from './jsonl'; + +// ============================================================================= +// Application-Specific Type Aliases +// ============================================================================= + +/** + * Token usage statistics (alias for API compatibility). + * Maps to UsageMetadata from the spec. + */ +export type TokenUsage = UsageMetadata; + +/** + * Message type classification for parsed messages. + */ +export type MessageType = + | 'user' + | 'assistant' + | 'system' + | 'summary' + | 'file-history-snapshot' + | 'queue-operation'; + +/** + * Message category for chunk building. + * Used to classify messages into one of four categories for independent chunk creation. + */ +export type MessageCategory = 'user' | 'system' | 'hardNoise' | 'ai' | 'compact'; + +// ============================================================================= +// Project & Session Types +// ============================================================================= + +/** + * Project information derived from ~/.claude/projects/ directory. + */ +export interface Project { + /** Encoded directory name (e.g., "-Users-username-projectname") */ + id: string; + /** Decoded actual filesystem path */ + path: string; + /** Display name (last path segment) */ + name: string; + /** List of session IDs (JSONL filenames without extension) */ + sessions: string[]; + /** Unix timestamp when project directory was created */ + createdAt: number; + /** Unix timestamp of most recent session activity */ + mostRecentSession?: number; +} + +/** + * Session metadata and summary. + */ +export interface Session { + /** Session UUID (JSONL filename without extension) */ + id: string; + /** Parent project ID */ + projectId: string; + /** Project filesystem path */ + projectPath: string; + /** Task list data from ~/.claude/todos/{id}.json if exists */ + todoData?: unknown; + /** Unix timestamp when session file was created */ + createdAt: number; + /** First user message text (for preview) */ + firstMessage?: string; + /** Timestamp of first user message (RFC3339) */ + messageTimestamp?: string; + /** Whether this session has subagents */ + hasSubagents: boolean; + /** Total message count in the session */ + messageCount: number; + /** Whether the session is ongoing (last AI response has no output yet) */ + isOngoing?: boolean; + /** Git branch name if available */ + gitBranch?: string; +} + +/** + * Aggregated metrics for a session or chunk. + */ +export interface SessionMetrics { + /** Duration in milliseconds */ + durationMs: number; + /** Total tokens (input + output) */ + totalTokens: number; + /** Input tokens */ + inputTokens: number; + /** Output tokens */ + outputTokens: number; + /** Cache read tokens */ + cacheReadTokens: number; + /** Cache creation tokens */ + cacheCreationTokens: number; + /** Number of messages */ + messageCount: number; + /** Estimated cost in USD */ + costUsd?: number; +} + +// ============================================================================= +// Repository & Worktree Grouping Types +// ============================================================================= + +/** + * Worktree source identifies how/where the worktree was created. + * Used for badge display and source-specific naming strategies. + */ +export type WorktreeSource = + | 'vibe-kanban' // /tmp/vibe-kanban/worktrees/{issue-branch}/{repo} + | 'conductor' // /Users/.../conductor/workspaces/{repo}/{workspace} + | 'auto-claude' // /Users/.../.auto-claude/worktrees/tasks/{task-id} + | '21st' // /Users/.../.21st/worktrees/{id}/{name [bracket-id]} + | 'claude-desktop' // /Users/.../.claude-worktrees/{repo}/{name} + | 'ccswitch' // /Users/.../.ccswitch/worktrees/{repo}/{name} + | 'git' // Standard git worktree (main repo or detached) + | 'unknown'; // Non-git project or undetectable + +/** + * Git repository identity for grouping worktrees. + * Multiple projects (worktrees) can share the same RepositoryIdentity. + */ +export interface RepositoryIdentity { + /** Unique identifier - hash of remote URL or main repo path */ + id: string; + /** Git remote URL if available (e.g., "https://github.com/org/repo.git") */ + remoteUrl?: string; + /** Path to the main git directory (e.g., "/Users/username/projectname/.git") */ + mainGitDir: string; + /** Display name for the repository (e.g., "projectname") */ + name: string; +} + +/** + * A worktree represents a single working directory of a git repository. + * In the grouped view, projects become worktrees under a RepositoryGroup. + */ +export interface Worktree { + /** Encoded directory name (same as Project.id) */ + id: string; + /** Decoded actual filesystem path */ + path: string; + /** Display name (worktree-specific, e.g., branch name or "main") */ + name: string; + /** Git branch name if available */ + gitBranch?: string; + /** Whether this is the main worktree (not a detached worktree) */ + isMainWorktree: boolean; + /** Worktree source for badge display (vibe-kanban, conductor, etc.) */ + source: WorktreeSource; + /** List of session IDs */ + sessions: string[]; + /** Unix timestamp when first session was created */ + createdAt: number; + /** Unix timestamp of most recent session activity */ + mostRecentSession?: number; +} + +/** + * A repository group contains all worktrees of a single git repository. + * This is the top-level entity when worktree grouping is enabled. + * Non-git projects are represented as single-worktree RepositoryGroups. + */ +export interface RepositoryGroup { + /** Unique identifier from RepositoryIdentity.id (or project.id for non-git) */ + id: string; + /** Repository identity information (null for non-git projects) */ + identity: RepositoryIdentity | null; + /** All worktrees of this repository */ + worktrees: Worktree[]; + /** Display name (derived from repo name) */ + name: string; + /** Unix timestamp of most recent session across all worktrees */ + mostRecentSession?: number; + /** Total session count across all worktrees */ + totalSessions: number; +} + +// ============================================================================= +// Search Types +// ============================================================================= + +/** + * A single search result from searching sessions. + */ +export interface SearchResult { + /** Session ID where match was found */ + sessionId: string; + /** Project ID */ + projectId: string; + /** Session title/first message */ + sessionTitle: string; + /** The matched text (trimmed) */ + matchedText: string; + /** Context around the match */ + context: string; + /** Message type (user/assistant) */ + messageType: 'user' | 'assistant'; + /** Timestamp of the message */ + timestamp: number; + /** Stable chat group ID used by in-session navigation (e.g., "user-..." or "ai-...") */ + groupId?: string; + /** Searchable item type used for in-session matching */ + itemType?: 'user' | 'ai'; + /** Match index within the item's searchable text (0-based) */ + matchIndexInItem?: number; + /** Character offset of the match within the searchable text */ + matchStartOffset?: number; + /** Source message UUID for diagnostics/fallback mapping */ + messageUuid?: string; +} + +/** + * Result of a search operation. + */ +export interface SearchSessionsResult { + /** Search results */ + results: SearchResult[]; + /** Total matches found */ + totalMatches: number; + /** Sessions searched */ + sessionsSearched: number; + /** Search query used */ + query: string; +} + +// ============================================================================= +// Pagination Types +// ============================================================================= + +/** + * Cursor for session pagination. + * Uses timestamp + sessionId as a composite cursor for stable pagination. + */ +export interface SessionCursor { + /** Unix timestamp (birthtimeMs) of the session file */ + timestamp: number; + /** Session ID for tie-breaking when timestamps are equal */ + sessionId: string; +} + +/** + * Result of paginated session listing. + */ +export interface PaginatedSessionsResult { + /** Sessions for this page */ + sessions: Session[]; + /** Cursor for next page (null if no more pages) */ + nextCursor: string | null; + /** Whether there are more sessions to load */ + hasMore: boolean; + /** Total count of sessions (for display purposes) */ + totalCount: number; +} + +/** + * Options controlling paginated session listing behavior. + */ +export interface SessionsPaginationOptions { + /** + * Whether to compute an accurate totalCount by scanning all sessions. + * Disable for faster background refreshes. + * @default true + */ + includeTotalCount?: boolean; + /** + * Whether to pre-filter all session files before paging. + * Disable for faster top-of-list refreshes. + * @default true + */ + prefilterAll?: boolean; +} diff --git a/src/main/types/index.ts b/src/main/types/index.ts new file mode 100644 index 00000000..8a83aac7 --- /dev/null +++ b/src/main/types/index.ts @@ -0,0 +1,24 @@ +/** + * Type definitions index - re-exports all types from focused modules. + * + * Import from this module to get all types: + * import { ParsedMessage, Chunk, Session } from '../types'; + * + * Or import from specific modules for focused imports: + * import { ContentBlock } from '../types/jsonl'; + * import { Session } from '../types/domain'; + * import { ParsedMessage } from '../types/messages'; + * import { Chunk, isAIChunk } from '../types/chunks'; + */ + +// JSONL format types (raw data from disk) +export * from './jsonl'; + +// Domain/business entities +export type * from './domain'; + +// Parsed message types and guards +export * from './messages'; + +// Chunk and visualization types +export * from './chunks'; diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts new file mode 100644 index 00000000..6435a707 --- /dev/null +++ b/src/main/types/jsonl.ts @@ -0,0 +1,326 @@ +/** + * JSONL format types - raw data structures from Claude Code session files. + * + * These types represent the exact format stored in .jsonl files at: + * ~/.claude/projects/{project_name}/{session_uuid}.jsonl + * + * Content type guards and entry type guards are included here as they + * operate directly on the raw JSONL structures. + */ + +// ============================================================================= +// Core Type Aliases +// ============================================================================= + +type EntryType = + | 'user' + | 'assistant' + | 'system' + | 'summary' + | 'file-history-snapshot' + | 'queue-operation'; + +type ContentType = 'text' | 'thinking' | 'tool_use' | 'tool_result' | 'image'; + +type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | null; + +// ============================================================================= +// Content Blocks +// ============================================================================= + +interface BaseContent { + type: ContentType; +} + +export interface TextContent extends BaseContent { + type: 'text'; + text: string; +} + +export interface ThinkingContent extends BaseContent { + type: 'thinking'; + thinking: string; + signature: string; +} + +export interface ToolUseContent extends BaseContent { + type: 'tool_use'; + id: string; + name: string; + input: Record; +} + +export interface ToolResultContent extends BaseContent { + type: 'tool_result'; + tool_use_id: string; + content: string | ContentBlock[]; + is_error?: boolean; +} + +export interface ImageContent extends BaseContent { + type: 'image'; + source: { + type: 'base64'; + media_type: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + data: string; + }; +} + +export type ContentBlock = + | TextContent + | ThinkingContent + | ToolUseContent + | ToolResultContent + | ImageContent; + +// ============================================================================= +// Usage Metadata +// ============================================================================= + +export interface UsageMetadata { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +} + +// ============================================================================= +// Messages +// ============================================================================= + +interface UserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +interface AssistantMessage { + role: 'assistant'; + model: string; + id: string; + type: 'message'; + content: ContentBlock[]; + stop_reason: StopReason; + stop_sequence: string | null; + usage: UsageMetadata; +} + +// ============================================================================= +// JSONL Entries +// ============================================================================= + +interface BaseEntry { + type: EntryType; + timestamp?: string; + uuid?: string; +} + +/** + * Base for conversational entries (user, assistant, system). + * + * Sidechain behavior: + * - isSidechain: false -> Main agent message + * - isSidechain: true -> Subagent message + * - sessionId: For subagents, points to parent session UUID + */ +interface ConversationalEntry extends BaseEntry { + parentUuid: string | null; + isSidechain: boolean; + userType: 'external'; + cwd: string; + sessionId: string; + version: string; + gitBranch: string; + slug?: string; +} + +/** + * Tool use result data - preserves full structure from JSONL entries. + * + * The structure varies significantly by tool type: + * - File tools: { type, success, filePath, content, structuredPatch, ... } + * - Task tools: { status, prompt, agentId, content, totalDurationMs, totalTokens, usage, ... } + * - AskUserQuestion: { questions, answers } + * - Bash: { stdout, stderr, exitCode, ... } + * + * Using Record to preserve all data without loss. + */ +export type ToolUseResultData = Record; + +/** + * CRITICAL: User entries serve two purposes: + * + * 1. Real User Input (chunk starters): + * - isMeta: false or undefined + * - content: string + * - These START new chunks + * + * 2. Response Messages (part of response flow): + * a) Internal (tool results): + * - isMeta: true + * - content: array with tool_result blocks + * b) Interruptions: + * - isMeta: false + * - content: array (not string) + */ +export interface UserEntry extends ConversationalEntry { + type: 'user'; + message: UserMessage; + isMeta?: boolean; + agentId?: string; + + toolUseResult?: ToolUseResultData; + sourceToolUseID?: string; + sourceToolAssistantUUID?: string; +} + +export interface AssistantEntry extends ConversationalEntry { + type: 'assistant'; + message: AssistantMessage; + requestId: string; + agentId?: string; +} + +export interface SystemEntry extends ConversationalEntry { + type: 'system'; + subtype: 'turn_duration' | 'init'; + durationMs: number; + isMeta: boolean; +} + +export interface SummaryEntry extends BaseEntry { + type: 'summary'; + summary: string; + leafUuid: string; +} + +export interface FileHistorySnapshotEntry extends BaseEntry { + type: 'file-history-snapshot'; + messageId: string; + snapshot: { + messageId: string; + trackedFileBackups: Record; + timestamp: string; + }; + isSnapshotUpdate: boolean; +} + +export interface QueueOperationEntry extends BaseEntry { + type: 'queue-operation'; + operation: string; +} + +export type ChatHistoryEntry = + | UserEntry + | AssistantEntry + | SystemEntry + | SummaryEntry + | FileHistorySnapshotEntry + | QueueOperationEntry; + +/** + * Conversational entries - entries that represent chat messages. + * These share common properties like message, cwd, gitBranch, etc. + */ +export type ConversationalChatEntry = UserEntry | AssistantEntry | SystemEntry; + +// ============================================================================= +// Content Type Guards +// ============================================================================= + +export function isTextContent(content: ContentBlock): content is TextContent { + return content.type === 'text'; +} + +export function isToolResultContent(content: ContentBlock): content is ToolResultContent { + return content.type === 'tool_result'; +} + +/** + * Type guard to check if an entry is a conversational entry. + */ +export function isConversationalEntry(entry: ChatHistoryEntry): entry is ConversationalChatEntry { + return entry.type === 'user' || entry.type === 'assistant' || entry.type === 'system'; +} + +// ============================================================================= +// Subagent Directory Structures +// ============================================================================= + +/** + * Claude Code supports two subagent directory structures: + * + * NEW STRUCTURE (Current): + * ~/.claude/projects/ + * {project_name}/ + * {session_uuid}.jsonl <- Main agent + * {session_uuid}/ + * agent_{agent_uuid}.jsonl <- Subagents + * + * OLD STRUCTURE (Legacy, still supported): + * ~/.claude/projects/ + * {project_name}/ + * {session_uuid}.jsonl <- Main agent + * agent_{agent_uuid}.jsonl <- Subagents (at root) + * + * Identification: + * - Main agent: isSidechain: false (or undefined) + * - Subagent: isSidechain: true + * - Linking: subagent.sessionId === parent session UUID + * + * When scanning for subagents: + * 1. First check {session_uuid}/ subdirectory (new structure) + * 2. Fall back to project root for agent_*.jsonl (old structure) + * 3. Match by sessionId field to link to parent + */ + +// ============================================================================= +// Message Flow Pattern +// ============================================================================= + +/** + * Typical conversation flow: + * + * 1. User types -> type: "user", isMeta: false, content: string -> TRIGGER MESSAGE (STARTS CHUNK) + * 2. Assistant responds -> type: "assistant", may contain tool_use -> FLOW MESSAGE (PART OF RESPONSE) + * 3. Tool executes -> type: "user", isMeta: true, contains tool_result -> FLOW MESSAGE (PART OF RESPONSE) + * 4. User interrupts -> type: "user", isMeta: false, content: array -> FLOW MESSAGE (PART OF RESPONSE) + * 5. Assistant continues -> type: "assistant" -> FLOW MESSAGE (PART OF RESPONSE) + * + * Message Categories (New 4-Category System): + * + * 1. USER MESSAGES (create UserChunks): + * - Genuine user input that initiates a new request/response cycle + * - Detected by: isParsedUserChunkMessage() type guard + * - Requirements: type='user', isMeta!=true, has text/image content + * - Excludes: , , + * - Allows: (slash commands like /model are visible user input) + * + * 2. SYSTEM MESSAGES (create SystemChunks): + * - Command output from slash commands + * - Detected by: isParsedSystemChunkMessage() type guard + * - Contains tag + * - Renders on LEFT side like AI responses + * + * 3. HARD NOISE MESSAGES (filtered out): + * - System-generated metadata that should NEVER be displayed + * - Detected by: isParsedHardNoiseMessage() type guard + * - Includes: system/summary/file-history-snapshot/queue-operation entries + * - Includes: User messages with ONLY or + * + * 4. AI MESSAGES (create AIChunks): + * - All assistant messages and flow messages between User/System/HardNoise + * - Includes: assistant messages, tool results, interruptions + * - Consecutive AI messages are grouped into single AIChunk + * - AIChunks are INDEPENDENT - no longer paired with UserChunks + * + * Key Rules: + * - User messages START UserChunks (render on RIGHT) + * - System messages START SystemChunks (render on LEFT) + * - AI messages are GROUPED into independent AIChunks (render on LEFT) + * - Hard noise messages are FILTERED OUT entirely + * + * Tool Linking: + * - tool_use.id in assistant message + * - tool_result.tool_use_id in internal user message + * - Also: sourceToolUseID field directly on internal user entry + */ diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts new file mode 100644 index 00000000..c6266d4f --- /dev/null +++ b/src/main/types/messages.ts @@ -0,0 +1,376 @@ +/** + * Parsed message types and type guards for Claude Code Context. + * + * ParsedMessage is the application's internal representation after parsing + * raw JSONL entries. This module also contains type guards for classifying + * parsed messages into categories for chunk building. + */ + +import { + EMPTY_STDERR, + EMPTY_STDOUT, + HARD_NOISE_TAGS, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + SYSTEM_OUTPUT_TAGS, +} from '../constants/messageTags'; + +import { type MessageType, type TokenUsage } from './domain'; +import { type ContentBlock, type ToolUseResultData } from './jsonl'; + +// ============================================================================= +// Tool Types +// ============================================================================= + +/** + * Tool call extracted from assistant message. + */ +export interface ToolCall { + /** Tool use ID for linking to results */ + id: string; + /** Tool name */ + name: string; + /** Tool input parameters */ + input: Record; + /** Whether this is a Task (subagent) tool call */ + isTask: boolean; + /** Task description if isTask */ + taskDescription?: string; + /** Task subagent type if isTask */ + taskSubagentType?: string; +} + +/** + * Tool result extracted from user message. + */ +export interface ToolResult { + /** Corresponding tool_use ID */ + toolUseId: string; + /** Result content */ + content: string | unknown[]; + /** Whether the tool execution errored */ + isError: boolean; +} + +// ============================================================================= +// Parsed Message +// ============================================================================= + +/** + * Parsed and enriched message from JSONL. + * This is the application's internal representation after parsing raw JSONL entries. + */ +export interface ParsedMessage { + /** Unique message identifier */ + uuid: string; + /** Parent message UUID for threading */ + parentUuid: string | null; + /** Message type */ + type: MessageType; + /** Message timestamp */ + timestamp: Date; + /** Message role if present */ + role?: string; + /** Message content (string or content blocks) */ + content: ContentBlock[] | string; + /** Token usage for this message */ + usage?: TokenUsage; + /** Model used for this response */ + model?: string; + // Metadata + /** Current working directory when message was created */ + cwd?: string; + /** Git branch context */ + gitBranch?: string; + /** Agent ID for subagent messages */ + agentId?: string; + /** Whether this is a sidechain message */ + isSidechain: boolean; + /** Whether this is a meta message */ + isMeta: boolean; + /** User type ("external" for user input) */ + userType?: string; + // Extracted tool information + /** Tool calls made in this message */ + toolCalls: ToolCall[]; + /** Tool results received in this message */ + toolResults: ToolResult[]; + /** Source tool use ID if this is a tool result message */ + sourceToolUseID?: string; + /** Source assistant UUID if this is a tool result message */ + sourceToolAssistantUUID?: string; + /** Tool use result information if this is a tool result message */ + toolUseResult?: ToolUseResultData; + /** Whether this is a compact summary boundary message */ + isCompactSummary?: boolean; +} + +// ============================================================================= +// ParsedMessage Type Guards +// ============================================================================= + +/** + * Type guard to check if a ParsedMessage is a real user message. + * This wraps the spec's type guard but works with ParsedMessage instead of UserEntry. + * + * Accepts both formats: + * - Older sessions: content as string + * - Newer sessions: content as array with text/image blocks + * + * Excludes command output messages (with ) which should + * be treated as system responses, not user input that starts new chunks. + */ +export function isParsedRealUserMessage(msg: ParsedMessage): boolean { + if (msg.type !== 'user') return false; + if (msg.isMeta) return false; + + const content = msg.content; + + // String content format (older sessions) + if (typeof content === 'string') { + return true; + } + + // Array content format (newer sessions) + if (Array.isArray(content)) { + // Check if it contains text or image blocks (real user input) + // Exclude arrays with only tool_result blocks (those are internal messages) + return content.some((block) => block.type === 'text' || block.type === 'image'); + } + + return false; +} + +/** + * Type guard for User chunk creation - genuine user input that starts User chunks. + * + * Returns true if message should create a User chunk: + * - type='user' + * - isMeta!=true + * - Has text/image content + * - Content does NOT contain: , , + * - Content MAY contain: (slash commands like /model ARE user input) + * + * Example User chunk messages: + * - "Help me debug this code" + * - "/model Switch to sonnet" + * + * NOT User chunks: + * - "Set model to..." -> System chunk + * - "..." -> Hard noise + * - "..." -> Hard noise + */ +export function isParsedUserChunkMessage(msg: ParsedMessage): boolean { + if (msg.type !== 'user') return false; + if (msg.isMeta === true) return false; + if (isParsedTeammateMessage(msg)) return false; + + const content = msg.content; + + // Check string content + if (typeof content === 'string') { + const trimmed = content.trim(); + + // Exclude messages that are system output or system metadata + // These tags indicate system-generated content, not user input + for (const tag of SYSTEM_OUTPUT_TAGS) { + if (trimmed.startsWith(tag)) { + return false; + } + } + + // is ALLOWED - it's user-initiated slash commands + // Remaining content is genuine user input + return trimmed.length > 0; + } + + // Array content format (newer sessions) + if (Array.isArray(content)) { + // Must contain text or image blocks for real user input + const hasUserContent = content.some((block) => block.type === 'text' || block.type === 'image'); + + if (!hasUserContent) { + return false; + } + + // Filter out user interruption messages (should be part of AI response flow) + // These have exactly 1 text block with content like "[Request interrupted by user]" + // or "[Request interrupted by user for tool use]" + if ( + content.length === 1 && + content[0].type === 'text' && + typeof content[0].text === 'string' && + content[0].text.startsWith('[Request interrupted by user') + ) { + return false; + } + + // Check text blocks for excluded tags + for (const block of content) { + if (block.type === 'text') { + const textBlock = block; + for (const tag of SYSTEM_OUTPUT_TAGS) { + if (textBlock.text.startsWith(tag)) { + return false; + } + } + } + } + + return true; + } + + return false; +} + +/** + * Type guard for System chunk creation - command output messages. + * + * Returns true if message should create a System chunk: + * - type='user' (confusingly, command output comes as user entries in JSONL) + * - Contains tag + * + * System chunks render on the LEFT side (like AI responses) with neutral gray styling. + * + * Example: + * ``` + * { + * type: "user", + * content: "Set model to sonnet..." + * } + * ``` + */ +export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean { + if (msg.type !== 'user') return false; + + const content = msg.content; + + if (typeof content === 'string') { + return ( + content.startsWith(LOCAL_COMMAND_STDOUT_TAG) || content.startsWith(LOCAL_COMMAND_STDERR_TAG) + ); + } + + // Array content - check text blocks + if (Array.isArray(content)) { + return content.some( + (block) => block.type === 'text' && block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG) + ); + } + + return false; +} + +/** + * Type guard to check if a ParsedMessage is an internal user message. + * This wraps the spec's type guard but works with ParsedMessage instead of UserEntry. + */ +export function isParsedInternalUserMessage(msg: ParsedMessage): boolean { + return msg.type === 'user' && msg.isMeta === true; +} + +/** + * Hard noise message (ParsedMessage version) - NEVER rendered or counted in the UI. + * This wraps isHardNoiseMessage() but works with ParsedMessage instead of ChatHistoryEntry. + * + * Filtered messages: + * - Messages with parentUuid: null (orphaned/root messages that shouldn't display) + * - e.g., compact_boundary system messages, root-level meta messages + * + * Filtered types: + * - 'system' entries + * - 'summary' entries + * - 'file-history-snapshot' entries + * - 'queue-operation' entries + * + * Filtered user messages: + * - Messages containing ONLY these system metadata tags (no real content): + * - + * - + * - Empty command output: + * - Interruption messages: [Request interrupted by user...] + * + * Filtered assistant messages: + * - Synthetic messages with model='' (system-generated placeholders) + */ +export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean { + // Filter structural metadata types - these should never be displayed + if (msg.type === 'system') return true; + if (msg.type === 'summary') return true; + if (msg.type === 'file-history-snapshot') return true; + if (msg.type === 'queue-operation') return true; + + // Filter synthetic assistant messages (system-generated placeholders) + if (msg.type === 'assistant' && msg.model === '') { + return true; + } + + // Filter user messages with ONLY system metadata tags (no real content) + if (msg.type === 'user') { + const content = msg.content; + + if (typeof content === 'string') { + // Check if content contains ONLY noise tags (trim whitespace) + const trimmedContent = content.trim(); + + // If the content is wrapped in a noise tag, it's hard noise + for (const tag of HARD_NOISE_TAGS) { + const openTag = tag; + const closeTag = tag.replace('<', 'content + */ +const TEAMMATE_MESSAGE_REGEX = /^ block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim()) + ); + } + return false; +} diff --git a/src/main/utils/contextAccumulator.ts b/src/main/utils/contextAccumulator.ts new file mode 100644 index 00000000..a768b30c --- /dev/null +++ b/src/main/utils/contextAccumulator.ts @@ -0,0 +1,34 @@ +import { type ParsedMessage, type SemanticStep } from '../types'; + +/** + * Calculate context for each step using its source message's usage data. + * Each step's context is calculated independently from its source message. + */ +export function calculateStepContext(steps: SemanticStep[], messages: ParsedMessage[]): void { + for (const step of steps) { + // Find source message for this step + const msg = messages.find((m) => m.uuid === step.sourceMessageId); + + // Calculate context from message usage + if (msg?.usage) { + const cacheRead = msg.usage.cache_read_input_tokens ?? 0; + const cacheCreation = msg.usage.cache_creation_input_tokens ?? 0; + const inputTokens = msg.usage.input_tokens ?? 0; + + // Context = input tokens sent to API (cache_read + cache_creation + regular input) + step.accumulatedContext = inputTokens + cacheRead + cacheCreation; + } else if (step.tokens) { + // For steps that already have token info (like subagents) + step.accumulatedContext = (step.tokens.input ?? 0) + (step.tokens.cached ?? 0); + } + + // Individual step doesn't contribute tokens (message-level tracking) + step.contextTokens = 0; + step.tokenBreakdown = { + input: 0, + output: 0, + cacheRead: 0, + cacheCreation: 0, + }; + } +} diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts new file mode 100644 index 00000000..1bf314cd --- /dev/null +++ b/src/main/utils/jsonl.ts @@ -0,0 +1,473 @@ +/** + * Utilities for parsing JSONL (JSON Lines) files used by Claude Code sessions. + * + * JSONL format: One JSON object per line + * - Each line is a complete, valid JSON object + * - Lines are separated by newline characters + * - Empty lines should be skipped + */ + +import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as readline from 'readline'; + +const logger = createLogger('Util:jsonl'); + +import { + type ChatHistoryEntry, + type ContentBlock, + EMPTY_METRICS, + isConversationalEntry, + isParsedUserChunkMessage, + isTextContent, + type MessageType, + type ParsedMessage, + type SessionMetrics, + type TokenUsage, + type ToolCall, +} from '../types'; + +// Import from extracted modules +import { extractToolCalls, extractToolResults } from './toolExtraction'; + +// Re-export for backwards compatibility +export { extractCwd } from './metadataExtraction'; +export { checkMessagesOngoing } from './sessionStateDetection'; + +// ============================================================================= +// Core Parsing Functions +// ============================================================================= + +/** + * Parse a JSONL file line by line using streaming. + * This avoids loading the entire file into memory. + */ +export async function parseJsonlFile(filePath: string): Promise { + const messages: ParsedMessage[] = []; + + if (!fs.existsSync(filePath)) { + return messages; + } + + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const parsed = parseJsonlLine(line); + if (parsed) { + messages.push(parsed); + } + } catch (error) { + logger.error(`Error parsing line in ${filePath}:`, error); + } + } + + return messages; +} + +/** + * Parse a single JSONL line into a ParsedMessage. + * Returns null for invalid/unsupported lines. + */ +export function parseJsonlLine(line: string): ParsedMessage | null { + if (!line.trim()) { + return null; + } + + const entry = JSON.parse(line) as ChatHistoryEntry; + return parseChatHistoryEntry(entry); +} + +// ============================================================================= +// Entry Parsing +// ============================================================================= + +/** + * Parse a single JSONL entry into a ParsedMessage. + */ +function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { + // Skip entries without uuid (usually metadata) + if (!entry.uuid) { + return null; + } + + const type = parseMessageType(entry.type); + if (!type) { + return null; + } + + // Handle different entry types + let content: string | ContentBlock[] = ''; + let role: string | undefined; + let usage: TokenUsage | undefined; + let model: string | undefined; + let cwd: string | undefined; + let gitBranch: string | undefined; + let agentId: string | undefined; + let isSidechain = false; + let isMeta = false; + let userType: string | undefined; + let sourceToolUseID: string | undefined; + let sourceToolAssistantUUID: string | undefined; + let toolUseResult: Record | undefined; + let parentUuid: string | null = null; + + // Extract properties based on entry type + let isCompactSummary = false; + if (isConversationalEntry(entry)) { + // Common properties from ConversationalEntry base + cwd = entry.cwd; + gitBranch = entry.gitBranch; + isSidechain = entry.isSidechain ?? false; + userType = entry.userType; + parentUuid = entry.parentUuid ?? null; + + // Type-specific properties + if (entry.type === 'user') { + content = entry.message.content ?? ''; + role = entry.message.role; + agentId = entry.agentId; + isMeta = entry.isMeta ?? false; + sourceToolUseID = entry.sourceToolUseID; + sourceToolAssistantUUID = entry.sourceToolAssistantUUID; + toolUseResult = entry.toolUseResult; + // Check for isCompactSummary on user entry (may exist on raw JSONL) + isCompactSummary = 'isCompactSummary' in entry && entry.isCompactSummary === true; + } else if (entry.type === 'assistant') { + content = entry.message.content; + role = entry.message.role; + usage = entry.message.usage; + model = entry.message.model; + agentId = entry.agentId; + } else if (entry.type === 'system') { + isMeta = entry.isMeta ?? false; + } + } + + // Extract tool calls and results + const toolCalls = extractToolCalls(content); + const toolResultsList = extractToolResults(content); + + return { + uuid: entry.uuid, + parentUuid, + type, + timestamp: entry.timestamp ? new Date(entry.timestamp) : new Date(), + role, + content, + usage, + model, + // Metadata + cwd, + gitBranch, + agentId, + isSidechain, + isMeta, + userType, + isCompactSummary, + // Tool info + toolCalls, + toolResults: toolResultsList, + sourceToolUseID, + sourceToolAssistantUUID, + toolUseResult, + }; +} + +/** + * Parse message type string into enum. + */ +function parseMessageType(type?: string): MessageType | null { + switch (type) { + case 'user': + return 'user'; + case 'assistant': + return 'assistant'; + case 'system': + return 'system'; + case 'summary': + return 'summary'; + case 'file-history-snapshot': + return 'file-history-snapshot'; + case 'queue-operation': + return 'queue-operation'; + default: + // Unknown types are skipped + return null; + } +} + +// ============================================================================= +// Metrics Calculation +// ============================================================================= + +/** + * Calculate session metrics from parsed messages. + */ +export function calculateMetrics(messages: ParsedMessage[]): SessionMetrics { + if (messages.length === 0) { + return { ...EMPTY_METRICS }; + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + const costUsd = 0; + + // Get timestamps for duration (loop instead of Math.min/max spread to avoid stack overflow on large sessions) + const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); + + let minTime = 0; + let maxTime = 0; + if (timestamps.length > 0) { + minTime = timestamps[0]; + maxTime = timestamps[0]; + for (let i = 1; i < timestamps.length; i++) { + if (timestamps[i] < minTime) minTime = timestamps[i]; + if (timestamps[i] > maxTime) maxTime = timestamps[i]; + } + } + + for (const msg of messages) { + if (msg.usage) { + inputTokens += msg.usage.input_tokens ?? 0; + outputTokens += msg.usage.output_tokens ?? 0; + cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0; + cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0; + } + } + + return { + durationMs: maxTime - minTime, + totalTokens: inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens, + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + messageCount: messages.length, + costUsd: costUsd > 0 ? costUsd : undefined, + }; +} + +// ============================================================================= +// Utility Functions +// ============================================================================= + +/** + * Extract text content from a message for display. + * This version applies content sanitization to filter XML-like tags. + */ +export function extractTextContent(message: ParsedMessage): string { + let rawText: string; + + if (typeof message.content === 'string') { + rawText = message.content; + } else { + rawText = message.content + .filter(isTextContent) + .map((block) => block.text) + .join('\n'); + } + + // Apply sanitization to remove XML-like tags for display + return sanitizeDisplayContent(rawText); +} + +/** + * Get all Task calls from a list of messages. + */ +export function getTaskCalls(messages: ParsedMessage[]): ToolCall[] { + return messages.flatMap((m) => m.toolCalls.filter((tc) => tc.isTask)); +} + +export interface SessionFileMetadata { + firstUserMessage: { text: string; timestamp: string } | null; + messageCount: number; + isOngoing: boolean; + gitBranch: string | null; +} + +/** + * Analyze key session metadata in a single streaming pass. + * This avoids multiple file scans when listing sessions. + */ +export async function analyzeSessionFileMetadata(filePath: string): Promise { + if (!fs.existsSync(filePath)) { + return { + firstUserMessage: null, + messageCount: 0, + isOngoing: false, + gitBranch: null, + }; + } + + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + let firstUserMessage: { text: string; timestamp: string } | null = null; + let firstCommandMessage: { text: string; timestamp: string } | null = null; + let messageCount = 0; + let gitBranch: string | null = null; + + let activityIndex = 0; + let lastEndingIndex = -1; + let hasAnyOngoingActivity = false; + let hasActivityAfterLastEnding = false; + // Track tool_use IDs that are shutdown responses so their tool_results are also ending events + const shutdownToolIds = new Set(); + + for await (const line of rl) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let entry: ChatHistoryEntry; + try { + entry = JSON.parse(trimmed) as ChatHistoryEntry; + } catch { + continue; + } + + const parsed = parseChatHistoryEntry(entry); + if (!parsed) { + continue; + } + + if (isParsedUserChunkMessage(parsed)) { + messageCount++; + } + + if (!gitBranch && 'gitBranch' in entry && entry.gitBranch) { + gitBranch = entry.gitBranch; + } + + if (!firstUserMessage && entry.type === 'user') { + const content = entry.message?.content; + if (typeof content === 'string') { + if (isCommandOutputContent(content)) { + // Skip + } else if (content.startsWith('[Request interrupted by user')) { + // Skip interruption messages + } else if (content.startsWith('')) { + if (!firstCommandMessage) { + const commandMatch = /\/([^<]+)<\/command-name>/.exec(content); + const commandName = commandMatch ? `/${commandMatch[1]}` : '/command'; + firstCommandMessage = { + text: commandName, + timestamp: entry.timestamp ?? new Date().toISOString(), + }; + } + } else { + const sanitized = sanitizeDisplayContent(content); + if (sanitized.length > 0) { + firstUserMessage = { + text: sanitized.substring(0, 500), + timestamp: entry.timestamp ?? new Date().toISOString(), + }; + } + } + } else if (Array.isArray(content)) { + const textContent = content + .filter(isTextContent) + .map((b) => b.text) + .join(' '); + if ( + textContent && + !textContent.startsWith('') && + !textContent.startsWith('[Request interrupted by user') + ) { + const sanitized = sanitizeDisplayContent(textContent); + if (sanitized.length > 0) { + firstUserMessage = { + text: sanitized.substring(0, 500), + timestamp: entry.timestamp ?? new Date().toISOString(), + }; + } + } + } + } + + // Ongoing detection with one-pass activity tracking. + if (parsed.type === 'assistant' && Array.isArray(parsed.content)) { + for (const block of parsed.content) { + if (block.type === 'thinking' && block.thinking) { + hasAnyOngoingActivity = true; + if (lastEndingIndex >= 0) { + hasActivityAfterLastEnding = true; + } + activityIndex++; + } else if (block.type === 'tool_use' && block.id) { + if (block.name === 'ExitPlanMode') { + lastEndingIndex = activityIndex++; + hasActivityAfterLastEnding = false; + } else if ( + block.name === 'SendMessage' && + block.input?.type === 'shutdown_response' && + block.input?.approve === true + ) { + // SendMessage shutdown_response = agent is shutting down (ending event) + shutdownToolIds.add(block.id); + lastEndingIndex = activityIndex++; + hasActivityAfterLastEnding = false; + } else { + hasAnyOngoingActivity = true; + if (lastEndingIndex >= 0) { + hasActivityAfterLastEnding = true; + } + activityIndex++; + } + } else if (block.type === 'text' && block.text && String(block.text).trim().length > 0) { + lastEndingIndex = activityIndex++; + hasActivityAfterLastEnding = false; + } + } + } else if (parsed.type === 'user' && Array.isArray(parsed.content)) { + // Check if this is a user-rejected tool use (ending event, not ongoing activity) + const isRejection = + 'toolUseResult' in entry && + (entry as unknown as Record).toolUseResult === 'User rejected tool use'; + + for (const block of parsed.content) { + if (block.type === 'tool_result' && block.tool_use_id) { + if (shutdownToolIds.has(block.tool_use_id) || isRejection) { + // Shutdown tool result or user rejection = ending event + lastEndingIndex = activityIndex++; + hasActivityAfterLastEnding = false; + } else { + hasAnyOngoingActivity = true; + if (lastEndingIndex >= 0) { + hasActivityAfterLastEnding = true; + } + activityIndex++; + } + } else if ( + block.type === 'text' && + typeof block.text === 'string' && + block.text.startsWith('[Request interrupted by user') + ) { + lastEndingIndex = activityIndex++; + hasActivityAfterLastEnding = false; + } + } + } + } + + return { + firstUserMessage: firstUserMessage ?? firstCommandMessage, + messageCount, + isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding, + gitBranch, + }; +} diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts new file mode 100644 index 00000000..41a24dec --- /dev/null +++ b/src/main/utils/metadataExtraction.ts @@ -0,0 +1,44 @@ +/** + * Metadata extraction utilities for parsing first messages and session context from JSONL files. + */ + +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as readline from 'readline'; + +import { type ChatHistoryEntry } from '../types'; + +const logger = createLogger('Util:metadataExtraction'); + +/** + * Extract CWD (current working directory) from the first entry. + * Used to get the actual project path from encoded directory names. + */ +export async function extractCwd(filePath: string): Promise { + if (!fs.existsSync(filePath)) { + return null; + } + + const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + if (!line.trim()) continue; + + const entry = JSON.parse(line) as ChatHistoryEntry; + // Only conversational entries have cwd + if ('cwd' in entry && entry.cwd) { + fileStream.destroy(); + return entry.cwd; + } + } + } catch (error) { + logger.error(`Error extracting cwd from ${filePath}:`, error); + } + + return null; +} diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts new file mode 100644 index 00000000..fea1038b --- /dev/null +++ b/src/main/utils/pathDecoder.ts @@ -0,0 +1,228 @@ +import * as os from 'os'; +import * as path from 'path'; + +/** + * Utility functions for encoding/decoding Claude Code project directory names. + * + * Directory naming pattern: + * - Path: /Users/username/projectname + * - Encoded: -Users-username-projectname + * + * IMPORTANT: This encoding is LOSSY for paths containing dashes. + * For accurate path resolution, use extractCwd() from jsonl.ts to read + * the actual cwd from session files. + */ + +// ============================================================================= +// Core Encoding/Decoding +// ============================================================================= + +/** + * Encodes an absolute path into Claude Code's directory naming format. + * Replaces all path separators (/ and \) with dashes. + * + * @param absolutePath - The absolute path to encode (e.g., "/Users/username/projectname") + * @returns The encoded directory name (e.g., "-Users-username-projectname") + */ +export function encodePath(absolutePath: string): string { + if (!absolutePath) { + return ''; + } + + const encoded = absolutePath.replace(/[/\\]/g, '-'); + + // Ensure leading dash for absolute paths + return encoded.startsWith('-') ? encoded : `-${encoded}`; +} + +/** + * Decodes a project directory name to its original path. + * Note: This is a best-effort decode. Paths with dashes cannot be decoded accurately. + * + * @param encodedName - The encoded directory name (e.g., "-Users-username-projectname") + * @returns The decoded path (e.g., "/Users/username/projectname") + */ +export function decodePath(encodedName: string): string { + if (!encodedName) { + return ''; + } + + // Remove leading dash if present (indicates absolute path) + const withoutLeadingDash = encodedName.startsWith('-') ? encodedName.slice(1) : encodedName; + + // Replace dashes with slashes + const decodedPath = withoutLeadingDash.replace(/-/g, '/'); + + // Windows paths may decode to "C:/..." + if (/^[a-zA-Z]:\//.test(decodedPath)) { + return decodedPath; + } + + // Ensure leading slash for POSIX-style absolute paths + return decodedPath.startsWith('/') ? decodedPath : `/${decodedPath}`; +} + +/** + * Extract the project name (last path segment) from an encoded directory name. + * + * @param encodedName - The encoded directory name + * @returns The project name + */ +export function extractProjectName(encodedName: string): string { + const decoded = decodePath(encodedName); + const segments = decoded.split('/').filter(Boolean); + return segments[segments.length - 1] || encodedName; +} + +// ============================================================================= +// Validation +// ============================================================================= + +/** + * Validates if a directory name follows the Claude Code encoding pattern. + * + * @param encodedName - The directory name to validate + * @returns true if valid, false otherwise + */ +export function isValidEncodedPath(encodedName: string): boolean { + if (!encodedName) { + return false; + } + + // Must start with a dash (indicates absolute path) + if (!encodedName.startsWith('-')) { + return false; + } + + // Allow only expected encoded characters: + // - alphanumeric, underscores, dots, spaces, dashes + // - optional ":" for Windows drive notation (e.g., -C:-Users-name-project) + const validPattern = /^-[a-zA-Z0-9_.\s:-]+$/; + if (!validPattern.test(encodedName)) { + return false; + } + + // Windows-style drive syntax is allowed only at the beginning after "-" + // e.g. "-C:-Users-name-project". Reject stray ":" elsewhere. + const firstColon = encodedName.indexOf(':'); + if (firstColon === -1) { + return true; + } + + if (!/^-[a-zA-Z]:/.test(encodedName)) { + return false; + } + + return !encodedName.includes(':', firstColon + 1); +} + +/** + * Validates a project ID that may be either a plain encoded path or + * a composite subproject ID (`{encodedPath}::{8-char-hex}`). + * + * @param projectId - The project ID to validate + * @returns true if valid + */ +export function isValidProjectId(projectId: string): boolean { + if (!projectId) { + return false; + } + + const sep = projectId.indexOf('::'); + if (sep === -1) { + // Plain encoded path + return isValidEncodedPath(projectId); + } + + // Composite ID: validate base part and hash suffix + const basePart = projectId.slice(0, sep); + const hashPart = projectId.slice(sep + 2); + + return isValidEncodedPath(basePart) && /^[a-f0-9]{8}$/.test(hashPart); +} + +/** + * Extract the base directory (encoded path) from a project ID. + * For composite IDs (`{encoded}::{hash}`), returns the encoded part. + * For plain IDs, returns the ID as-is. + */ +export function extractBaseDir(projectId: string): string { + const sep = projectId.indexOf('::'); + if (sep !== -1) { + return projectId.slice(0, sep); + } + return projectId; +} + +// ============================================================================= +// Session ID Extraction +// ============================================================================= + +/** + * Extract session ID from a JSONL filename. + * + * @param filename - The filename (e.g., "abc123.jsonl") + * @returns The session ID (e.g., "abc123") + */ +export function extractSessionId(filename: string): string { + return filename.replace(/\.jsonl$/, ''); +} + +// ============================================================================= +// Path Construction +// ============================================================================= + +/** + * Construct the path to a session JSONL file. + * Handles composite project IDs by extracting the base directory. + */ +export function buildSessionPath(basePath: string, projectId: string, sessionId: string): string { + return path.join(basePath, extractBaseDir(projectId), `${sessionId}.jsonl`); +} + +/** + * Construct the path to a session's subagents directory. + * Handles composite project IDs by extracting the base directory. + */ +export function buildSubagentsPath(basePath: string, projectId: string, sessionId: string): string { + return path.join(basePath, extractBaseDir(projectId), sessionId, 'subagents'); +} + +/** + * Construct the path to a task list file (stored in todos directory). + */ +export function buildTodoPath(claudeBasePath: string, sessionId: string): string { + return path.join(claudeBasePath, 'todos', `${sessionId}.json`); +} + +// ============================================================================= +// Home Directory +// ============================================================================= + +/** + * Get the user's home directory. + */ +function getHomeDir(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir() || '/'; +} + +/** + * Get the Claude config base path (~/.claude). + */ +function getClaudeBasePath(): string { + return path.join(getHomeDir(), '.claude'); +} + +/** + * Get the projects directory path (~/.claude/projects). + */ +export function getProjectsBasePath(): string { + return path.join(getClaudeBasePath(), 'projects'); +} + +/** + * Get the todos directory path (~/.claude/todos). + */ +export function getTodosBasePath(): string { + return path.join(getClaudeBasePath(), 'todos'); +} diff --git a/src/main/utils/pathValidation.ts b/src/main/utils/pathValidation.ts new file mode 100644 index 00000000..985fb326 --- /dev/null +++ b/src/main/utils/pathValidation.ts @@ -0,0 +1,265 @@ +/** + * Path Validation Utilities. + * + * Provides security sandboxing for file path access to prevent + * unauthorized access to sensitive system files. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** + * Sensitive file patterns that should never be accessible. + * These are checked against the normalized absolute path. + */ +const SENSITIVE_PATTERNS: RegExp[] = [ + // SSH keys and config + /[/\\]\.ssh[/\\]/i, + // AWS credentials + /[/\\]\.aws[/\\]/i, + // GCP credentials + /[/\\]\.config[/\\]gcloud[/\\]/i, + // Azure credentials + /[/\\]\.azure[/\\]/i, + // Environment files (anywhere in path) + /[/\\]\.env($|\.)/i, + // Git credentials + /[/\\]\.git-credentials$/i, + /[/\\]\.gitconfig$/i, + // NPM tokens + /[/\\]\.npmrc$/i, + // Docker credentials + /[/\\]\.docker[/\\]config\.json$/i, + // Kubernetes config + /[/\\]\.kube[/\\]config$/i, + // Password files + /[/\\]\.password/i, + /[/\\]\.secret/i, + // Private keys + /[/\\]id_rsa$/i, + /[/\\]id_ed25519$/i, + /[/\\]id_ecdsa$/i, + /[/\\][^/\\]*\.pem$/i, + /[/\\][^/\\]*\.key$/i, + // System files + /^\/etc\/passwd$/, + /^\/etc\/shadow$/, + // Credentials in filename + /credentials\.json$/i, + /secrets\.json$/i, + /tokens\.json$/i, +]; + +/** + * Result of path validation. + */ +export interface PathValidationResult { + valid: boolean; + error?: string; + normalizedPath?: string; +} + +function normalizeForCompare(input: string, isWindows: boolean): string { + const normalized = path.normalize(input); + return isWindows ? normalized.toLowerCase() : normalized; +} + +function isPathWithinRoot(targetPath: string, rootPath: string): boolean { + return targetPath === rootPath || targetPath.startsWith(rootPath + path.sep); +} + +function resolveRealPathIfExists(inputPath: string): string | null { + try { + return fs.realpathSync.native(inputPath); + } catch { + return null; + } +} + +/** + * Checks if a path matches any sensitive file patterns. + * + * @param normalizedPath - The normalized absolute path to check + * @returns true if path matches a sensitive pattern + */ +function matchesSensitivePattern(normalizedPath: string): boolean { + return SENSITIVE_PATTERNS.some((pattern) => pattern.test(normalizedPath)); +} + +/** + * Checks if a path is within allowed directories. + * + * Allowed directories: + * - The project path itself + * - The ~/.claude directory (for session data) + * + * @param normalizedPath - The normalized absolute path to check + * @param projectPath - The project root path (can be null for global access) + * @returns true if path is within allowed directories + */ +export function isPathWithinAllowedDirectories( + normalizedPath: string, + projectPath: string | null +): boolean { + const isWindows = process.platform === 'win32'; + const normalizedTarget = normalizeForCompare(normalizedPath, isWindows); + const homeDir = os.homedir(); + const claudeDir = path.join(homeDir, '.claude'); + const normalizedClaudeDir = normalizeForCompare(claudeDir, isWindows); + + // Always allow access to ~/.claude for session data + if (isPathWithinRoot(normalizedTarget, normalizedClaudeDir)) { + return true; + } + + // If project path provided, allow access within project + if (projectPath) { + const normalizedProjectPath = normalizeForCompare(projectPath, isWindows); + if (isPathWithinRoot(normalizedTarget, normalizedProjectPath)) { + return true; + } + } + + return false; +} + +/** + * Validates a file path for safe reading. + * + * Security checks performed: + * 1. Path must be absolute + * 2. Path traversal prevention (no ..) + * 3. Must be within allowed directories (project or ~/.claude) + * 4. Must not match sensitive file patterns + * + * @param filePath - The file path to validate + * @param projectPath - The project root path (can be null for global access) + * @returns Validation result with normalized path if valid + */ +export function validateFilePath( + filePath: string, + projectPath: string | null +): PathValidationResult { + // Must be a non-empty string + if (!filePath || typeof filePath !== 'string') { + return { valid: false, error: 'Invalid file path' }; + } + + // Expand ~ to home directory + const expandedPath = filePath.startsWith('~') + ? path.join(os.homedir(), filePath.slice(1)) + : filePath; + + // Must be absolute path + const normalizedInput = path.normalize(expandedPath); + if (!path.isAbsolute(normalizedInput)) { + return { valid: false, error: 'Path must be absolute' }; + } + + // Normalize and resolve the path to remove traversal segments safely + const normalizedPath = path.resolve(normalizedInput); + + // Check against sensitive patterns + if (matchesSensitivePattern(normalizedPath)) { + return { valid: false, error: 'Access to sensitive files is not allowed' }; + } + + // Check if within allowed directories + if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) { + return { + valid: false, + error: 'Path is outside allowed directories (project or ~/.claude)', + }; + } + + // If target exists, validate real path containment to prevent symlink escapes. + const realTargetPath = resolveRealPathIfExists(normalizedPath); + if (realTargetPath) { + const isWindows = process.platform === 'win32'; + const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows); + if (matchesSensitivePattern(normalizedRealTarget)) { + return { valid: false, error: 'Access to sensitive files is not allowed' }; + } + + const realProjectPath = projectPath + ? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath))) + : null; + + if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) { + return { + valid: false, + error: 'Path is outside allowed directories (project or ~/.claude)', + }; + } + } + + return { valid: true, normalizedPath }; +} + +/** + * Validates a path for shell:openPath operation. + * More permissive than file reading - allows opening project directories + * and Claude data directories. + * + * @param targetPath - The path to open + * @param projectPath - The project root path (can be null) + * @returns Validation result + */ +export function validateOpenPath( + targetPath: string, + projectPath: string | null +): PathValidationResult { + if (!targetPath || typeof targetPath !== 'string') { + return { valid: false, error: 'Invalid path' }; + } + + // Expand ~ to home directory + const expandedPath = targetPath.startsWith('~') + ? path.join(os.homedir(), targetPath.slice(1)) + : targetPath; + + const normalizedPath = path.resolve(path.normalize(expandedPath)); + + // Must be absolute after expansion + if (!path.isAbsolute(normalizedPath)) { + return { valid: false, error: 'Path must be absolute' }; + } + + // Check against sensitive patterns (still block sensitive files) + if (matchesSensitivePattern(normalizedPath)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + + // For shell:openPath, we're more permissive but still require + // the path to be within project or claude directories + if (!isPathWithinAllowedDirectories(normalizedPath, projectPath)) { + return { + valid: false, + error: 'Path is outside allowed directories', + }; + } + + // If target exists, validate real path containment to prevent symlink escapes. + const realTargetPath = resolveRealPathIfExists(normalizedPath); + if (realTargetPath) { + const isWindows = process.platform === 'win32'; + const normalizedRealTarget = normalizeForCompare(realTargetPath, isWindows); + if (matchesSensitivePattern(normalizedRealTarget)) { + return { valid: false, error: 'Cannot open sensitive files' }; + } + + const realProjectPath = projectPath + ? (resolveRealPathIfExists(projectPath) ?? path.resolve(path.normalize(projectPath))) + : null; + + if (!isPathWithinAllowedDirectories(normalizedRealTarget, realProjectPath)) { + return { + valid: false, + error: 'Path is outside allowed directories', + }; + } + } + + return { valid: true, normalizedPath }; +} diff --git a/src/main/utils/regexValidation.ts b/src/main/utils/regexValidation.ts new file mode 100644 index 00000000..0f6cdcd1 --- /dev/null +++ b/src/main/utils/regexValidation.ts @@ -0,0 +1,182 @@ +/** + * Regex Validation Utilities. + * + * Provides security validation for user-supplied regex patterns + * to prevent ReDoS (Regular Expression Denial of Service) attacks. + */ + +/** + * Maximum allowed length for a regex pattern. + */ +const MAX_PATTERN_LENGTH = 100; + +/** + * Patterns that indicate potentially problematic regex constructs. + * These can cause exponential backtracking (ReDoS). + */ +const DANGEROUS_PATTERNS: RegExp[] = [ + // Nested quantifiers: (a+)+, (a*)+, (a+)*, (a*)* + /\([^)]{0,50}[+*][^)]{0,50}\)[+*]/, + // Overlapping alternation with quantifiers: (a|a)+ + /\([^)|]{0,50}\|[^)]{0,50}\)[+*]/, + // Multiple quantifiers on same group: a{1,}+ + /[+*]\{/, + /\}[+*]/, + // Backreferences with quantifiers (can cause exponential time) + /\\[1-9][+*]/, + // Very long character classes with quantifiers + /\[[^\]]{20}\][+*]/, +]; + +/** + * Characters that need to be balanced in a valid regex. + */ +const BALANCED_PAIRS: [string, string][] = [ + ['(', ')'], + ['[', ']'], + ['{', '}'], +]; + +/** + * Result of regex pattern validation. + */ +export interface RegexValidationResult { + valid: boolean; + error?: string; +} + +/** + * Checks if brackets in a string are balanced. + */ +function areBracketsBalanced(pattern: string): boolean { + const stack: string[] = []; + const openBrackets = new Map(BALANCED_PAIRS.map(([open, close]) => [open, close])); + const closeBrackets = new Map(BALANCED_PAIRS.map(([open, close]) => [close, open])); + + let escaped = false; + let inCharClass = false; + + for (const char of pattern) { + if (escaped) { + escaped = false; + continue; + } + + if (char === '\\') { + escaped = true; + continue; + } + + // Track character class state + if (char === '[' && !inCharClass) { + inCharClass = true; + stack.push(char); + continue; + } + + if (char === ']' && inCharClass) { + inCharClass = false; + if (stack.length === 0 || stack[stack.length - 1] !== '[') { + return false; + } + stack.pop(); + continue; + } + + // Skip bracket matching inside character classes + if (inCharClass) { + continue; + } + + if (openBrackets.has(char)) { + stack.push(char); + } else if (closeBrackets.has(char)) { + const expectedOpen = closeBrackets.get(char); + if (stack.length === 0 || stack[stack.length - 1] !== expectedOpen) { + return false; + } + stack.pop(); + } + } + + return stack.length === 0; +} + +/** + * Validates a regex pattern for safety and correctness. + * + * Security checks performed: + * 1. Length limit (max 100 chars) + * 2. Dangerous pattern detection (nested quantifiers, etc.) + * 3. Balanced brackets + * 4. Valid regex syntax (via RegExp constructor) + * + * @param pattern - The regex pattern to validate + * @returns Validation result with error message if invalid + */ +export function validateRegexPattern(pattern: string): RegexValidationResult { + // Empty pattern check + if (!pattern || typeof pattern !== 'string') { + return { valid: false, error: 'Pattern must be a non-empty string' }; + } + + // Length check + if (pattern.length > MAX_PATTERN_LENGTH) { + return { + valid: false, + error: `Pattern too long (max ${MAX_PATTERN_LENGTH} characters)`, + }; + } + + // Check for dangerous patterns that could cause ReDoS + for (const dangerous of DANGEROUS_PATTERNS) { + if (dangerous.test(pattern)) { + return { + valid: false, + error: 'Pattern contains constructs that could cause performance issues', + }; + } + } + + // Check bracket balance + if (!areBracketsBalanced(pattern)) { + return { + valid: false, + error: 'Pattern has unbalanced brackets', + }; + } + + // Try to compile the regex to check for syntax errors + try { + new RegExp(pattern); + } catch (e) { + const message = e instanceof Error ? e.message : 'Unknown error'; + return { + valid: false, + error: `Invalid regex syntax: ${message}`, + }; + } + + return { valid: true }; +} + +/** + * Creates a safe RegExp from a pattern, returning null if invalid. + * This is a convenience wrapper that validates and creates the regex. + * + * @param pattern - The regex pattern + * @param flags - Optional regex flags (default: 'i' for case-insensitive) + * @returns The compiled RegExp or null if validation fails + */ +export function createSafeRegExp(pattern: string, flags: string = 'i'): RegExp | null { + const validation = validateRegexPattern(pattern); + if (!validation.valid) { + return null; + } + + try { + return new RegExp(pattern, flags); + } catch { + return null; + } +} diff --git a/src/main/utils/sessionStateDetection.ts b/src/main/utils/sessionStateDetection.ts new file mode 100644 index 00000000..6e953338 --- /dev/null +++ b/src/main/utils/sessionStateDetection.ts @@ -0,0 +1,151 @@ +/** + * Session state detection utilities for determining if sessions are ongoing. + */ + +import { type ParsedMessage } from '../types'; + +/** Activity types for tracking session state */ +type ActivityType = + | 'text_output' + | 'thinking' + | 'tool_use' + | 'tool_result' + | 'interruption' + | 'exit_plan_mode'; + +/** Activity entry with type and order index */ +interface Activity { + type: ActivityType; + index: number; +} + +/** Check if a toolUseResult value indicates a user-rejected tool use */ +function isToolUseRejection(toolUseResult: unknown): boolean { + return toolUseResult === 'User rejected tool use'; +} + +/** Check if a tool_use block is a SendMessage shutdown_response with approve: true */ +function isShutdownResponse(block: { name?: string; input?: Record }): boolean { + return ( + block.name === 'SendMessage' && + block.input?.type === 'shutdown_response' && + block.input?.approve === true + ); +} + +/** + * Check if activities indicate an ongoing session. + * Shared logic used by checkMessagesOngoing. + * + * @param activities - Array of tracked activities in order + * @returns boolean - true if ongoing + */ +function isOngoingFromActivities(activities: Activity[]): boolean { + if (activities.length === 0) { + return false; + } + + // Find the index of the last "ending" event (text_output, interruption, or exit_plan_mode) + let lastEndingIndex = -1; + for (let i = activities.length - 1; i >= 0; i--) { + const actType = activities[i].type; + if (actType === 'text_output' || actType === 'interruption' || actType === 'exit_plan_mode') { + lastEndingIndex = activities[i].index; + break; + } + } + + // If no ending event found, check if there's any AI activity at all + if (lastEndingIndex === -1) { + return activities.some( + (a) => a.type === 'thinking' || a.type === 'tool_use' || a.type === 'tool_result' + ); + } + + // Check if there are any AI activities AFTER the last ending event + for (const activity of activities) { + if ( + activity.index > lastEndingIndex && + (activity.type === 'thinking' || + activity.type === 'tool_use' || + activity.type === 'tool_result') + ) { + return true; + } + } + + return false; +} + +/** + * Check if messages indicate an ongoing session (AI response in progress). + * + * A session is considered "ongoing" if there are AI-related activities + * (thinking, tool_use, tool_result) AFTER the last "ending" event (text output or interruption). + * + * Special case: ExitPlanMode tool_use is treated as an ending event, not a continuation. + * This is because ExitPlanMode signals the end of plan mode and contains the final plan content. + * + * This is the core logic shared between session files and subagent messages. + * + * @param messages - Array of ParsedMessage to check + * @returns boolean - true if ongoing + */ +export function checkMessagesOngoing(messages: ParsedMessage[]): boolean { + const activities: Activity[] = []; + let activityIndex = 0; + // Track tool_use IDs that are shutdown responses so their tool_results are also ending events + const shutdownToolIds = new Set(); + + for (const msg of messages) { + if (msg.type === 'assistant' && Array.isArray(msg.content)) { + // Process assistant message content blocks + for (const block of msg.content) { + if (block.type === 'thinking' && block.thinking) { + activities.push({ type: 'thinking', index: activityIndex++ }); + } else if (block.type === 'tool_use' && block.id) { + // ExitPlanMode is a special ending tool - treat it like an ending event + if (block.name === 'ExitPlanMode') { + activities.push({ type: 'exit_plan_mode', index: activityIndex++ }); + } else if (isShutdownResponse(block)) { + // SendMessage shutdown_response = agent is shutting down (ending event) + shutdownToolIds.add(block.id); + activities.push({ type: 'interruption', index: activityIndex++ }); + } else { + activities.push({ type: 'tool_use', index: activityIndex++ }); + } + } else if (block.type === 'text' && block.text && String(block.text).trim().length > 0) { + activities.push({ type: 'text_output', index: activityIndex++ }); + } + } + } else if (msg.type === 'user' && Array.isArray(msg.content)) { + // Check if this is a user-rejected tool use (ending event, not ongoing activity) + const isRejection = isToolUseRejection(msg.toolUseResult); + + // Check for tool results and interruptions in internal user messages + for (const block of msg.content) { + if (block.type === 'tool_result' && block.tool_use_id) { + if (shutdownToolIds.has(block.tool_use_id)) { + // Shutdown tool result = ending event + activities.push({ type: 'interruption', index: activityIndex++ }); + } else if (isRejection) { + // User rejection = ending event (like interruption) + activities.push({ type: 'interruption', index: activityIndex++ }); + } else { + activities.push({ type: 'tool_result', index: activityIndex++ }); + } + } + // Check for interruption message - this ends the session + if ( + block.type === 'text' && + typeof block.text === 'string' && + block.text.startsWith('[Request interrupted by user') + ) { + activities.push({ type: 'interruption', index: activityIndex++ }); + } + } + } + } + + return isOngoingFromActivities(activities); +} diff --git a/src/main/utils/timelineGapFilling.ts b/src/main/utils/timelineGapFilling.ts new file mode 100644 index 00000000..c5b0fe1e --- /dev/null +++ b/src/main/utils/timelineGapFilling.ts @@ -0,0 +1,53 @@ +import { type SemanticStep } from '../types'; + +interface GapFillingInput { + steps: SemanticStep[]; + chunkStartTime: Date; + chunkEndTime: Date; +} + +/** + * Fill timeline gaps so steps extend to next step's start. + * Handles parallel steps (don't extend past each other). + * Preserves real timing for subagents. + */ +export function fillTimelineGaps(input: GapFillingInput): SemanticStep[] { + const { steps, chunkEndTime } = input; + + if (steps.length === 0) return []; + + // Sort by startTime + const sorted = [...steps].sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + for (let i = 0; i < sorted.length; i++) { + const step = sorted[i]; + + // Keep original timing for subagents and steps with meaningful duration + if (step.type === 'subagent' && step.endTime && step.durationMs > 100) { + step.effectiveEndTime = step.endTime; + step.effectiveDurationMs = step.durationMs; + step.isGapFilled = false; + continue; + } + + // Find next non-parallel step + let nextStepStart: Date | null = null; + for (let j = i + 1; j < sorted.length; j++) { + const candidate = sorted[j]; + + // Skip parallel siblings (within 100ms window) + const timeDiff = candidate.startTime.getTime() - step.startTime.getTime(); + if (timeDiff < 100) continue; + + nextStepStart = candidate.startTime; + break; + } + + // Set effective end time + step.effectiveEndTime = nextStepStart ?? chunkEndTime; + step.effectiveDurationMs = step.effectiveEndTime.getTime() - step.startTime.getTime(); + step.isGapFilled = true; + } + + return sorted; +} diff --git a/src/main/utils/tokenizer.ts b/src/main/utils/tokenizer.ts new file mode 100644 index 00000000..9951fb4e --- /dev/null +++ b/src/main/utils/tokenizer.ts @@ -0,0 +1,46 @@ +/** + * Tokenizer utility for token counting. + * + * This module provides functions to estimate tokens in text content by + * dividing character length by 4. + * + * Usage: + * - Main process: Import and use directly + * - Renderer: Token counts should be pre-computed in main process and passed via IPC + */ + +/** + * Count tokens in a string by dividing length by 4. + * Uses character count estimation instead of exact tokenizer. + * + * @param text - The text to tokenize + * @returns Number of tokens (estimated) + */ +export function countTokens(text: string | undefined | null): number { + if (!text || text.length === 0) { + return 0; + } + + // Estimate tokens using character length / 4 approximation + return Math.ceil(text.length / 4); +} + +/** + * Count tokens for content that may be a string or array. + * Arrays are stringified before counting. + * + * @param content - String or array content + * @returns Number of tokens + */ +export function countContentTokens(content: string | unknown[] | undefined | null): number { + if (!content) { + return 0; + } + + if (typeof content === 'string') { + return countTokens(content); + } + + // For array content, stringify and count + return countTokens(JSON.stringify(content)); +} diff --git a/src/main/utils/toolExtraction.ts b/src/main/utils/toolExtraction.ts new file mode 100644 index 00000000..de963083 --- /dev/null +++ b/src/main/utils/toolExtraction.ts @@ -0,0 +1,63 @@ +/** + * Tool extraction utilities for parsing tool calls and results from JSONL content blocks. + */ + +import type { ContentBlock, ToolCall, ToolResult } from '../types'; + +/** + * Extract tool calls from content blocks. + */ +export function extractToolCalls(content: ContentBlock[] | string): ToolCall[] { + if (typeof content === 'string') { + return []; + } + + const toolCalls: ToolCall[] = []; + + for (const block of content) { + if (block.type === 'tool_use' && block.id && block.name) { + const input = block.input ?? {}; + const isTask = block.name === 'Task'; + + const toolCall: ToolCall = { + id: block.id, + name: block.name, + input, + isTask, + }; + + // Extract Task-specific info + if (isTask) { + toolCall.taskDescription = input.description as string | undefined; + toolCall.taskSubagentType = input.subagent_type as string | undefined; + } + + toolCalls.push(toolCall); + } + } + + return toolCalls; +} + +/** + * Extract tool results from content blocks. + */ +export function extractToolResults(content: ContentBlock[] | string): ToolResult[] { + if (typeof content === 'string') { + return []; + } + + const toolResults: ToolResult[] = []; + + for (const block of content) { + if (block.type === 'tool_result' && block.tool_use_id) { + toolResults.push({ + toolUseId: block.tool_use_id, + content: block.content ?? '', + isError: block.is_error ?? false, + }); + } + } + + return toolResults; +} diff --git a/src/preload/CLAUDE.md b/src/preload/CLAUDE.md new file mode 100644 index 00000000..bd5df472 --- /dev/null +++ b/src/preload/CLAUDE.md @@ -0,0 +1,61 @@ +# Preload Process + +Secure bridge between main and renderer processes via Electron's contextBridge. + +## Structure +- `index.ts` - ElectronAPI implementation +- `constants/ipcChannels.ts` - IPC channel name constants + +## ElectronAPI Organization +Groups exposed methods by domain: + +### Session APIs +- `getProjects()`, `getSessions()`, `getSessionsPaginated()` +- `getSessionDetail()`, `getSessionMetrics()`, `getWaterfallData()` +- `getSessionGroups()`, `searchSessions()`, `getAppVersion()` + +### Repository APIs +- `getRepositoryGroups()`, `getWorktreeSessions()` + +### Validation APIs +- `validatePath()`, `validateMentions()` + +### CLAUDE.md APIs +- `readClaudeMdFiles()`, `readDirectoryClaudeMd()`, `readMentionedFile()` + +### Notifications +- `notifications.{get,markRead,markAllRead,delete,clear,getUnreadCount}` +- `notifications.{onNew,onUpdated,onClicked}` - Event listeners + +### Config API +- `config.{get,update}` - Read/write config +- `config.{addTrigger,updateTrigger,removeTrigger,getTriggers,testTrigger}` +- `config.{addIgnoreRegex,removeIgnoreRegex,addIgnoreRepository,removeIgnoreRepository}` +- `config.{snooze,clearSnooze,selectFolders}` +- `config.{openInEditor,pinSession,unpinSession}` + +### Utilities +- `openPath()` - Shell operations +- `openExternal()` - Open URLs in browser +- `onFileChange()` - File watcher events +- `getZoomFactor()` - Get current zoom level +- `onZoomFactorChanged()` - Zoom change listener +- `session.scrollToLine()` - Deep link navigation + +## IPC Pattern +Config operations use `IpcResult` wrapper pattern: +```typescript +interface IpcResult { + success: boolean; + data?: T; + error?: string; +} +``` +The `invokeIpcWithResult()` helper unwraps and throws on failure. + +## Adding New IPC Methods +1. Define channel constant in `constants/ipcChannels.ts` +2. Implement handler in `src/main/ipc/{domain}.ts` +3. Register in `handlers.ts` via `register{Domain}Handlers()` +4. Add method to ElectronAPI in `preload/index.ts` +5. Update `@shared/types/ElectronAPI` if cross-process type needed diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts new file mode 100644 index 00000000..845dee5a --- /dev/null +++ b/src/preload/constants/ipcChannels.ts @@ -0,0 +1,60 @@ +/** + * IPC Channel Constants + * + * Centralized IPC channel names to avoid string duplication in preload bridge. + */ + +// ============================================================================= +// Config API Channels +// ============================================================================= + +/** Get application config */ +export const CONFIG_GET = 'config:get'; + +/** Update config section */ +export const CONFIG_UPDATE = 'config:update'; + +/** Add regex pattern to ignore list */ +export const CONFIG_ADD_IGNORE_REGEX = 'config:addIgnoreRegex'; + +/** Remove regex pattern from ignore list */ +export const CONFIG_REMOVE_IGNORE_REGEX = 'config:removeIgnoreRegex'; + +/** Add repository to ignore list */ +export const CONFIG_ADD_IGNORE_REPOSITORY = 'config:addIgnoreRepository'; + +/** Remove repository from ignore list */ +export const CONFIG_REMOVE_IGNORE_REPOSITORY = 'config:removeIgnoreRepository'; + +/** Snooze notifications */ +export const CONFIG_SNOOZE = 'config:snooze'; + +/** Clear notification snooze */ +export const CONFIG_CLEAR_SNOOZE = 'config:clearSnooze'; + +/** Add notification trigger */ +export const CONFIG_ADD_TRIGGER = 'config:addTrigger'; + +/** Update notification trigger */ +export const CONFIG_UPDATE_TRIGGER = 'config:updateTrigger'; + +/** Remove notification trigger */ +export const CONFIG_REMOVE_TRIGGER = 'config:removeTrigger'; + +/** Get all triggers */ +export const CONFIG_GET_TRIGGERS = 'config:getTriggers'; + +/** Test a trigger */ +export const CONFIG_TEST_TRIGGER = 'config:testTrigger'; + +/** Select folders dialog */ +export const CONFIG_SELECT_FOLDERS = 'config:selectFolders'; + +/** Open config file in external editor */ +export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor'; + +/** Pin a session */ +export const CONFIG_PIN_SESSION = 'config:pinSession'; + +/** Unpin a session */ +export const CONFIG_UNPIN_SESSION = 'config:unpinSession'; diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 00000000..3d1ce1e1 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,291 @@ +import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; +import { contextBridge, ipcRenderer } from 'electron'; + +import { + CONFIG_ADD_IGNORE_REGEX, + CONFIG_ADD_IGNORE_REPOSITORY, + CONFIG_ADD_TRIGGER, + CONFIG_CLEAR_SNOOZE, + CONFIG_GET, + CONFIG_GET_TRIGGERS, + CONFIG_OPEN_IN_EDITOR, + CONFIG_PIN_SESSION, + CONFIG_REMOVE_IGNORE_REGEX, + CONFIG_REMOVE_IGNORE_REPOSITORY, + CONFIG_REMOVE_TRIGGER, + CONFIG_SELECT_FOLDERS, + CONFIG_SNOOZE, + CONFIG_TEST_TRIGGER, + CONFIG_UNPIN_SESSION, + CONFIG_UPDATE, + CONFIG_UPDATE_TRIGGER, +} from './constants/ipcChannels'; + +import type { + AppConfig, + ElectronAPI, + NotificationTrigger, + SessionsPaginationOptions, + TriggerTestResult, +} from '@shared/types'; + +// ============================================================================= +// IPC Result Types and Helpers +// ============================================================================= + +/** + * Standard IPC result structure returned by main process handlers. + * All config-related IPC calls return this shape. + */ +interface IpcResult { + success: boolean; + data?: T; + error?: string; +} + +interface IpcFileChangePayload { + type: 'add' | 'change' | 'unlink'; + path: string; + projectId?: string; + sessionId?: string; + isSubagent: boolean; +} + +/** + * Type-safe IPC invoker for operations that return IpcResult. + * Throws an Error if the IPC call fails, otherwise returns the typed data. + */ +async function invokeIpcWithResult(channel: string, ...args: unknown[]): Promise { + const result = (await ipcRenderer.invoke(channel, ...args)) as IpcResult; + if (!result.success) { + throw new Error(result.error ?? 'Unknown error'); + } + return result.data as T; +} + +// Keep latest zoom factor cached even before renderer UI subscribes. +let currentZoomFactor = 1; +ipcRenderer.on( + WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, + (_event: Electron.IpcRendererEvent, zoomFactor: unknown) => { + if (typeof zoomFactor === 'number' && Number.isFinite(zoomFactor)) { + currentZoomFactor = zoomFactor; + } + } +); + +// ============================================================================= +// Electron API Implementation +// ============================================================================= + +// Expose protected methods that allow the renderer process to use +// the ipcRenderer without exposing the entire object +const electronAPI: ElectronAPI = { + getAppVersion: () => ipcRenderer.invoke('get-app-version'), + getProjects: () => ipcRenderer.invoke('get-projects'), + getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId), + getSessionsPaginated: ( + projectId: string, + cursor: string | null, + limit?: number, + options?: SessionsPaginationOptions + ) => ipcRenderer.invoke('get-sessions-paginated', projectId, cursor, limit, options), + searchSessions: (projectId: string, query: string, maxResults?: number) => + ipcRenderer.invoke('search-sessions', projectId, query, maxResults), + getSessionDetail: (projectId: string, sessionId: string) => + ipcRenderer.invoke('get-session-detail', projectId, sessionId), + getSessionMetrics: (projectId: string, sessionId: string) => + ipcRenderer.invoke('get-session-metrics', projectId, sessionId), + getWaterfallData: (projectId: string, sessionId: string) => + ipcRenderer.invoke('get-waterfall-data', projectId, sessionId), + getSubagentDetail: (projectId: string, sessionId: string, subagentId: string) => + ipcRenderer.invoke('get-subagent-detail', projectId, sessionId, subagentId), + getSessionGroups: (projectId: string, sessionId: string) => + ipcRenderer.invoke('get-session-groups', projectId, sessionId), + + // Repository grouping (worktree support) + getRepositoryGroups: () => ipcRenderer.invoke('get-repository-groups'), + getWorktreeSessions: (worktreeId: string) => + ipcRenderer.invoke('get-worktree-sessions', worktreeId), + + // Validation methods + validatePath: (relativePath: string, projectPath: string) => + ipcRenderer.invoke('validate-path', relativePath, projectPath), + validateMentions: (mentions: { type: 'path'; value: string }[], projectPath: string) => + ipcRenderer.invoke('validate-mentions', mentions, projectPath), + + // CLAUDE.md reading methods + readClaudeMdFiles: (projectRoot: string) => + ipcRenderer.invoke('read-claude-md-files', projectRoot), + readDirectoryClaudeMd: (dirPath: string) => + ipcRenderer.invoke('read-directory-claude-md', dirPath), + readMentionedFile: (absolutePath: string, projectRoot: string, maxTokens?: number) => + ipcRenderer.invoke('read-mentioned-file', absolutePath, projectRoot, maxTokens), + + // Notifications API + notifications: { + get: (options?: { limit?: number; offset?: number }) => + ipcRenderer.invoke('notifications:get', options), + markRead: (id: string) => ipcRenderer.invoke('notifications:markRead', id), + markAllRead: () => ipcRenderer.invoke('notifications:markAllRead'), + delete: (id: string) => ipcRenderer.invoke('notifications:delete', id), + clear: () => ipcRenderer.invoke('notifications:clear'), + getUnreadCount: () => ipcRenderer.invoke('notifications:getUnreadCount'), + onNew: (callback: (event: unknown, error: unknown) => void): (() => void) => { + ipcRenderer.on( + 'notification:new', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + 'notification:new', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + onUpdated: ( + callback: (event: unknown, payload: { total: number; unreadCount: number }) => void + ): (() => void) => { + ipcRenderer.on( + 'notification:updated', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + 'notification:updated', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + onClicked: (callback: (event: unknown, data: unknown) => void): (() => void) => { + ipcRenderer.on( + 'notification:clicked', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + 'notification:clicked', + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }, + + // Config API - uses typed helper to unwrap { success, data, error } responses + config: { + get: async (): Promise => { + return invokeIpcWithResult(CONFIG_GET); + }, + update: async (section: string, data: object): Promise => { + return invokeIpcWithResult(CONFIG_UPDATE, section, data); + }, + addIgnoreRegex: async (pattern: string): Promise => { + await invokeIpcWithResult(CONFIG_ADD_IGNORE_REGEX, pattern); + // Re-fetch config after mutation + return invokeIpcWithResult(CONFIG_GET); + }, + removeIgnoreRegex: async (pattern: string): Promise => { + await invokeIpcWithResult(CONFIG_REMOVE_IGNORE_REGEX, pattern); + return invokeIpcWithResult(CONFIG_GET); + }, + addIgnoreRepository: async (repositoryId: string): Promise => { + await invokeIpcWithResult(CONFIG_ADD_IGNORE_REPOSITORY, repositoryId); + return invokeIpcWithResult(CONFIG_GET); + }, + removeIgnoreRepository: async (repositoryId: string): Promise => { + await invokeIpcWithResult(CONFIG_REMOVE_IGNORE_REPOSITORY, repositoryId); + return invokeIpcWithResult(CONFIG_GET); + }, + snooze: async (minutes: number): Promise => { + await invokeIpcWithResult(CONFIG_SNOOZE, minutes); + return invokeIpcWithResult(CONFIG_GET); + }, + clearSnooze: async (): Promise => { + await invokeIpcWithResult(CONFIG_CLEAR_SNOOZE); + return invokeIpcWithResult(CONFIG_GET); + }, + addTrigger: async (trigger: Omit): Promise => { + await invokeIpcWithResult(CONFIG_ADD_TRIGGER, trigger); + // Return updated config + return invokeIpcWithResult(CONFIG_GET); + }, + updateTrigger: async ( + triggerId: string, + updates: Partial + ): Promise => { + await invokeIpcWithResult(CONFIG_UPDATE_TRIGGER, triggerId, updates); + // Return updated config + return invokeIpcWithResult(CONFIG_GET); + }, + removeTrigger: async (triggerId: string): Promise => { + await invokeIpcWithResult(CONFIG_REMOVE_TRIGGER, triggerId); + // Return updated config + return invokeIpcWithResult(CONFIG_GET); + }, + getTriggers: async (): Promise => { + return invokeIpcWithResult(CONFIG_GET_TRIGGERS); + }, + testTrigger: async (trigger: NotificationTrigger): Promise => { + return invokeIpcWithResult(CONFIG_TEST_TRIGGER, trigger); + }, + selectFolders: async (): Promise => { + return invokeIpcWithResult(CONFIG_SELECT_FOLDERS); + }, + openInEditor: async (): Promise => { + return invokeIpcWithResult(CONFIG_OPEN_IN_EDITOR); + }, + pinSession: async (projectId: string, sessionId: string): Promise => { + return invokeIpcWithResult(CONFIG_PIN_SESSION, projectId, sessionId); + }, + unpinSession: async (projectId: string, sessionId: string): Promise => { + return invokeIpcWithResult(CONFIG_UNPIN_SESSION, projectId, sessionId); + }, + }, + + // Deep link navigation + session: { + scrollToLine: (sessionId: string, lineNumber: number) => + ipcRenderer.invoke('session:scrollToLine', sessionId, lineNumber), + }, + + // Zoom factor sync (used for traffic-light-safe layout) + getZoomFactor: async (): Promise => currentZoomFactor, + onZoomFactorChanged: (callback: (zoomFactor: number) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, zoomFactor: unknown): void => { + if (typeof zoomFactor !== 'number' || !Number.isFinite(zoomFactor)) return; + currentZoomFactor = zoomFactor; + callback(zoomFactor); + }; + ipcRenderer.on(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener); + return (): void => { + ipcRenderer.removeListener(WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, listener); + }; + }, + + // File change events (real-time updates) + onFileChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void => + callback(data); + ipcRenderer.on('file-change', listener); + return (): void => { + ipcRenderer.removeListener('file-change', listener); + }; + }, + + // Shell operations + openPath: (targetPath: string, projectRoot?: string) => + ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), + openExternal: (url: string) => ipcRenderer.invoke('shell:openExternal', url), + + onTodoChange: (callback: (event: IpcFileChangePayload) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: IpcFileChangePayload): void => + callback(data); + ipcRenderer.on('todo-change', listener); + return (): void => { + ipcRenderer.removeListener('todo-change', listener); + }; + }, +}; + +// Use contextBridge to securely expose the API to the renderer process +contextBridge.exposeInMainWorld('electronAPI', electronAPI); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx new file mode 100644 index 00000000..82446a5d --- /dev/null +++ b/src/renderer/App.tsx @@ -0,0 +1,32 @@ +import React, { useEffect } from 'react'; + +import { ErrorBoundary } from './components/common/ErrorBoundary'; +import { TabbedLayout } from './components/layout/TabbedLayout'; +import { useTheme } from './hooks/useTheme'; +import { initializeNotificationListeners } from './store'; + +export const App = (): React.JSX.Element => { + // Initialize theme on app load + useTheme(); + + // Dismiss splash screen once React is ready + useEffect(() => { + const splash = document.getElementById('splash'); + if (splash) { + splash.style.opacity = '0'; + setTimeout(() => splash.remove(), 300); + } + }, []); + + // Initialize IPC event listeners (notifications, file changes) + useEffect(() => { + const cleanup = initializeNotificationListeners(); + return cleanup; + }, []); + + return ( + + + + ); +}; diff --git a/src/renderer/CLAUDE.md b/src/renderer/CLAUDE.md new file mode 100644 index 00000000..a9346ae1 --- /dev/null +++ b/src/renderer/CLAUDE.md @@ -0,0 +1,82 @@ +# Renderer Process + +React application running in Chromium. + +## Structure +- `App.tsx` - Root layout +- `main.tsx` - React entry point +- `index.css` - Global styles with Tailwind +- `components/` - UI components by feature +- `store/` - Zustand state (slices pattern) +- `hooks/` - Custom React hooks +- `utils/` - Renderer utilities +- `types/` - Renderer type definitions +- `constants/` - CSS variables (`cssVariables.ts`), layout constants (`layout.ts`), team colors (`teamColors.ts`) +- `contexts/` - React contexts (`TabUIContext.tsx`, `useTabUIContext.ts`) + +## Component Organization +``` +components/ +├── chat/ # Chat display, message items, viewers, context panel +├── common/ # Shared components (badges, dropdowns, token display) +├── dashboard/ # Dashboard views +├── layout/ # Layout components (headers, shells) +├── notifications/ # Notification panels and badges +├── search/ # Search UI and results +├── settings/ # Settings UI +└── sidebar/ # Sidebar navigation +``` + +## Types (`types/`) +- `data.ts` - Core data types (ParsedMessage, SemanticStep, SessionMetrics) +- `groups.ts` - Chat groups (UserGroup, AIGroup, SystemGroup, AIGroupDisplayItem union) +- `contextInjection.ts` - Context tracking (ContextInjection union, ContextStats, ContextPhaseInfo) +- `claudeMd.ts` - CLAUDE.md injection types +- `panes.ts` - Pane layout types +- `tabs.ts` - Tab management types +- `notifications.ts` - Notification types +- `api.ts` - API types + +## Utils (`utils/`) +- `contextTracker.ts` - Visible context tracking (computeContextStats, processSessionContextWithPhases) +- `claudeMdTracker.ts` - CLAUDE.md injection detection +- `aiGroupEnhancer.ts` - AI group enrichment (linkToolCallsToResults, buildDisplayItems) +- `aiGroupHelpers.ts` - AI group utility functions +- `displayItemBuilder.ts` - Display item construction +- `displaySummary.ts` - Display summary generation +- `formatters.ts` - Display formatting +- `groupTransformer.ts` - Chat item grouping +- `lastOutputDetector.ts` - Last output detection +- `modelExtractor.ts` - Model name extraction +- `pathDisplay.ts` - Path display formatting +- `pathUtils.ts` - Path utility functions +- `slashCommandExtractor.ts` - Slash command extraction +- `stringUtils.ts` - String utility functions +- `toolLinkingEngine.ts` - Tool call/result linking +- `toolRendering/` - Tool rendering helpers + - `toolContentChecks.ts` - Tool content validation + - `toolSummaryHelpers.ts` - Tool summary formatting + - `toolTokens.ts` - Tool token utilities + +## Hooks +- `useAutoScrollBottom` - Auto-scroll chat to bottom +- `useKeyboardShortcuts` - Keyboard shortcuts +- `useTabNavigationController` - Turn navigation with highlighting +- `useTabUI` - Per-tab UI state access +- `useTheme` - Dark/light theme toggle +- `useVisibleAIGroup` - Viewport-aware AI group tracking +- `useZoomFactor` - Zoom level management +- `navigation/utils.ts` - Navigation utility functions + +## Contexts +- `contexts/TabUIContext.tsx` - Per-tab UI state isolation +- `contexts/useTabUIContext.ts` - Context consumer hook + +## State Management +Zustand store with slices pattern: +- Each domain has data, selectedId, loading, error +- Actions grouped by domain +- Selectors for derived state + +## Virtual Scrolling +Use `@tanstack/react-virtual` for large lists (sessions, messages). diff --git a/src/renderer/components/CLAUDE.md b/src/renderer/components/CLAUDE.md new file mode 100644 index 00000000..99978beb --- /dev/null +++ b/src/renderer/components/CLAUDE.md @@ -0,0 +1,81 @@ +# Components + +UI components organized by feature domain. + +## Structure +``` +components/ +├── chat/ # Session message display +│ ├── items/ # Individual message/tool items +│ │ ├── linkedTool/ # Tool call/result display helpers +│ │ ├── BaseItem # Base item wrapper +│ │ ├── baseItemHelpers # Base item utility functions +│ │ ├── ExecutionTrace # Execution trace display +│ │ ├── LinkedToolItem # Tool call with linked result +│ │ ├── MetricsPill # Metrics badge display +│ │ ├── SlashItem # Slash command display +│ │ ├── SubagentItem # Subagent execution display +│ │ ├── TeammateMessageItem # Team message cards +│ │ ├── ThinkingItem # Extended thinking display +│ │ └── TextItem # Text output display +│ ├── viewers/ # Content viewers (JSON, code, diff) +│ ├── SessionContextPanel/ # Visible context tracking panel +│ │ ├── components/ # Section wrappers (ClaudeMdFilesSection, ToolOutputsSection, UserMessagesSection, etc.) +│ │ ├── items/ # Per-injection item renderers (ClaudeMdItem, ToolOutputItem, UserMessageItem, etc.) +│ │ ├── DirectoryTree/ # CLAUDE.md directory navigation +│ │ ├── utils/ # Formatting helpers +│ │ ├── index.tsx # Main panel component +│ │ └── types.ts # SectionType constants, panel props +│ ├── AIChatGroup.tsx # AI response group display +│ ├── ChatHistory.tsx # Chat timeline container +│ ├── ChatHistoryEmptyState.tsx # Empty state display +│ ├── ChatHistoryItem.tsx # Individual history item +│ ├── ChatHistoryLoadingState.tsx # Loading state display +│ ├── CompactBoundary.tsx # Compaction event boundary marker +│ ├── ContextBadge.tsx # Per-turn context injection popover badge +│ ├── DisplayItemList.tsx # Display item list rendering +│ ├── LastOutputDisplay.tsx # Last output display +│ ├── SystemChatGroup.tsx # System message group display +│ ├── UserChatGroup.tsx # User message display +│ ├── markdownComponents.tsx # Custom markdown renderers +│ └── searchHighlightUtils.ts # Search highlight utilities +├── common/ # Shared UI primitives +│ ├── CopyButton # Copy to clipboard button +│ ├── CopyablePath # Clickable, copyable file path +│ ├── ErrorBoundary # React error boundary +│ ├── OngoingIndicator # Session in-progress indicator +│ ├── RepositoryDropdown # Repository selector dropdown +│ ├── TokenUsageDisplay # Token breakdown with context stats hover +│ └── WorktreeBadge # Git worktree badge +├── dashboard/ # Overview and listing pages +├── layout/ # App shell, sidebars, headers +├── notifications/ # Notification panels and badges +├── search/ # Search UI and results +├── settings/ # Settings pages and controls +│ ├── components/ # Reusable setting controls (SettingRow, SettingsToggle, etc.) +│ ├── hooks/ # Settings-specific hooks +│ ├── sections/ # Setting sections (General, Notifications, Advanced) +│ └── NotificationTriggerSettings/ # Trigger config UI +│ ├── components/ # Trigger form components +│ ├── hooks/ # Trigger form hooks +│ └── utils/ # Trigger utilities +└── sidebar/ # Project/session navigation +``` + +## Adding Components +1. Choose appropriate parent directory by feature +2. If used across features, place in `common/` +3. Use Tailwind with theme-aware CSS variables +4. Connect to store via `useStore()` hook if needed +5. Colocate related hooks/utils in same directory + +## Component Guidelines +- One component per file, PascalCase naming +- Use functional components with hooks +- Prefer composition over prop drilling +- Use `TabUIContext` for per-tab UI state + +## Virtual Scrolling +Use `@tanstack/react-virtual` for lists > 100 items: +- Session lists in sidebar +- Message lists in chat view diff --git a/src/renderer/components/chat/AIChatGroup.tsx b/src/renderer/components/chat/AIChatGroup.tsx new file mode 100644 index 00000000..7bf24642 --- /dev/null +++ b/src/renderer/components/chat/AIChatGroup.tsx @@ -0,0 +1,529 @@ +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { useTabUI } from '@renderer/hooks/useTabUI'; +import { useStore } from '@renderer/store'; +import { enhanceAIGroup, type PrecedingSlashInfo } from '@renderer/utils/aiGroupEnhancer'; +import { extractSlashInfo, isCommandContent } from '@shared/utils/contentSanitizer'; +import { getModelColorClass } from '@shared/utils/modelParser'; +import { estimateTokens } from '@shared/utils/tokenFormatting'; +import { format } from 'date-fns'; +import { Bot, ChevronDown, Clock } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { TokenUsageDisplay } from '../common/TokenUsageDisplay'; + +import { ContextBadge } from './ContextBadge'; +import { DisplayItemList } from './DisplayItemList'; +import { LastOutputDisplay } from './LastOutputDisplay'; + +import type { ContextStats } from '@renderer/types/contextInjection'; +import type { + AIGroup, + AIGroupDisplayItem, + EnhancedAIGroup, + UserGroup, +} from '@renderer/types/groups'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +/** + * Extract slash info from a UserGroup's message content. + * Returns PrecedingSlashInfo if the user message was a slash invocation, + * null otherwise. + */ +function extractPrecedingSlashInfo( + userGroup: UserGroup | undefined +): PrecedingSlashInfo | undefined { + if (!userGroup) return undefined; + + const msg = userGroup.message; + const content = msg.content; + + // Check if this is a slash message (has tags) + if (typeof content === 'string' && isCommandContent(content)) { + const slashInfo = extractSlashInfo(content); + if (slashInfo) { + return { + name: slashInfo.name, + message: slashInfo.message, + args: slashInfo.args, + commandMessageUuid: msg.uuid, + timestamp: new Date(msg.timestamp), + }; + } + } + + return undefined; +} + +/** + * Format duration in milliseconds to human-readable string. + * Examples: "1.2s", "45s", "1m 30s", "5m" + */ +function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + const decimal = ms % 1000 >= 100 ? `.${Math.floor((ms % 1000) / 100)}` : ''; + return `${seconds}${decimal}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (remainingSeconds === 0) { + return `${minutes}m`; + } + return `${minutes}m ${remainingSeconds}s`; +} + +interface AIChatGroupProps { + aiGroup: AIGroup; + /** Tool use ID to highlight for error deep linking */ + highlightToolUseId?: string; + /** Custom highlight color from trigger */ + highlightColor?: TriggerColor; + /** Register ref for individual tool items (for precise scroll targeting) */ + registerToolRef?: (toolId: string, el: HTMLElement | null) => void; +} + +/** + * Checks if a tool ID exists within the display items (including nested subagents). + */ +function containsToolUseId(items: AIGroupDisplayItem[], toolUseId: string): boolean { + for (const item of items) { + if (item.type === 'tool' && item.tool.id === toolUseId) { + return true; + } + // Check nested subagent messages for the tool ID + if (item.type === 'subagent' && item.subagent.messages) { + for (const msg of item.subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === toolUseId)) { + return true; + } + if (msg.toolResults?.some((tr) => tr.toolUseId === toolUseId)) { + return true; + } + } + } + } + return false; +} + +/** + * AIChatGroup displays an AI response using a clean, minimal card-based design. + * + * Features: + * - Card container with subtle zinc styling + * - Clickable header with Bot icon, "Claude" label, and items summary + * - LastOutputDisplay: Always visible last output (text or tool result) + * - DisplayItemList: Shows items when expanded with inline expansion support + * - Manages local expansion state and inline item expansion + */ +const AIChatGroupInner = ({ + aiGroup, + highlightToolUseId, + highlightColor, + registerToolRef, +}: Readonly): React.JSX.Element => { + // Per-tab UI state for expansion (completely isolated per tab) + const { + tabId, + isAIGroupExpanded: isAIGroupExpandedForTab, + toggleAIGroupExpansion, + getExpandedDisplayItemIds, + toggleDisplayItemExpansion, + expandDisplayItem, + } = useTabUI(); + + // Per-tab session data, falling back to global state + const projectRoot = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; + }); + const isSessionOngoing = useStore((s) => { + const id = s.selectedSessionId; + if (!id) return false; + return s.sessions.find((sess) => sess.id === id)?.isOngoing ?? false; + }); + + // Per-tab session data subscriptions, falling back to global state + const { + sessionClaudeMdStats, + sessionContextStats, + sessionPhaseInfo, + conversation, + searchExpandedAIGroupIds, + searchExpandedSubagentIds, + searchCurrentDisplayItemId, + } = useStore( + useShallow((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return { + sessionClaudeMdStats: td?.sessionClaudeMdStats ?? s.sessionClaudeMdStats, + sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, + sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, + conversation: td?.conversation ?? s.conversation, + searchExpandedAIGroupIds: s.searchExpandedAIGroupIds, + searchExpandedSubagentIds: s.searchExpandedSubagentIds, + searchCurrentDisplayItemId: s.searchCurrentDisplayItemId, + }; + }) + ); + + // Notification color map for tool item dots + const notifications = useStore((s) => s.notifications); + const notificationColorMap = useMemo(() => { + const map = new Map(); + for (const n of notifications) { + if (n.toolUseId && n.triggerColor) { + map.set(n.toolUseId, n.triggerColor); + } + } + return map; + }, [notifications]); + + // Derived state from store values + const claudeMdStats = sessionClaudeMdStats?.get(aiGroup.id); + const contextStats: ContextStats | undefined = sessionContextStats?.get(aiGroup.id); + + // Phase data for this AI group + const phaseNumber = sessionPhaseInfo?.aiGroupPhaseMap.get(aiGroup.id); + const totalPhases = sessionPhaseInfo?.phases.length ?? 0; + + // Find the preceding UserGroup for this AIGroup to extract slash info + // eslint-disable-next-line react-hooks/preserve-manual-memoization -- React Compiler can't preserve this; manual memo needed for O(n) traversal + const precedingSlash = useMemo(() => { + if (!conversation?.items) return undefined; + + // Find the index of this AIGroup in the conversation + const aiGroupIndex = conversation.items.findIndex( + (item) => item.type === 'ai' && item.group.id === aiGroup.id + ); + + if (aiGroupIndex <= 0) return undefined; + + // Look backwards for the nearest UserGroup + for (let i = aiGroupIndex - 1; i >= 0; i--) { + const item = conversation.items[i]; + if (item.type === 'user') { + return extractPrecedingSlashInfo(item.group); + } + // Stop if we hit another AI group (shouldn't happen in normal flow) + if (item.type === 'ai') break; + } + + return undefined; + }, [conversation?.items, aiGroup.id]); + + // Enhance the AI group to get display-ready data + const enhanced: EnhancedAIGroup = useMemo( + () => enhanceAIGroup(aiGroup, claudeMdStats, precedingSlash), + [aiGroup, claudeMdStats, precedingSlash] + ); + + // Check if this group should be expanded for search results + const shouldExpandForSearch = searchExpandedAIGroupIds.has(aiGroup.id); + + // Check if this group contains the highlighted error tool + const containsHighlightedError = useMemo(() => { + if (!highlightToolUseId) return false; + return containsToolUseId(enhanced.displayItems, highlightToolUseId); + }, [enhanced.displayItems, highlightToolUseId]); + + // Get the LAST assistant message's usage (represents current context window snapshot) + // This is the correct metric to display - not the summed values across all messages + const lastUsage = useMemo(() => { + const responses = aiGroup.responses || []; + // Find the last assistant message with usage data + for (let i = responses.length - 1; i >= 0; i--) { + const msg = responses[i]; + if (msg.type === 'assistant' && msg.usage) { + return msg.usage; + } + } + return null; + }, [aiGroup.responses]); + + // Calculate thinking and text output tokens from assistant message content blocks + // These are estimated from the actual content, providing breakdown of output token usage + const { thinkingTokens, textOutputTokens } = useMemo(() => { + let thinking = 0; + let textOutput = 0; + + const responses = aiGroup.responses || []; + for (const msg of responses) { + if (msg.type === 'assistant' && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'thinking' && block.thinking) { + thinking += estimateTokens(block.thinking); + } else if (block.type === 'text' && block.text) { + textOutput += estimateTokens(block.text); + } + } + } + } + + return { thinkingTokens: thinking, textOutputTokens: textOutput }; + }, [aiGroup.responses]); + + // Auto-expand if contains error or search result, or if manually expanded + const isExpanded = + isAIGroupExpandedForTab(aiGroup.id) || containsHighlightedError || shouldExpandForSearch; + + // Helper function to find the item ID containing the highlighted tool + const findHighlightedItemId = useCallback( + (toolUseId: string): string | null => { + for (let i = 0; i < enhanced.displayItems.length; i++) { + const item = enhanced.displayItems[i]; + if (item.type === 'tool' && item.tool.id === toolUseId) { + return `tool-${item.tool.id}-${i}`; + } + // For subagents, expand the subagent item + if (item.type === 'subagent' && item.subagent.messages) { + for (const msg of item.subagent.messages) { + if ( + msg.toolCalls?.some((tc) => tc.id === toolUseId) || + msg.toolResults?.some((tr) => tr.toolUseId === toolUseId) + ) { + return `subagent-${item.subagent.id}-${i}`; + } + } + } + } + return null; + }, + [enhanced.displayItems] + ); + + // Get expanded item IDs for this AI group (per-tab) + const expandedItemIds = useMemo( + () => getExpandedDisplayItemIds(aiGroup.id), + [getExpandedDisplayItemIds, aiGroup.id] + ); + + // Track which highlightToolUseId we've already processed to prevent infinite loops + const processedHighlightRef = useRef(null); + + // Effect to auto-expand display item when highlightToolUseId is set + // AI group expansion is now handled by the navigation coordinator + // This only handles display item expansion which requires enhanced data + useEffect(() => { + if (!highlightToolUseId || !containsHighlightedError) { + // Reset ref when highlight is cleared + if (!highlightToolUseId) { + processedHighlightRef.current = null; + } + return; + } + + // Skip if we've already processed this exact highlight + if (processedHighlightRef.current === highlightToolUseId) { + return; + } + + // Mark as processed BEFORE making any state changes + processedHighlightRef.current = highlightToolUseId; + + // Find and expand the display item containing the highlighted tool + // No delay needed - navigation coordinator ensures DOM is stable before highlight + const itemId = findHighlightedItemId(highlightToolUseId); + if (itemId) { + expandDisplayItem(aiGroup.id, itemId); + } + }, [ + highlightToolUseId, + containsHighlightedError, + aiGroup.id, + expandDisplayItem, + findHighlightedItemId, + ]); + + // Track which search we've already processed to prevent infinite loops + const processedSearchRef = useRef(null); + + // Effect to auto-expand display items when search navigates to this group + // Note: AI group expansion is handled by derived isExpanded (shouldExpandForSearch) + useEffect(() => { + if (!shouldExpandForSearch) { + processedSearchRef.current = null; + return; + } + + // Create a unique key for this search state + const searchKey = `${searchCurrentDisplayItemId ?? ''}-${Array.from(searchExpandedSubagentIds).join(',')}`; + if (processedSearchRef.current === searchKey) { + return; + } + processedSearchRef.current = searchKey; + + // Expand the specific display item containing the search result (uses per-tab state) + if (searchCurrentDisplayItemId) { + expandDisplayItem(aiGroup.id, searchCurrentDisplayItemId); + } + + // If any subagents in this group need their trace expanded for search, expand them + for (let i = 0; i < enhanced.displayItems.length; i++) { + const item = enhanced.displayItems[i]; + if (item.type === 'subagent' && searchExpandedSubagentIds.has(item.subagent.id)) { + const subagentItemId = `subagent-${item.subagent.id}-${i}`; + expandDisplayItem(aiGroup.id, subagentItemId); + } + } + }, [ + shouldExpandForSearch, + searchCurrentDisplayItemId, + searchExpandedSubagentIds, + enhanced.displayItems, + aiGroup.id, + expandDisplayItem, + ]); + + // Determine if there's content to toggle + const hasToggleContent = enhanced.displayItems.length > 0; + + // Handle item click - toggle inline expansion using store action + const handleItemClick = (itemId: string): void => { + toggleDisplayItemExpansion(aiGroup.id, itemId); + }; + + return ( +
+ {/* Header Row */} + {hasToggleContent && ( +
+ {/* Clickable toggle area */} +
toggleAIGroupExpansion(aiGroup.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleAIGroupExpansion(aiGroup.id); + } + }} + > + + + Claude + + + {/* Main agent model */} + {enhanced.mainModel && ( + + {enhanced.mainModel.name} + + )} + + {/* Subagent models if different */} + {enhanced.subagentModels.length > 0 && ( + <> + + → + + + {enhanced.subagentModels.map((m, i) => ( + + {i > 0 && ', '} + {m.name} + + ))} + + + )} + + + · + + + {enhanced.itemsSummary} + + +
+ + {/* Right side: Context badge, Token usage, Timestamp (non-clickable) */} +
+ {/* Context injection badge (CLAUDE.md, mentioned files, tool outputs) */} + {contextStats && } + + {/* Token usage - show last assistant message's usage (context window snapshot) */} + {lastUsage && ( + + )} + + {/* Duration */} + {aiGroup.durationMs > 0 && ( + + + {formatDuration(aiGroup.durationMs)} + + )} + + {/* Timestamp - receded for visual hierarchy */} + {enhanced.lastOutput?.timestamp && ( + + {format(enhanced.lastOutput.timestamp, 'h:mm:ss a')} + + )} +
+
+ )} + + {/* Expandable Content */} + {hasToggleContent && isExpanded && ( +
+ +
+ )} + + {/* Always-visible Output */} +
+ +
+
+ ); +}; + +export const AIChatGroup = React.memo(AIChatGroupInner); diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx new file mode 100644 index 00000000..143b58ed --- /dev/null +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -0,0 +1,745 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useAutoScrollBottom } from '@renderer/hooks/useAutoScrollBottom'; +import { useTabNavigationController } from '@renderer/hooks/useTabNavigationController'; +import { useTabUI } from '@renderer/hooks/useTabUI'; +import { useVisibleAIGroup } from '@renderer/hooks/useVisibleAIGroup'; +import { useStore } from '@renderer/store'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useShallow } from 'zustand/react/shallow'; + +import { SessionContextPanel } from './SessionContextPanel/index'; +import { ChatHistoryEmptyState } from './ChatHistoryEmptyState'; +import { ChatHistoryItem } from './ChatHistoryItem'; +import { ChatHistoryLoadingState } from './ChatHistoryLoadingState'; + +import type { ContextInjection } from '@renderer/types/contextInjection'; + +/** + * Waits for two requestAnimationFrame cycles, allowing the virtualizer to render. + */ +function waitForDoubleRaf(): Promise { + return new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + ); +} + +interface ChatHistoryProps { + /** Tab ID for per-tab state isolation (scroll position, deep links) */ + tabId?: string; +} + +export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { + const VIRTUALIZATION_THRESHOLD = 120; + const ESTIMATED_CHAT_ITEM_HEIGHT = 260; + + // Per-tab UI state (context panel, scroll position, expansion) from useTabUI + const { + isContextPanelVisible, + setContextPanelVisible, + savedScrollTop, + saveScrollPosition, + expandAIGroup, + expandSubagentTrace, + selectedContextPhase, + setSelectedContextPhase, + } = useTabUI(); + + // Global store subscriptions (shared data) + const { + searchQuery, + currentSearchIndex, + searchMatches, + openTabs, + activeTabId, + consumeTabNavigation, + setSearchQuery, + syncSearchMatchesWithRendered, + selectSearchMatch, + setTabVisibleAIGroup, + } = useStore( + useShallow((s) => ({ + searchQuery: s.searchQuery, + currentSearchIndex: s.currentSearchIndex, + searchMatches: s.searchMatches, + openTabs: s.openTabs, + activeTabId: s.activeTabId, + consumeTabNavigation: s.consumeTabNavigation, + setSearchQuery: s.setSearchQuery, + syncSearchMatchesWithRendered: s.syncSearchMatchesWithRendered, + selectSearchMatch: s.selectSearchMatch, + setTabVisibleAIGroup: s.setTabVisibleAIGroup, + })) + ); + + // Per-tab session data (each tab renders its own session independently) + const tabData = useStore( + useShallow((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return { + conversation: td?.conversation ?? s.conversation, + conversationLoading: td?.conversationLoading ?? s.conversationLoading, + sessionContextStats: td?.sessionContextStats ?? s.sessionContextStats, + sessionPhaseInfo: td?.sessionPhaseInfo ?? s.sessionPhaseInfo, + sessionDetail: td?.sessionDetail ?? s.sessionDetail, + }; + }) + ); + const { + conversation, + conversationLoading, + sessionContextStats, + sessionPhaseInfo, + sessionDetail, + } = tabData; + + // State for Context button hover (local state OK - doesn't need per-tab isolation) + const [isContextButtonHovered, setIsContextButtonHovered] = useState(false); + + // Determine if this tab instance is currently active + // Use tabId prop if provided, otherwise fall back to activeTabId (for backwards compatibility) + const effectiveTabId = tabId ?? activeTabId; + const isThisTabActive = effectiveTabId === activeTabId; + + // Get THIS tab's pending navigation request + const thisTab = effectiveTabId ? openTabs.find((t) => t.id === effectiveTabId) : null; + const pendingNavigation = thisTab?.pendingNavigation; + + // Compute all accumulated context injections (phase-aware) + const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => { + if (!sessionContextStats || !conversation?.items.length) { + return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined }; + } + + // Determine which phase to show + const effectivePhase = selectedContextPhase; + + // If a specific phase is selected, find the last AI group in that phase + let targetAiGroupId: string | undefined; + if (effectivePhase !== null && sessionPhaseInfo) { + const phase = sessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase); + if (phase) { + targetAiGroupId = phase.lastAIGroupId; + } + } + + // Default: use the last AI group overall + if (!targetAiGroupId) { + const lastAiItem = [...conversation.items].reverse().find((item) => item.type === 'ai'); + if (lastAiItem?.type !== 'ai') { + return { + allContextInjections: [] as ContextInjection[], + lastAiGroupTotalTokens: undefined, + }; + } + targetAiGroupId = lastAiItem.group.id; + } + + const stats = sessionContextStats.get(targetAiGroupId); + const injections = stats?.accumulatedInjections ?? []; + + // Get total tokens from the target AI group + let totalTokens: number | undefined; + const targetItem = conversation.items.find( + (item) => item.type === 'ai' && item.group.id === targetAiGroupId + ); + if (targetItem?.type === 'ai') { + const responses = targetItem.group.responses || []; + for (let i = responses.length - 1; i >= 0; i--) { + const msg = responses[i]; + if (msg.type === 'assistant' && msg.usage) { + const usage = msg.usage; + totalTokens = + (usage.input_tokens ?? 0) + + (usage.output_tokens ?? 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + break; + } + } + } + + return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens }; + }, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]); + + // State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel) + const [isNavigationHighlight, setIsNavigationHighlight] = useState(false); + const navigationHighlightTimerRef = useRef | null>(null); + + // Refs map for AI groups, chat items, and individual tool items (for scrolling) + const aiGroupRefs = useRef>(new Map()); + const chatItemRefs = useRef>(new Map()); + const toolItemRefs = useRef>(new Map()); + + // Shared scroll container ref - used by both auto-scroll and navigation coordinator + const scrollContainerRef = useRef(null); + + const isSearchActive = searchQuery.trim().length > 0; + const shouldVirtualize = (conversation?.items.length ?? 0) >= VIRTUALIZATION_THRESHOLD; + const emptyRenderedSyncCountRef = useRef(0); + + const setSearchQueryForTab = useCallback( + (query: string): void => { + setSearchQuery(query, conversation); + }, + [setSearchQuery, conversation] + ); + + const groupIndexMap = useMemo(() => { + const map = new Map(); + if (!conversation?.items) { + return map; + } + conversation.items.forEach((item, index) => { + map.set(item.group.id, index); + }); + return map; + }, [conversation]); + + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ESTIMATED_CHAT_ITEM_HEIGHT, + overscan: 8, + measureElement: (element) => element.getBoundingClientRect().height, + }); + + const ensureGroupVisible = useCallback( + async (groupId: string) => { + if (!shouldVirtualize) { + return; + } + const index = groupIndexMap.get(groupId); + if (index === undefined) { + return; + } + rowVirtualizer.scrollToIndex(index, { align: 'center' }); + // Wait 2 RAF frames so the virtualizer has time to render the target row + await waitForDoubleRaf(); + }, + [groupIndexMap, rowVirtualizer, shouldVirtualize] + ); + + // Sticky context button height (py-3 = 12px padding * 2 + button height ~28px + pt-3 = 12px) + // Total: approximately 52px, round up to 60px for safety + const STICKY_BUTTON_OFFSET = allContextInjections.length > 0 ? 60 : 0; + + // Unified navigation controller - replaces useNavigationCoordinator + useSearchContextNavigation + // Must be created before useAutoScrollBottom so we can pass shouldDisableAutoScroll + const { + highlightedGroupId, + setHighlightedGroupId, + highlightToolUseId: controllerToolUseId, + isSearchHighlight, + highlightColor, + shouldDisableAutoScroll, + } = useTabNavigationController({ + isActiveTab: isThisTabActive, + pendingNavigation, + conversation, + conversationLoading, + consumeTabNavigation, + tabId: effectiveTabId ?? '', + aiGroupRefs, + chatItemRefs, + toolItemRefs, + expandAIGroup, + expandSubagentTrace, + scrollContainerRef, + stickyOffset: STICKY_BUTTON_OFFSET, + ensureGroupVisible, + setSearchQuery: setSearchQueryForTab, + selectSearchMatch, + }); + + const effectiveHighlightToolUseId = controllerToolUseId ?? undefined; + + // Keep search match indices aligned with this tab's rendered conversation. + // This avoids stale/global match lists after tab switches or in-place refreshes. + useEffect(() => { + if (!isThisTabActive || !searchQuery.trim()) { + return; + } + setSearchQuery(searchQuery, conversation); + }, [isThisTabActive, searchQuery, conversation, setSearchQuery]); + + // Canonicalize matches from rendered mark elements (DOM order). + // This guarantees that nth navigation follows the exact nth visible highlight. + // Skip when virtualizing: only a subset of items are rendered, so DOM-based sync + // would produce an incomplete match list. The store-level matches are already correct. + useEffect(() => { + if (!isThisTabActive || !isSearchActive || !conversation || shouldVirtualize) { + emptyRenderedSyncCountRef.current = 0; + return; + } + + let frameA = 0; + let frameB = 0; + let cancelled = false; + + const run = (): void => { + const container = scrollContainerRef.current; + if (!container || cancelled) return; + + const renderedMatches: { itemId: string; matchIndexInItem: number }[] = []; + const marks = container.querySelectorAll( + 'mark[data-search-item-id][data-search-match-index]' + ); + for (const mark of marks) { + const itemId = mark.dataset.searchItemId; + const matchIndexRaw = mark.dataset.searchMatchIndex; + const matchIndex = matchIndexRaw !== undefined ? Number(matchIndexRaw) : Number.NaN; + if (!itemId || !Number.isFinite(matchIndex)) continue; + renderedMatches.push({ itemId, matchIndexInItem: matchIndex }); + } + + // Prevent transient "0 marks" snapshots during mount from wiping results. + if (renderedMatches.length === 0 && searchMatches.length > 0) { + emptyRenderedSyncCountRef.current += 1; + if (emptyRenderedSyncCountRef.current < 3) { + return; + } + } else { + emptyRenderedSyncCountRef.current = 0; + } + + syncSearchMatchesWithRendered(renderedMatches); + }; + + // Wait for highlight marks to be mounted and stabilized. + frameA = requestAnimationFrame(() => { + frameB = requestAnimationFrame(run); + }); + + return () => { + cancelled = true; + cancelAnimationFrame(frameA); + cancelAnimationFrame(frameB); + }; + }, [ + isThisTabActive, + isSearchActive, + shouldVirtualize, + conversation, + currentSearchIndex, + searchMatches, + syncSearchMatchesWithRendered, + ]); + + // Track shouldDisableAutoScroll transitions for scroll restore coordination + const prevShouldDisableRef = useRef(shouldDisableAutoScroll); + + const { registerAIGroupRef } = useVisibleAIGroup({ + onVisibleChange: (aiGroupId) => { + if (effectiveTabId) { + setTabVisibleAIGroup(effectiveTabId, aiGroupId); + } + }, + threshold: 0.5, + rootRef: scrollContainerRef, + }); + + // Auto-scroll to bottom when new content is added + // Disabled during navigation to prevent conflicts with deep link scrolling + // Uses shared scrollContainerRef created above + // resetKey ensures auto-scroll state resets when switching tabs/sessions + useAutoScrollBottom([conversation?.items.length], { + threshold: 150, + smoothDuration: 300, + disabled: shouldDisableAutoScroll, + externalRef: scrollContainerRef, + resetKey: effectiveTabId, + }); + + // Callback to register AI group refs (combines with visibility hook) + const registerAIGroupRefCombined = useCallback( + (groupId: string) => { + const visibilityRef = registerAIGroupRef(groupId); + return (el: HTMLElement | null) => { + if (typeof visibilityRef === 'function') visibilityRef(el); + if (el) aiGroupRefs.current.set(groupId, el); + else aiGroupRefs.current.delete(groupId); + }; + }, + [registerAIGroupRef] + ); + + // Handler to navigate to a specific turn (AI group) from CLAUDE.md panel + const handleNavigateToTurn = useCallback( + (turnIndex: number) => { + if (!conversation) return; + const targetItem = conversation.items.find( + (item) => item.type === 'ai' && item.group.turnIndex === turnIndex + ); + if (targetItem?.type !== 'ai') return; + + const run = async (): Promise => { + const groupId = targetItem.group.id; + await ensureGroupVisible(groupId); + const element = aiGroupRefs.current.get(groupId); + if (!element) return; + + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setHighlightedGroupId(groupId); + setIsNavigationHighlight(true); + if (navigationHighlightTimerRef.current) { + clearTimeout(navigationHighlightTimerRef.current); + } + navigationHighlightTimerRef.current = setTimeout(() => { + setHighlightedGroupId(null); + setIsNavigationHighlight(false); + navigationHighlightTimerRef.current = null; + }, 2000); + }; + void run(); + }, + [conversation, ensureGroupVisible, setHighlightedGroupId] + ); + + // Scroll to current search result when it changes + useEffect(() => { + const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; + if (!currentMatch) return; + + let frameId = 0; + let attempt = 0; + let cancelled = false; + + /** + * Promote a mark element to "current" (demote any previous) and scroll to it. + */ + const promoteAndScroll = (el: HTMLElement): void => { + const container = scrollContainerRef.current; + if (container) { + container + .querySelectorAll('mark[data-search-result="current"]') + .forEach((prev) => { + /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for search result highlighting */ + prev.setAttribute('data-search-result', 'match'); + prev.style.backgroundColor = 'var(--highlight-bg-inactive)'; + prev.style.color = 'var(--highlight-text-inactive)'; + prev.style.boxShadow = ''; + /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ + }); + } + /* eslint-disable no-param-reassign -- Directly mutating DOM element style/attributes is necessary for current search result highlighting */ + el.setAttribute('data-search-result', 'current'); + el.style.backgroundColor = 'var(--highlight-bg)'; + el.style.color = 'var(--highlight-text)'; + el.style.boxShadow = '0 0 0 1px var(--highlight-ring)'; + /* eslint-enable no-param-reassign -- Re-enable after DOM mutations */ + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }; + + /** + * DOM text-search fallback: walk text nodes inside the group element to find the + * Nth occurrence of the search query, then scroll the enclosing element into view. + * This works even when React hasn't created elements (ReactMarkdown + * component memoization, render timing, etc.). + */ + const fallbackDOMSearch = (): boolean => { + const groupEl = + chatItemRefs.current.get(currentMatch.itemId) ?? + aiGroupRefs.current.get(currentMatch.itemId); + if (!groupEl) return false; + + const query = useStore.getState().searchQuery; + if (!query) return false; + const lowerQuery = query.toLowerCase(); + let count = 0; + + // Scope to [data-search-content] elements to exclude UI chrome + // (timestamps, labels, buttons) from text-node walking + const searchRoots = groupEl.querySelectorAll('[data-search-content]'); + const roots = searchRoots.length > 0 ? Array.from(searchRoots) : [groupEl]; + + for (const root of roots) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + let node: Node | null; + while ((node = walker.nextNode())) { + const text = node.textContent ?? ''; + const lowerText = text.toLowerCase(); + let pos = 0; + while ((pos = lowerText.indexOf(lowerQuery, pos)) !== -1) { + if (count === currentMatch.matchIndexInItem) { + const parent = node.parentElement; + if (parent) { + parent.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return true; + } + } + count++; + pos += lowerQuery.length; + } + } + } + return false; + }; + + const tryScrollToResult = (): void => { + const container = scrollContainerRef.current; + if (!container) return; + + // Primary: find mark by item ID + match index + const el = container.querySelector( + `mark[data-search-item-id="${CSS.escape(currentMatch.itemId)}"][data-search-match-index="${currentMatch.matchIndexInItem}"]` + ); + if (el) { + promoteAndScroll(el); + return; + } + + // Secondary: align by global order (nth rendered mark) as canonical fallback. + if (attempt >= 3) { + const orderedMarks = Array.from( + container.querySelectorAll( + 'mark[data-search-item-id][data-search-match-index]' + ) + ); + const byGlobal = orderedMarks[currentSearchIndex]; + if (byGlobal) { + promoteAndScroll(byGlobal); + return; + } + } + + // After a few frames, try fallback DOM text search + if (attempt >= 6) { + if (fallbackDOMSearch()) return; + } + + // Keep retrying (marks may appear after async render) + if (attempt < 60) { + attempt++; + frameId = requestAnimationFrame(tryScrollToResult); + } + }; + + const run = async (): Promise => { + await ensureGroupVisible(currentMatch.itemId); + if (cancelled) return; + frameId = requestAnimationFrame(tryScrollToResult); + }; + + void run(); + return () => { + cancelled = true; + cancelAnimationFrame(frameId); + }; + }, [currentSearchIndex, searchMatches, scrollContainerRef, ensureGroupVisible]); + + // Track previous active state to detect when THIS tab becomes active/inactive + const wasActiveRef = useRef(isThisTabActive); + + // Save scroll position when THIS tab becomes inactive + useEffect(() => { + const wasActive = wasActiveRef.current; + wasActiveRef.current = isThisTabActive; + + // If this tab just became inactive, save its scroll position + if (wasActive && !isThisTabActive && scrollContainerRef.current) { + saveScrollPosition(scrollContainerRef.current.scrollTop); + } + }, [isThisTabActive, saveScrollPosition, scrollContainerRef]); + + // Also save on unmount (e.g., when tab is closed) + useEffect(() => { + const scrollContainer = scrollContainerRef.current; + return () => { + if (scrollContainer) { + saveScrollPosition(scrollContainer.scrollTop); + } + }; + }, [saveScrollPosition, scrollContainerRef]); + + // Restore scroll position when THIS tab becomes active with saved position + // Uses shouldDisableAutoScroll (covers full navigation lifecycle) instead of pendingNavigation + // After navigation completes (transition true→false), save current position to prevent stale restore + useEffect(() => { + const wasDisabled = prevShouldDisableRef.current; + prevShouldDisableRef.current = shouldDisableAutoScroll; + + // Navigation just completed — save current scroll position, skip restore + if (wasDisabled && !shouldDisableAutoScroll && scrollContainerRef.current) { + saveScrollPosition(scrollContainerRef.current.scrollTop); + return; + } + + if ( + isThisTabActive && + savedScrollTop !== undefined && + scrollContainerRef.current && + !conversationLoading && + !shouldDisableAutoScroll + ) { + let frameA = 0; + let frameB = 0; + // Use double RAF so layout + virtual rows settle before restore. + frameA = requestAnimationFrame(() => { + frameB = requestAnimationFrame(() => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = savedScrollTop; + } + }); + }); + return () => { + cancelAnimationFrame(frameA); + cancelAnimationFrame(frameB); + }; + } + }, [ + isThisTabActive, + savedScrollTop, + conversationLoading, + scrollContainerRef, + shouldDisableAutoScroll, + saveScrollPosition, + ]); + + useEffect(() => { + return () => { + if (navigationHighlightTimerRef.current) { + clearTimeout(navigationHighlightTimerRef.current); + } + }; + }, []); + + // Register ref for user/system chat items + const registerChatItemRef = useCallback((groupId: string) => { + return (el: HTMLElement | null) => { + if (el) chatItemRefs.current.set(groupId, el); + else chatItemRefs.current.delete(groupId); + }; + }, []); + + // Register ref for individual tool items (for precise scroll targeting) + const registerToolRef = useCallback((toolId: string, el: HTMLElement | null) => { + if (el) toolItemRefs.current.set(toolId, el); + else toolItemRefs.current.delete(toolId); + }, []); + + // Loading state + if (conversationLoading) return ; + + // Empty state + if (!conversation || conversation.items.length === 0) return ; + + return ( +
+
+ {/* Chat content */} +
+ {/* Sticky Context button */} + {allContextInjections.length > 0 && ( +
+ +
+ )} +
0 ? '-2rem' : 0 }} + > +
+ {shouldVirtualize ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = conversation.items[virtualRow.index]; + if (!item) return null; + return ( +
+ +
+ ); + })} +
+ ) : ( + conversation.items.map((item) => ( + + )) + )} +
+
+
+ + {/* Context panel sidebar */} + {isContextPanelVisible && allContextInjections.length > 0 && ( +
+ setContextPanelVisible(false)} + projectRoot={sessionDetail?.session?.projectPath} + onNavigateToTurn={handleNavigateToTurn} + totalSessionTokens={lastAiGroupTotalTokens} + phaseInfo={sessionPhaseInfo ?? undefined} + selectedPhase={selectedContextPhase} + onPhaseChange={setSelectedContextPhase} + /> +
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/chat/ChatHistoryEmptyState.tsx b/src/renderer/components/chat/ChatHistoryEmptyState.tsx new file mode 100644 index 00000000..c98a2864 --- /dev/null +++ b/src/renderer/components/chat/ChatHistoryEmptyState.tsx @@ -0,0 +1,14 @@ +/** + * Empty state for ChatHistory when no conversation exists. + */ +export const ChatHistoryEmptyState = (): JSX.Element => { + return ( +
+
+
💬
+
No conversation history
+
This session does not contain any messages yet.
+
+
+ ); +}; diff --git a/src/renderer/components/chat/ChatHistoryItem.tsx b/src/renderer/components/chat/ChatHistoryItem.tsx new file mode 100644 index 00000000..d611fea1 --- /dev/null +++ b/src/renderer/components/chat/ChatHistoryItem.tsx @@ -0,0 +1,133 @@ +import React from 'react'; + +import { + getHighlightProps, + HIGHLIGHT_CLASSES, + isPresetColorKey, + type TriggerColor, +} from '@shared/constants/triggerColors'; + +import { AIChatGroup } from './AIChatGroup'; +import { CompactBoundary } from './CompactBoundary'; +import { SystemChatGroup } from './SystemChatGroup'; +import { UserChatGroup } from './UserChatGroup'; + +import type { ChatItem } from '@renderer/types/groups'; + +interface ChatHistoryItemProps { + readonly item: ChatItem; + readonly highlightedGroupId: string | null; + readonly highlightToolUseId?: string; + readonly isSearchHighlight: boolean; + readonly isNavigationHighlight: boolean; + readonly highlightColor?: TriggerColor; + readonly registerChatItemRef: (groupId: string) => (el: HTMLElement | null) => void; + readonly registerAIGroupRef: (groupId: string) => (el: HTMLElement | null) => void; + /** Register ref for individual tool items (for precise scroll targeting) */ + readonly registerToolRef: (toolId: string, el: HTMLElement | null) => void; +} + +/** + * Get highlight class/style based on type: search (yellow), navigation (blue), error (custom color) + */ +function getHighlight( + isHighlighted: boolean, + isSearchHighlight: boolean, + isNavigationHighlight: boolean, + highlightColor?: TriggerColor +): { className: string; style?: React.CSSProperties } { + if (!isHighlighted) return { className: 'ring-0 bg-transparent' }; + if (isSearchHighlight) return { className: 'ring-2 ring-yellow-500/30 bg-yellow-500/5' }; + if (isNavigationHighlight) return { className: 'ring-2 ring-blue-500/30 bg-blue-500/5' }; + const key = highlightColor ?? 'red'; + if (isPresetColorKey(key)) return { className: HIGHLIGHT_CLASSES[key] }; + return getHighlightProps(key); +} + +/** + * Renders a single chat history item (user, system, ai, or compact). + */ +const ChatHistoryItemInner = ({ + item, + highlightedGroupId, + highlightToolUseId, + isSearchHighlight, + isNavigationHighlight, + highlightColor, + registerChatItemRef, + registerAIGroupRef, + registerToolRef, +}: ChatHistoryItemProps): JSX.Element | null => { + switch (item.type) { + case 'user': { + const isHighlighted = highlightedGroupId === item.group.id; + const hl = getHighlight( + isHighlighted, + isSearchHighlight, + isNavigationHighlight, + highlightColor + ); + return ( +
+ +
+ ); + } + case 'system': { + const isHighlighted = highlightedGroupId === item.group.id; + const hl = getHighlight( + isHighlighted, + isSearchHighlight, + isNavigationHighlight, + highlightColor + ); + return ( +
+ +
+ ); + } + case 'ai': { + const isHighlighted = highlightedGroupId === item.group.id; + // Pass highlightToolUseId to ALL AI groups (when not search/navigation) + // Each group will check if it contains the tool and expand accordingly + // This fixes issues where timestamp matching might fail to find the correct group + const toolUseIdForGroup = + !isSearchHighlight && !isNavigationHighlight ? highlightToolUseId : undefined; + const hl = getHighlight( + isHighlighted, + isSearchHighlight, + isNavigationHighlight, + highlightColor + ); + return ( +
+ +
+ ); + } + case 'compact': + return ; + default: + return null; + } +}; + +export const ChatHistoryItem = React.memo(ChatHistoryItemInner); diff --git a/src/renderer/components/chat/ChatHistoryLoadingState.tsx b/src/renderer/components/chat/ChatHistoryLoadingState.tsx new file mode 100644 index 00000000..d03c60d7 --- /dev/null +++ b/src/renderer/components/chat/ChatHistoryLoadingState.tsx @@ -0,0 +1,24 @@ +/** + * Loading skeleton for ChatHistory while conversation is loading. + */ +export const ChatHistoryLoadingState = (): JSX.Element => { + return ( +
+
+ {/* Loading skeleton */} + {[1, 2, 3].map((i) => ( +
+ {/* User message skeleton - right aligned */} +
+
+
+ {/* AI response skeleton - left aligned with border accent */} +
+
+
+
+ ))} +
+
+ ); +}; diff --git a/src/renderer/components/chat/CompactBoundary.tsx b/src/renderer/components/chat/CompactBoundary.tsx new file mode 100644 index 00000000..e5548d41 --- /dev/null +++ b/src/renderer/components/chat/CompactBoundary.tsx @@ -0,0 +1,171 @@ +import React, { useState } from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { + CODE_BG, + CODE_BORDER, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, + TOOL_CALL_BG, + TOOL_CALL_BORDER, + TOOL_CALL_TEXT, +} from '@renderer/constants/cssVariables'; +import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting'; +import { format } from 'date-fns'; +import { ChevronRight, Layers } from 'lucide-react'; +import remarkGfm from 'remark-gfm'; + +import { CopyButton } from '../common/CopyButton'; + +import { markdownComponents } from './markdownComponents'; + +import type { CompactGroup } from '@renderer/types/groups'; + +interface CompactBoundaryProps { + compactGroup: CompactGroup; +} + +/** + * CompactBoundary displays an interactive, collapsible marker indicating where + * the conversation was compacted. + * + * Features: + * - Minimalist design with subtle border and hover states + * - Click to expand/collapse compacted content + * - Scrollable content area with enforced max-height + * - Linear/Notion-inspired aesthetics + */ +export const CompactBoundary = ({ + compactGroup, +}: Readonly): React.JSX.Element => { + const { timestamp, message } = compactGroup; + const [isExpanded, setIsExpanded] = useState(false); + + // Extract content from message + const getCompactContent = (): string => { + if (!message?.content) return ''; + + if (typeof message.content === 'string') { + return message.content; + } + + // If it's an array of content blocks, extract text + if (Array.isArray(message.content)) { + return message.content + .filter((block: { type: string; text?: string }) => block.type === 'text') + .map((block: { type: string; text?: string }) => block.text ?? '') + .join('\n\n'); + } + + return ''; + }; + + const compactContent = getCompactContent(); + + return ( +
+ {/* Collapsible Header - Amber/orange accent for distinction */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {compactContent && } + + {/* Content - scrollable with left accent bar */} +
+ {compactContent ? ( + + {compactContent} + + ) : ( +
+ +
+

+ Conversation Compacted +

+

+ Previous messages were summarized to save context. The full conversation history + is preserved in the session file. +

+
+
+ )} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/ContextBadge.tsx b/src/renderer/components/chat/ContextBadge.tsx new file mode 100644 index 00000000..737ee229 --- /dev/null +++ b/src/renderer/components/chat/ContextBadge.tsx @@ -0,0 +1,571 @@ +/** + * ContextBadge - Displays a compact badge showing unified context injections. + * Shows count of NEW injections (CLAUDE.md, mentioned files, tool outputs) with hover popover. + * Replaces the standalone ClaudeMdBadge with a unified view of all context sources. + */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { + COLOR_BORDER, + COLOR_BORDER_SUBTLE, + COLOR_SURFACE_RAISED, + COLOR_TEXT, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, +} from '@renderer/constants/cssVariables'; +import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay'; +import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting'; +import { ChevronRight } from 'lucide-react'; + +import { CopyablePath } from '../common/CopyablePath'; + +import type { + ClaudeMdContextInjection, + ContextInjection, + ContextStats, + MentionedFileInjection, + TaskCoordinationInjection, + ThinkingTextInjection, + ToolOutputInjection, + UserMessageInjection, +} from '@renderer/types/contextInjection'; + +interface ContextBadgeProps { + stats: ContextStats; + projectRoot?: string; +} + +/** + * Type guard for ClaudeMdContextInjection. + */ +function isClaudeMdInjection(inj: ContextInjection): inj is ClaudeMdContextInjection { + return inj.category === 'claude-md'; +} + +/** + * Type guard for MentionedFileInjection. + */ +function isMentionedFileInjection(inj: ContextInjection): inj is MentionedFileInjection { + return inj.category === 'mentioned-file'; +} + +/** + * Type guard for ToolOutputInjection. + */ +function isToolOutputInjection(inj: ContextInjection): inj is ToolOutputInjection { + return inj.category === 'tool-output'; +} + +/** + * Type guard for ThinkingTextInjection. + */ +function isThinkingTextInjection(inj: ContextInjection): inj is ThinkingTextInjection { + return inj.category === 'thinking-text'; +} + +/** + * Type guard for TaskCoordinationInjection. + */ +function isTaskCoordinationInjection(inj: ContextInjection): inj is TaskCoordinationInjection { + return inj.category === 'task-coordination'; +} + +/** + * Type guard for UserMessageInjection. + */ +function isUserMessageInjection(inj: ContextInjection): inj is UserMessageInjection { + return inj.category === 'user-message'; +} + +/** + * Section component for expandable groups in the popover. + */ +const PopoverSection = ({ + title, + count, + tokenCount, + children, + defaultExpanded = false, +}: Readonly<{ + title: string; + count: number; + tokenCount: number; + children: React.ReactNode; + defaultExpanded?: boolean; +}>): React.ReactElement => { + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ {/* Section header */} +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + setExpanded(!expanded); + } + }} + > + + + {title} ({count}) ~{formatTokens(tokenCount)} tokens + +
+ {/* Section content */} + {expanded &&
{children}
} +
+ ); +}; + +export const ContextBadge = ({ + stats, + projectRoot, +}: Readonly): React.ReactElement | null => { + const [showPopover, setShowPopover] = useState(false); + const [popoverStyle, setPopoverStyle] = useState({}); + const [arrowStyle, setArrowStyle] = useState({}); + const containerRef = useRef(null); + const popoverRef = useRef(null); + + // Calculate total new count + const totalNew = useMemo( + () => + stats.newCounts.claudeMd + + stats.newCounts.mentionedFiles + + stats.newCounts.toolOutputs + + stats.newCounts.thinkingText + + stats.newCounts.taskCoordination + + stats.newCounts.userMessages, + [stats.newCounts] + ); + + // Filter new injections by category + const newClaudeMdInjections = useMemo( + () => stats.newInjections.filter(isClaudeMdInjection), + [stats.newInjections] + ); + + const newMentionedFileInjections = useMemo( + () => stats.newInjections.filter(isMentionedFileInjection), + [stats.newInjections] + ); + + const newToolOutputInjections = useMemo( + () => stats.newInjections.filter(isToolOutputInjection), + [stats.newInjections] + ); + + const newThinkingTextInjections = useMemo( + () => stats.newInjections.filter(isThinkingTextInjection), + [stats.newInjections] + ); + + const newTaskCoordinationInjections = useMemo( + () => stats.newInjections.filter(isTaskCoordinationInjection), + [stats.newInjections] + ); + + const newUserMessageInjections = useMemo( + () => stats.newInjections.filter(isUserMessageInjection), + [stats.newInjections] + ); + + // Calculate total new tokens + const totalNewTokens = useMemo( + () => stats.newInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [stats.newInjections] + ); + + // Calculate token totals per section + const claudeMdTokens = useMemo( + () => newClaudeMdInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newClaudeMdInjections] + ); + + const mentionedFileTokens = useMemo( + () => newMentionedFileInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newMentionedFileInjections] + ); + + const toolOutputTokens = useMemo( + () => newToolOutputInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newToolOutputInjections] + ); + + const thinkingTextTokens = useMemo( + () => newThinkingTextInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newThinkingTextInjections] + ); + + const taskCoordinationTokens = useMemo( + () => newTaskCoordinationInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newTaskCoordinationInjections] + ); + + const userMessageTokens = useMemo( + () => newUserMessageInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [newUserMessageInjections] + ); + + // Linear-style neutral badge — uses theme-aware CSS variables + const badgeStyle: React.CSSProperties = { + backgroundColor: COLOR_SURFACE_RAISED, + border: `1px solid ${COLOR_BORDER}`, + color: COLOR_TEXT_SECONDARY, + }; + + // Calculate popover position based on trigger element + useEffect(() => { + if (showPopover && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const popoverWidth = 300; + const margin = 12; + + // Determine if popover should open left or right + const openLeft = rect.left + popoverWidth > viewportWidth - 20; + + // Determine if popover should open above or below + const spaceBelow = viewportHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + const openAbove = spaceBelow < 200 && spaceAbove > spaceBelow; + + const maxHeight = Math.max(openAbove ? spaceAbove : spaceBelow, 120) - 8; + + queueMicrotask(() => { + setPopoverStyle({ + position: 'fixed', + ...(openAbove ? { bottom: viewportHeight - rect.top + 4 } : { top: rect.bottom + 4 }), + left: openLeft ? rect.right - popoverWidth : rect.left, + minWidth: 260, + maxWidth: 340, + maxHeight, + overflowY: 'auto', + zIndex: 99999, + }); + + setArrowStyle({ + position: 'absolute', + ...(openAbove + ? { + bottom: -4, + borderRight: `1px solid ${COLOR_BORDER}`, + borderBottom: `1px solid ${COLOR_BORDER}`, + borderLeft: 'none', + borderTop: 'none', + } + : { + top: -4, + borderLeft: `1px solid ${COLOR_BORDER}`, + borderTop: `1px solid ${COLOR_BORDER}`, + borderRight: 'none', + borderBottom: 'none', + }), + [openLeft ? 'right' : 'left']: 12, + width: 8, + height: 8, + transform: 'rotate(45deg)', + backgroundColor: COLOR_SURFACE_RAISED, + }); + }); + } + }, [showPopover]); + + // Handle click outside and scroll to close popover + useEffect(() => { + if (!showPopover) return; + + const isInsideRect = (el: HTMLElement | null, x: number, y: number): boolean => { + if (!el) return false; + const rect = el.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; + }; + + const handleClickOutside = (e: MouseEvent): void => { + // Use coordinate-based hit test — reliable with portals, scrollbars, and re-renders + if ( + isInsideRect(popoverRef.current, e.clientX, e.clientY) || + isInsideRect(containerRef.current, e.clientX, e.clientY) + ) { + return; + } + setShowPopover(false); + }; + + const handleScroll = (e: Event): void => { + // Don't close if scrolling inside the popover + if (popoverRef.current && e.target instanceof Node && popoverRef.current.contains(e.target)) { + return; + } + setShowPopover(false); + }; + + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('scroll', handleScroll, true); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('scroll', handleScroll, true); + }; + }, [showPopover]); + + // Only render if there are new injections + if (totalNew === 0) { + return null; + } + + return ( +
{ + e.stopPropagation(); + setShowPopover(!showPopover); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + setShowPopover(!showPopover); + } + }} + > + {/* Badge */} + + Context + +{totalNew} + + + {/* Popover - rendered via Portal to escape stacking context */} + {showPopover && + createPortal( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events -- dialog uses stopPropagation only, not interactive +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {/* Arrow pointer */} +
+ + {/* Title */} +
+ New Context Injected In This Turn +
+ + {/* Sections */} +
+ {/* User Messages section */} + {newUserMessageInjections.length > 0 && ( + + {newUserMessageInjections.map((injection) => ( +
+
+ + Turn {injection.turnIndex + 1} + + + ~{formatTokens(injection.estimatedTokens)} tokens + +
+ {injection.textPreview && ( +
+ {injection.textPreview} +
+ )} +
+ ))} +
+ )} + + {/* CLAUDE.md Files section */} + {newClaudeMdInjections.length > 0 && ( + + {newClaudeMdInjections.map((injection) => { + const displayPath = + shortenDisplayPath(injection.path, projectRoot) || injection.displayName; + const absolutePath = resolveAbsolutePath(injection.path, projectRoot); + return ( +
+ +
+ ~{formatTokens(injection.estimatedTokens)} tokens +
+
+ ); + })} +
+ )} + + {/* Mentioned Files section */} + {newMentionedFileInjections.length > 0 && ( + + {newMentionedFileInjections.map((injection) => { + const displayPath = shortenDisplayPath(injection.path, projectRoot); + const absolutePath = resolveAbsolutePath(injection.path, projectRoot); + return ( +
+ +
+ ~{formatTokens(injection.estimatedTokens)} tokens +
+
+ ); + })} +
+ )} + + {/* Tool Outputs section */} + {newToolOutputInjections.length > 0 && ( + + {newToolOutputInjections.map((injection) => + injection.toolBreakdown.map((tool, idx) => ( +
+ {tool.toolName} + + ~{formatTokens(tool.tokenCount)} tokens + +
+ )) + )} +
+ )} + + {/* Task Coordination section */} + {newTaskCoordinationInjections.length > 0 && ( + + {newTaskCoordinationInjections.map((injection) => + injection.breakdown.map((item, idx) => ( +
+ {item.label} + + ~{formatTokens(item.tokenCount)} tokens + +
+ )) + )} +
+ )} + + {/* Thinking + Text section */} + {newThinkingTextInjections.length > 0 && ( + + {newThinkingTextInjections.map((injection) => ( +
+
+ Turn {injection.turnIndex + 1} +
+
+ {injection.breakdown.map((item, idx) => ( +
+ + {item.type === 'thinking' ? 'Thinking' : 'Text'} + + + ~{formatTokens(item.tokenCount)} tokens + +
+ ))} +
+
+ ))} +
+ )} +
+ + {/* Total tokens footer */} +
+ Total new tokens + + ~{formatTokens(totalNewTokens)} tokens + +
+
, + document.body + )} +
+ ); +}; diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx new file mode 100644 index 00000000..e0e82e30 --- /dev/null +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -0,0 +1,232 @@ +import React, { useCallback, useState } from 'react'; + +import { LinkedToolItem } from './items/LinkedToolItem'; +import { SlashItem } from './items/SlashItem'; +import { SubagentItem } from './items/SubagentItem'; +import { TeammateMessageItem } from './items/TeammateMessageItem'; +import { TextItem } from './items/TextItem'; +import { ThinkingItem } from './items/ThinkingItem'; + +import type { AIGroupDisplayItem } from '@renderer/types/groups'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface DisplayItemListProps { + items: AIGroupDisplayItem[]; + onItemClick: (itemId: string) => void; + expandedItemIds: Set; + aiGroupId: string; + /** Tool use ID to highlight for error deep linking */ + highlightToolUseId?: string; + /** Custom highlight color from trigger */ + highlightColor?: TriggerColor; + /** Map of tool use ID to trigger color for notification dots */ + notificationColorMap?: Map; + /** Optional callback to register tool element refs for scroll targeting */ + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; +} + +/** + * Truncates text to a maximum length and adds ellipsis if needed. + */ +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength) + '...'; +} + +/** + * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. + * + * This component maps each display item to its corresponding component based on type: + * - thinking -> ThinkingItem + * - output -> TextItem + * - tool -> LinkedToolItem + * - subagent -> SubagentItem + * - slash -> SlashItem + * + * The list is completely flat with no nested toggles or hierarchies. + */ +export const DisplayItemList = ({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, +}: Readonly): React.JSX.Element => { + // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair + const [replyLinkToolId, setReplyLinkToolId] = useState(null); + + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); + + /** Check if an item is part of the currently highlighted reply link */ + const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { + if (!replyLinkToolId) return false; + if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; + if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) + return true; + return false; + }; + + if (!items || items.length === 0) { + return ( +
+ No items to display +
+ ); + } + + return ( +
+ {items.map((item, index) => { + let itemKey = ''; + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + itemKey = `thinking-${index}`; + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + /> + ); + break; + } + + case 'output': { + itemKey = `output-${index}`; + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + /> + ); + break; + } + + case 'tool': { + itemKey = `tool-${item.tool.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + isHighlighted={highlightToolUseId === item.tool.id} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + break; + } + + case 'subagent': { + itemKey = `subagent-${item.subagent.id}-${index}`; + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + aiGroupId={aiGroupId} + highlightToolUseId={highlightToolUseId} + highlightColor={highlightColor} + notificationColorMap={notificationColorMap} + registerToolRef={registerToolRef} + /> + ); + break; + } + + case 'slash': { + itemKey = `slash-${item.slash.name}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + /> + ); + break; + } + + case 'teammate_message': { + itemKey = `teammate-${item.teammateMessage.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + onReplyHover={handleReplyHover} + /> + ); + break; + } + + default: + return null; + } + + // Apply reply-link spotlight: dim items not in the highlighted pair + const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); + return ( +
+ {element} +
+ ); + })} +
+ ); +}; diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx new file mode 100644 index 00000000..16d47d7a --- /dev/null +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { useStore } from '@renderer/store'; +import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react'; +import remarkGfm from 'remark-gfm'; +import { useShallow } from 'zustand/react/shallow'; + +import { CopyButton } from '../common/CopyButton'; +import { OngoingBanner } from '../common/OngoingIndicator'; + +import { createMarkdownComponents, markdownComponents } from './markdownComponents'; +import { createSearchContext } from './searchHighlightUtils'; + +import type { AIGroupLastOutput } from '@renderer/types/groups'; + +interface LastOutputDisplayProps { + lastOutput: AIGroupLastOutput | null; + aiGroupId: string; + /** Whether this is the last AI group in the conversation */ + isLastGroup?: boolean; + /** Whether the session is ongoing (from sessions array, same source as sidebar) */ + isSessionOngoing?: boolean; +} + +/** + * LastOutputDisplay shows the always-visible last text output OR last tool result. + * This is what the user sees as "the answer" from the AI. + * + * Features: + * - Shows text output with elegant prose styling + * - Shows tool result with tool name and icon + * - Handles error states for tool results + * - Shows timestamp + * - Expandable for long content + */ +export const LastOutputDisplay = ({ + lastOutput, + aiGroupId, + isLastGroup = false, + isSessionOngoing = false, +}: Readonly): React.JSX.Element | null => { + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => ({ + searchQuery: s.searchQuery, + searchMatches: s.searchMatches, + currentSearchIndex: s.currentSearchIndex, + })) + ); + const isTextOutput = lastOutput?.type === 'text' && Boolean(lastOutput.text); + + // Create search context (fresh each render so counter starts at 0) + const searchCtx = + searchQuery && isTextOutput + ? createSearchContext(searchQuery, aiGroupId, searchMatches, currentSearchIndex) + : null; + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const mdComponents = searchCtx ? createMarkdownComponents(searchCtx) : markdownComponents; + + // Show ongoing banner if this is the last AI group and session is ongoing + // This uses the same source (sessions array) as the sidebar green dot for consistency + if (isLastGroup && isSessionOngoing) { + return ; + } + + if (!lastOutput) { + return null; + } + + const { type } = lastOutput; + + // Render text output + if (type === 'text' && lastOutput.text) { + const textContent = lastOutput.text || ''; + + return ( +
+ + + {/* Content - scrollable */} +
+ + {textContent} + +
+
+ ); + } + + // Render tool result + if (type === 'tool_result' && lastOutput.toolResult) { + const isError = lastOutput.isError ?? false; + const Icon = isError ? XCircle : CheckCircle; + + return ( +
+ {/* Header */} +
+ + {lastOutput.toolName && ( + + {lastOutput.toolName} + + )} + {isError && ( + + Error + + )} +
+ + {/* Content */} +
+
+            {lastOutput.toolResult}
+          
+
+
+ ); + } + + // Render interruption as a simple horizontal banner + if (type === 'interruption') { + return ( +
+ + + Request interrupted by user + +
+ ); + } + + // Render plan_exit (ExitPlanMode) with plan content in markdown + if (type === 'plan_exit' && lastOutput.planContent) { + const planContent = lastOutput.planContent || ''; + const planPreamble = lastOutput.planPreamble; + + return ( +
+ {/* Preamble text (e.g., "The plan is complete. Let me exit plan mode...") */} + {planPreamble && ( +
+
+ + {planPreamble} + +
+
+ )} + + {/* Plan content block */} +
+ {/* Header */} +
+
+ + + Plan Ready for Approval + +
+ +
+ + {/* Plan content - scrollable */} +
+ + {planContent} + +
+
+
+ ); + } + + return null; +}; diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx new file mode 100644 index 00000000..28f06a05 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/DirectoryTreeNode.tsx @@ -0,0 +1,125 @@ +/** + * DirectoryTreeNode - Recursive component for rendering directory tree nodes. + */ + +import React, { useState } from 'react'; + +import { CopyablePath } from '@renderer/components/common/CopyablePath'; +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { ChevronRight } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; +import { formatFirstSeen, parseTurnIndex } from '../utils/pathParsing'; + +import type { TreeNode } from './types'; + +interface DirectoryTreeNodeProps { + node: TreeNode; + depth?: number; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const DirectoryTreeNode = ({ + node, + depth = 0, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + const [expanded, setExpanded] = useState(true); + const indent = depth * 12; + + const sortedChildren = Array.from(node.children.values()).sort((a, b) => { + if (a.isFile && !b.isFile) return -1; + if (!a.isFile && b.isFile) return 1; + return a.name.localeCompare(b.name); + }); + + if (node.isFile) { + const turnIndex = node.firstSeenInGroup ? parseTurnIndex(node.firstSeenInGroup) : -1; + const isClickable = onNavigateToTurn && turnIndex >= 0; + + return ( +
+ + (~{formatTokens(node.tokens ?? 0)}) + {node.firstSeenInGroup && + (isClickable ? ( + + ) : ( + + @{formatFirstSeen(node.firstSeenInGroup)} + + ))} +
+ ); + } + + return ( +
+ {node.name && ( +
{ + e.stopPropagation(); + setExpanded(!expanded); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + setExpanded(!expanded); + } + }} + > + + {node.name}/ +
+ )} + {expanded && + sortedChildren.map((child) => ( + + ))} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts new file mode 100644 index 00000000..48e38d43 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/buildDirectoryTree.ts @@ -0,0 +1,47 @@ +/** + * Build a directory tree structure from CLAUDE.md injections. + */ + +import type { TreeNode } from './types'; +import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; + +/** + * Build a tree structure from a list of directory CLAUDE.md injections. + */ +export function buildDirectoryTree( + injections: ClaudeMdContextInjection[], + projectRoot: string +): TreeNode { + const root: TreeNode = { name: '', path: '', isFile: false, children: new Map() }; + + for (const injection of injections) { + let relativePath = injection.path; + if (projectRoot && relativePath.startsWith(projectRoot)) { + relativePath = relativePath.slice(projectRoot.length); + if (relativePath.startsWith('/') || relativePath.startsWith('\\')) + relativePath = relativePath.slice(1); + } + + const parts = relativePath.split(/[\\/]/); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLast = i === parts.length - 1; + + if (!current.children.has(part)) { + current.children.set(part, { + name: part, + path: isLast ? injection.path : '', + isFile: isLast && part === 'CLAUDE.md', + tokens: isLast ? injection.estimatedTokens : undefined, + firstSeenInGroup: isLast ? injection.firstSeenInGroup : undefined, + children: new Map(), + }); + } + current = current.children.get(part)!; + } + } + + return root; +} diff --git a/src/renderer/components/chat/SessionContextPanel/DirectoryTree/types.ts b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/types.ts new file mode 100644 index 00000000..386c5e7f --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/DirectoryTree/types.ts @@ -0,0 +1,12 @@ +/** + * Type definitions for DirectoryTree components. + */ + +export interface TreeNode { + name: string; + path: string; + isFile: boolean; + tokens?: number; + firstSeenInGroup?: string; + children: Map; +} diff --git a/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdFilesSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdFilesSection.tsx new file mode 100644 index 00000000..f3e38b4f --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdFilesSection.tsx @@ -0,0 +1,90 @@ +/** + * ClaudeMdFilesSection - Section for displaying CLAUDE.md files with nested groups. + */ + +import React, { useMemo } from 'react'; + +import { CLAUDE_MD_GROUP_CONFIG, CLAUDE_MD_GROUP_ORDER } from '../types'; + +import { ClaudeMdSubSection } from './ClaudeMdSection'; +import { CollapsibleSection } from './CollapsibleSection'; + +import type { ClaudeMdGroupCategory } from '../types'; +import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; + +interface ClaudeMdFilesSectionProps { + injections: ClaudeMdContextInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + projectRoot: string; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ClaudeMdFilesSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + projectRoot, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + // Group CLAUDE.md injections by category + const claudeMdGroups = useMemo(() => { + const groups = new Map(); + + for (const category of CLAUDE_MD_GROUP_ORDER) { + groups.set(category, []); + } + + for (const injection of injections) { + for (const [category, config] of Object.entries(CLAUDE_MD_GROUP_CONFIG)) { + if (config.sources.includes(injection.source)) { + const group = groups.get(category as ClaudeMdGroupCategory) ?? []; + group.push(injection); + groups.set(category as ClaudeMdGroupCategory, group); + break; + } + } + } + + return groups; + }, [injections]); + + // Get non-empty CLAUDE.md groups + const nonEmptyClaudeMdGroups = useMemo( + () => + CLAUDE_MD_GROUP_ORDER.filter((category) => { + const group = claudeMdGroups.get(category); + return group && group.length > 0; + }), + [claudeMdGroups] + ); + + if (injections.length === 0) return null; + + return ( + + {nonEmptyClaudeMdGroups.map((category) => { + const group = claudeMdGroups.get(category) ?? []; + const config = CLAUDE_MD_GROUP_CONFIG[category]; + return ( + + ); + })} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdSection.tsx new file mode 100644 index 00000000..90c665ab --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/ClaudeMdSection.tsx @@ -0,0 +1,86 @@ +/** + * ClaudeMdSection - CLAUDE.md files section with nested Global/Project/Directory groups. + */ + +import React, { useState } from 'react'; + +import { ChevronRight } from 'lucide-react'; + +import { buildDirectoryTree } from '../DirectoryTree/buildDirectoryTree'; +import { DirectoryTreeNode } from '../DirectoryTree/DirectoryTreeNode'; +import { ClaudeMdItem } from '../items/ClaudeMdItem'; +import { formatTokens } from '../utils/formatting'; + +import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; + +interface ClaudeMdSubSectionProps { + label: string; + injections: ClaudeMdContextInjection[]; + isDirectory: boolean; + projectRoot: string; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ClaudeMdSubSection = ({ + label, + injections, + isDirectory, + projectRoot, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const [expanded, setExpanded] = useState(true); + const sectionTokens = injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0); + + return ( +
+
setExpanded(!expanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(!expanded); + } + }} + > + + {label} + + {injections.length} + + (~{formatTokens(sectionTokens)}) +
+ + {expanded && ( +
+ {isDirectory ? ( + + ) : ( + injections.map((injection) => ( + + )) + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx new file mode 100644 index 00000000..bbceba0c --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/CollapsibleSection.tsx @@ -0,0 +1,77 @@ +/** + * CollapsibleSection - Generic collapsible wrapper for content sections. + */ + +import React from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +interface CollapsibleSectionProps { + title: string; + count: number; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + children: React.ReactNode; +} + +export const CollapsibleSection = ({ + title, + count, + tokenCount, + isExpanded, + onToggle, + children, +}: Readonly): React.ReactElement => { + return ( +
+ + + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx new file mode 100644 index 00000000..75dd31a1 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/MentionedFilesSection.tsx @@ -0,0 +1,50 @@ +/** + * MentionedFilesSection - Section for displaying mentioned files. + */ + +import React from 'react'; + +import { MentionedFileItem } from '../items/MentionedFileItem'; + +import { CollapsibleSection } from './CollapsibleSection'; + +import type { MentionedFileInjection } from '@renderer/types/contextInjection'; + +interface MentionedFilesSectionProps { + injections: MentionedFileInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + projectRoot?: string; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const MentionedFilesSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + projectRoot, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + if (injections.length === 0) return null; + + return ( + + {injections.map((injection) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx new file mode 100644 index 00000000..042e12cf --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHeader.tsx @@ -0,0 +1,155 @@ +/** + * SessionContextHeader - Header component with title, help tooltip, and token stats. + */ + +import React from 'react'; + +import { + COLOR_BORDER, + COLOR_BORDER_SUBTLE, + COLOR_SURFACE_OVERLAY, + COLOR_TEXT, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, +} from '@renderer/constants/cssVariables'; +import { FileText, X } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import { SessionContextHelpTooltip } from './SessionContextHelpTooltip'; + +import type { ContextPhaseInfo } from '@renderer/types/contextInjection'; + +interface SessionContextHeaderProps { + injectionCount: number; + totalTokens: number; + totalSessionTokens?: number; + onClose?: () => void; + phaseInfo?: ContextPhaseInfo; + selectedPhase: number | null; + onPhaseChange: (phase: number | null) => void; +} + +export const SessionContextHeader = ({ + injectionCount, + totalTokens, + totalSessionTokens, + onClose, + phaseInfo, + selectedPhase, + onPhaseChange, +}: Readonly): React.ReactElement => { + return ( +
+ {/* Title row */} +
+
+ +

+ Visible Context +

+ + {injectionCount} + +
+
+ + {onClose && ( + + )} +
+
+ + {/* Token comparison stats */} +
+
+ {/* Visible Context tokens */} +
+ Visible: + + ~{formatTokens(totalTokens)} + +
+ {/* Total Session tokens (if provided) */} + {totalSessionTokens !== undefined && totalSessionTokens > 0 && ( +
+ Total: + + {formatTokens(totalSessionTokens)} + +
+ )} +
+ {/* Percentage of total */} + {totalSessionTokens !== undefined && totalSessionTokens > 0 && ( + + {Math.min((totalTokens / totalSessionTokens) * 100, 100).toFixed(1)}% of total + + )} +
+ + {/* Phase selector - only shown when compactions exist */} + {phaseInfo && phaseInfo.phases.length > 1 && ( +
+ + Phase: + + {phaseInfo.phases.map((phase) => ( + + ))} + +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx new file mode 100644 index 00000000..776fd4be --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/SessionContextHelpTooltip.tsx @@ -0,0 +1,184 @@ +/** + * SessionContextHelpTooltip - Help tooltip explaining Visible Context vs Total Tokens. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { HelpCircle } from 'lucide-react'; + +export const SessionContextHelpTooltip = (): React.ReactElement => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState({}); + const [arrowStyle, setArrowStyle] = useState({}); + const containerRef = useRef(null); + const tooltipRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + + const clearHideTimeout = (): void => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + const handleMouseEnter = (): void => { + clearHideTimeout(); + setShowTooltip(true); + }; + + const handleMouseLeave = (): void => { + clearHideTimeout(); + hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 150); + }; + + useEffect(() => { + return () => clearHideTimeout(); + }, []); + + // Close tooltip on scroll + useEffect(() => { + if (!showTooltip) return; + + const handleScroll = (): void => { + setShowTooltip(false); + }; + + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [showTooltip]); + + // Calculate tooltip position based on trigger element + useEffect(() => { + if (showTooltip && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const tooltipWidth = 288; // w-72 = 18rem = 288px + + setTooltipStyle({ + position: 'fixed', + top: rect.bottom + 8, + left: rect.right - tooltipWidth, + width: tooltipWidth, + zIndex: 99999, + }); + + setArrowStyle({ + position: 'absolute', + top: -4, + right: 12, + width: 8, + height: 8, + transform: 'rotate(45deg)', + backgroundColor: 'var(--color-surface-raised)', + borderLeft: '1px solid var(--color-border)', + borderTop: '1px solid var(--color-border)', + }); + } + }, [showTooltip]); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowTooltip(!showTooltip); + } + }} + > + + + {showTooltip && + createPortal( +
+ {/* Arrow */} +
+ +
+ {/* What is Visible Context */} +
+
+ What is Visible Context? +
+

+ Tokens consumed by file reads, tool outputs, and configuration files (CLAUDE.md) + that are injected into the conversation. +

+
+ + {/* Difference with Total */} +
+
+ Total Context vs Visible Context +
+
+
+ + Total: + + + Total tokens that are injected into the conversation + +
+
+ + Visible: + + + Subset of tokens that you can optimize & debug + +
+
+
+ + {/* Tips */} +
+
+ Optimization Tips +
+
    +
  • Shorten large CLAUDE.md files
  • +
  • Split large @-mentioned files
  • +
  • Adjust MCP tool output verbosity
  • +
+
+
+
, + document.body + )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx new file mode 100644 index 00000000..508eccdb --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/TaskCoordinationSection.tsx @@ -0,0 +1,47 @@ +/** + * TaskCoordinationSection - Section for displaying task coordination injections. + */ + +import React from 'react'; + +import { TaskCoordinationItem } from '../items/TaskCoordinationItem'; + +import { CollapsibleSection } from './CollapsibleSection'; + +import type { TaskCoordinationInjection } from '@renderer/types/contextInjection'; + +interface TaskCoordinationSectionProps { + injections: TaskCoordinationInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const TaskCoordinationSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + if (injections.length === 0) return null; + + return ( + + {injections.map((injection) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/ThinkingTextSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/ThinkingTextSection.tsx new file mode 100644 index 00000000..88d6f333 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/ThinkingTextSection.tsx @@ -0,0 +1,47 @@ +/** + * ThinkingTextSection - Section for displaying thinking text. + */ + +import React from 'react'; + +import { ThinkingTextItem } from '../items/ThinkingTextItem'; + +import { CollapsibleSection } from './CollapsibleSection'; + +import type { ThinkingTextInjection } from '@renderer/types/contextInjection'; + +interface ThinkingTextSectionProps { + injections: ThinkingTextInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ThinkingTextSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + if (injections.length === 0) return null; + + return ( + + {injections.map((injection) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/ToolOutputsSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/ToolOutputsSection.tsx new file mode 100644 index 00000000..57de5672 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/ToolOutputsSection.tsx @@ -0,0 +1,47 @@ +/** + * ToolOutputsSection - Section for displaying tool outputs. + */ + +import React from 'react'; + +import { ToolOutputItem } from '../items/ToolOutputItem'; + +import { CollapsibleSection } from './CollapsibleSection'; + +import type { ToolOutputInjection } from '@renderer/types/contextInjection'; + +interface ToolOutputsSectionProps { + injections: ToolOutputInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ToolOutputsSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + if (injections.length === 0) return null; + + return ( + + {injections.map((injection) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/components/UserMessagesSection.tsx b/src/renderer/components/chat/SessionContextPanel/components/UserMessagesSection.tsx new file mode 100644 index 00000000..76f4914a --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/components/UserMessagesSection.tsx @@ -0,0 +1,47 @@ +/** + * UserMessagesSection - Section for displaying user message injections. + */ + +import React from 'react'; + +import { UserMessageItem } from '../items/UserMessageItem'; + +import { CollapsibleSection } from './CollapsibleSection'; + +import type { UserMessageInjection } from '@renderer/types/contextInjection'; + +interface UserMessagesSectionProps { + injections: UserMessageInjection[]; + tokenCount: number; + isExpanded: boolean; + onToggle: () => void; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const UserMessagesSection = ({ + injections, + tokenCount, + isExpanded, + onToggle, + onNavigateToTurn, +}: Readonly): React.ReactElement | null => { + if (injections.length === 0) return null; + + return ( + + {injections.map((injection) => ( + + ))} + + ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/index.tsx b/src/renderer/components/chat/SessionContextPanel/index.tsx new file mode 100644 index 00000000..c72387bf --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/index.tsx @@ -0,0 +1,250 @@ +/** + * SessionContextPanel - Panel showing all context injections for a session. + * Displays CLAUDE.md files, mentioned files, and tool outputs in collapsible sections. + */ + +import React, { useMemo, useState } from 'react'; + +import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constants/cssVariables'; + +import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection'; +import { MentionedFilesSection } from './components/MentionedFilesSection'; +import { SessionContextHeader } from './components/SessionContextHeader'; +import { TaskCoordinationSection } from './components/TaskCoordinationSection'; +import { ThinkingTextSection } from './components/ThinkingTextSection'; +import { ToolOutputsSection } from './components/ToolOutputsSection'; +import { UserMessagesSection } from './components/UserMessagesSection'; +import { + SECTION_CLAUDE_MD, + SECTION_MENTIONED_FILES, + SECTION_TASK_COORDINATION, + SECTION_THINKING_TEXT, + SECTION_TOOL_OUTPUTS, + SECTION_USER_MESSAGES, +} from './types'; + +import type { SectionType, SessionContextPanelProps } from './types'; +import type { + ClaudeMdContextInjection, + MentionedFileInjection, + TaskCoordinationInjection, + ThinkingTextInjection, + ToolOutputInjection, + UserMessageInjection, +} from '@renderer/types/contextInjection'; + +export const SessionContextPanel = ({ + injections, + onClose, + projectRoot, + onNavigateToTurn, + totalSessionTokens, + phaseInfo, + selectedPhase, + onPhaseChange, +}: Readonly): React.ReactElement => { + // Track which main sections are expanded + const [expandedSections, setExpandedSections] = useState>( + new Set([ + SECTION_USER_MESSAGES, + SECTION_CLAUDE_MD, + SECTION_MENTIONED_FILES, + SECTION_TOOL_OUTPUTS, + SECTION_TASK_COORDINATION, + SECTION_THINKING_TEXT, + ]) + ); + + // Separate injections by category + const { + claudeMdInjections, + mentionedFileInjections, + toolOutputInjections, + thinkingTextInjections, + taskCoordinationInjections, + userMessageInjections, + } = useMemo(() => { + const claudeMd: ClaudeMdContextInjection[] = []; + const mentionedFiles: MentionedFileInjection[] = []; + const toolOutputs: ToolOutputInjection[] = []; + const thinkingText: ThinkingTextInjection[] = []; + const taskCoordination: TaskCoordinationInjection[] = []; + const userMessages: UserMessageInjection[] = []; + + for (const injection of injections) { + switch (injection.category) { + case 'claude-md': + claudeMd.push(injection); + break; + case 'mentioned-file': + mentionedFiles.push(injection); + break; + case 'tool-output': + toolOutputs.push(injection); + break; + case 'thinking-text': + thinkingText.push(injection); + break; + case 'task-coordination': + taskCoordination.push(injection); + break; + case 'user-message': + userMessages.push(injection); + break; + } + } + + // Sort mentioned files and tool outputs by tokens descending + mentionedFiles.sort((a, b) => b.estimatedTokens - a.estimatedTokens); + toolOutputs.sort((a, b) => b.estimatedTokens - a.estimatedTokens); + // Sort task coordination by tokens descending + taskCoordination.sort((a, b) => b.estimatedTokens - a.estimatedTokens); + // Sort thinking-text by turn index ascending + thinkingText.sort((a, b) => a.turnIndex - b.turnIndex); + // Sort user messages by turn index ascending + userMessages.sort((a, b) => a.turnIndex - b.turnIndex); + + return { + claudeMdInjections: claudeMd, + mentionedFileInjections: mentionedFiles, + toolOutputInjections: toolOutputs, + thinkingTextInjections: thinkingText, + taskCoordinationInjections: taskCoordination, + userMessageInjections: userMessages, + }; + }, [injections]); + + // Calculate total tokens + const totalTokens = useMemo( + () => injections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [injections] + ); + + // Section token counts + const claudeMdTokens = useMemo( + () => claudeMdInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [claudeMdInjections] + ); + + const mentionedFilesTokens = useMemo( + () => mentionedFileInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [mentionedFileInjections] + ); + + const toolOutputsTokens = useMemo( + () => toolOutputInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [toolOutputInjections] + ); + + const thinkingTextTokens = useMemo( + () => thinkingTextInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [thinkingTextInjections] + ); + + const taskCoordinationTokens = useMemo( + () => taskCoordinationInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [taskCoordinationInjections] + ); + + const userMessagesTokens = useMemo( + () => userMessageInjections.reduce((sum, inj) => sum + inj.estimatedTokens, 0), + [userMessageInjections] + ); + + // Toggle section expansion + const toggleSection = (section: SectionType): void => { + setExpandedSections((prev) => { + const next = new Set(prev); + if (next.has(section)) { + next.delete(section); + } else { + next.add(section); + } + return next; + }); + }; + + return ( +
+ + + {/* Content */} +
+ {injections.length === 0 ? ( +
+ No context injections detected in this session +
+ ) : ( + <> + toggleSection(SECTION_USER_MESSAGES)} + onNavigateToTurn={onNavigateToTurn} + /> + + toggleSection(SECTION_CLAUDE_MD)} + projectRoot={projectRoot ?? ''} + onNavigateToTurn={onNavigateToTurn} + /> + + toggleSection(SECTION_MENTIONED_FILES)} + projectRoot={projectRoot} + onNavigateToTurn={onNavigateToTurn} + /> + + toggleSection(SECTION_TOOL_OUTPUTS)} + onNavigateToTurn={onNavigateToTurn} + /> + + toggleSection(SECTION_TASK_COORDINATION)} + onNavigateToTurn={onNavigateToTurn} + /> + + toggleSection(SECTION_THINKING_TEXT)} + onNavigateToTurn={onNavigateToTurn} + /> + + )} +
+
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx new file mode 100644 index 00000000..6b25072b --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/ClaudeMdItem.tsx @@ -0,0 +1,76 @@ +/** + * ClaudeMdItem - Single CLAUDE.md file item display. + */ + +import React from 'react'; + +import { CopyablePath } from '@renderer/components/common/CopyablePath'; +import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay'; + +import { formatTokens } from '../utils/formatting'; +import { formatFirstSeen, parseTurnIndex } from '../utils/pathParsing'; + +import type { ClaudeMdContextInjection } from '@renderer/types/contextInjection'; + +interface ClaudeMdItemProps { + injection: ClaudeMdContextInjection; + projectRoot?: string; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ClaudeMdItem = ({ + injection, + projectRoot, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const turnIndex = parseTurnIndex(injection.firstSeenInGroup); + const isClickable = onNavigateToTurn && turnIndex >= 0; + const displayPath = shortenDisplayPath(injection.path, projectRoot); + const absolutePath = resolveAbsolutePath(injection.path, projectRoot); + + return ( +
+ +
+ + ~{formatTokens(injection.estimatedTokens)} tokens + + {isClickable ? ( + + ) : ( + + @{formatFirstSeen(injection.firstSeenInGroup)} + + )} +
+
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/MentionedFileItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/MentionedFileItem.tsx new file mode 100644 index 00000000..8f7449a2 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/MentionedFileItem.tsx @@ -0,0 +1,91 @@ +/** + * MentionedFileItem - Single mentioned file item display. + */ + +import React from 'react'; + +import { CopyablePath } from '@renderer/components/common/CopyablePath'; +import { resolveAbsolutePath, shortenDisplayPath } from '@renderer/utils/pathDisplay'; +import { File } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import type { MentionedFileInjection } from '@renderer/types/contextInjection'; + +interface MentionedFileItemProps { + injection: MentionedFileInjection; + projectRoot?: string; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const MentionedFileItem = ({ + injection, + projectRoot, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const turnIndex = injection.firstSeenTurnIndex; + const isClickable = onNavigateToTurn && turnIndex >= 0; + const displayPath = shortenDisplayPath(injection.path, projectRoot); + const absolutePath = resolveAbsolutePath(injection.path, projectRoot); + + return ( +
+
+ + + {!injection.exists && ( + + missing + + )} +
+
+ + ~{formatTokens(injection.estimatedTokens)} tokens + + {isClickable ? ( + onNavigateToTurn(turnIndex)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onNavigateToTurn(turnIndex); + } + }} + > + @Turn {turnIndex + 1} + + ) : ( + + @Turn {turnIndex + 1} + + )} +
+
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx new file mode 100644 index 00000000..62832646 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/TaskCoordinationItem.tsx @@ -0,0 +1,116 @@ +/** + * TaskCoordinationItem - Single task coordination injection with expandable breakdown. + */ + +import React, { useState } from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { ChevronRight, Users } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import type { TaskCoordinationInjection } from '@renderer/types/contextInjection'; + +interface TaskCoordinationItemProps { + injection: TaskCoordinationInjection; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const TaskCoordinationItem = ({ + injection, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const [expanded, setExpanded] = useState(false); + const turnIndex = injection.turnIndex; + const isClickable = onNavigateToTurn && turnIndex >= 0; + const hasBreakdown = injection.breakdown.length > 0; + + const containerContent = ( + <> + {hasBreakdown && ( + + )} + + {isClickable ? ( + { + e.stopPropagation(); + onNavigateToTurn(turnIndex); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onNavigateToTurn(turnIndex); + } + }} + > + @Turn {turnIndex + 1} + + ) : ( + + @Turn {turnIndex + 1} + + )} + + ~{formatTokens(injection.estimatedTokens)} tokens + + + {injection.breakdown.length} item{injection.breakdown.length !== 1 ? 's' : ''} + + + ); + + return ( +
+ {hasBreakdown ? ( + + ) : ( +
{containerContent}
+ )} + + {expanded && hasBreakdown && ( +
+ {injection.breakdown.map((item, idx) => ( +
+ {item.label} + + ~{formatTokens(item.tokenCount)} + +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx new file mode 100644 index 00000000..92be27b0 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/ThinkingTextItem.tsx @@ -0,0 +1,96 @@ +/** + * ThinkingTextItem - Single thinking text item with expandable breakdown. + */ + +import React, { useState } from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { Brain, ChevronRight } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import type { ThinkingTextInjection } from '@renderer/types/contextInjection'; + +interface ThinkingTextItemProps { + injection: ThinkingTextInjection; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ThinkingTextItem = ({ + injection, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const [expanded, setExpanded] = useState(false); + const turnIndex = injection.turnIndex; + const isClickable = onNavigateToTurn && turnIndex >= 0; + + return ( +
+ + + {expanded && injection.breakdown.length > 0 && ( +
+ {injection.breakdown.map((item, idx) => ( +
+ + {item.type === 'thinking' ? 'Thinking' : 'Text'} + + + ~{formatTokens(item.tokenCount)} + +
+ ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx new file mode 100644 index 00000000..dec23d2c --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/ToolBreakdownItem.tsx @@ -0,0 +1,38 @@ +/** + * ToolBreakdownItem - Single tool breakdown item display. + */ + +import React from 'react'; + +import { formatTokens } from '../utils/formatting'; + +import type { ToolTokenBreakdown } from '@renderer/types/contextInjection'; + +interface ToolBreakdownItemProps { + tool: ToolTokenBreakdown; +} + +export const ToolBreakdownItem = ({ + tool, +}: Readonly): React.ReactElement => { + return ( +
+ {tool.toolName} + + ~{formatTokens(tool.tokenCount)} + + {tool.isError && ( + + error + + )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx new file mode 100644 index 00000000..fe46a522 --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/ToolOutputItem.tsx @@ -0,0 +1,113 @@ +/** + * ToolOutputItem - Single tool output item with expandable breakdown. + */ + +import React, { useState } from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { ChevronRight, Wrench } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import { ToolBreakdownItem } from './ToolBreakdownItem'; + +import type { ToolOutputInjection } from '@renderer/types/contextInjection'; + +interface ToolOutputItemProps { + injection: ToolOutputInjection; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const ToolOutputItem = ({ + injection, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const [expanded, setExpanded] = useState(false); + const turnIndex = injection.turnIndex; + const isClickable = onNavigateToTurn && turnIndex >= 0; + const hasBreakdown = injection.toolBreakdown.length > 0; + + const containerContent = ( + <> + {hasBreakdown && ( + + )} + + {isClickable ? ( + { + e.stopPropagation(); + onNavigateToTurn(turnIndex); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.stopPropagation(); + onNavigateToTurn(turnIndex); + } + }} + > + @Turn {turnIndex + 1} + + ) : ( + + @Turn {turnIndex + 1} + + )} + + ~{formatTokens(injection.estimatedTokens)} tokens + + + {injection.toolCount} tool{injection.toolCount !== 1 ? 's' : ''} + + + ); + + return ( +
+ {hasBreakdown ? ( + + ) : ( +
{containerContent}
+ )} + + {expanded && hasBreakdown && ( +
+ {injection.toolBreakdown.map((tool, idx) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx new file mode 100644 index 00000000..ce3219fa --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/items/UserMessageItem.tsx @@ -0,0 +1,69 @@ +/** + * UserMessageItem - Single user message item showing turn link, tokens, and preview. + */ + +import React from 'react'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { MessageSquare } from 'lucide-react'; + +import { formatTokens } from '../utils/formatting'; + +import type { UserMessageInjection } from '@renderer/types/contextInjection'; + +interface UserMessageItemProps { + injection: UserMessageInjection; + onNavigateToTurn?: (turnIndex: number) => void; +} + +export const UserMessageItem = ({ + injection, + onNavigateToTurn, +}: Readonly): React.ReactElement => { + const turnIndex = injection.turnIndex; + const isClickable = onNavigateToTurn && turnIndex >= 0; + + return ( +
+
+ + {isClickable ? ( + onNavigateToTurn(turnIndex)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onNavigateToTurn(turnIndex); + } + }} + > + @Turn {turnIndex + 1} + + ) : ( + + @Turn {turnIndex + 1} + + )} + + ~{formatTokens(injection.estimatedTokens)} tokens + +
+ {injection.textPreview && ( +
+ {injection.textPreview} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/SessionContextPanel/types.ts b/src/renderer/components/chat/SessionContextPanel/types.ts new file mode 100644 index 00000000..ef1aeb4e --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/types.ts @@ -0,0 +1,79 @@ +/** + * Type definitions for SessionContextPanel components. + */ + +import type { ClaudeMdSource } from '@renderer/types/claudeMd'; +import type { ContextInjection, ContextPhaseInfo } from '@renderer/types/contextInjection'; + +// ============================================================================= +// Props Interface +// ============================================================================= + +export interface SessionContextPanelProps { + /** All accumulated context injections */ + injections: ContextInjection[]; + /** Close button handler */ + onClose?: () => void; + /** Project root for relative path display */ + projectRoot?: string; + /** Click Turn N to navigate to that turn */ + onNavigateToTurn?: (turnIndex: number) => void; + /** Total session tokens (input + output + cache) for comparison */ + totalSessionTokens?: number; + /** Phase information for phase selector */ + phaseInfo?: ContextPhaseInfo; + /** Currently selected phase (null = current/latest) */ + selectedPhase: number | null; + /** Callback to change selected phase */ + onPhaseChange: (phase: number | null) => void; +} + +// ============================================================================= +// Section Types +// ============================================================================= + +/** Section type constants */ +export const SECTION_CLAUDE_MD = 'claude-md' as const; +export const SECTION_MENTIONED_FILES = 'mentioned-files' as const; +export const SECTION_TOOL_OUTPUTS = 'tool-outputs' as const; +export const SECTION_THINKING_TEXT = 'thinking-text' as const; +export const SECTION_TASK_COORDINATION = 'task-coordination' as const; +export const SECTION_USER_MESSAGES = 'user-messages' as const; + +/** Section identifiers for collapsible panels */ +export type SectionType = + | typeof SECTION_CLAUDE_MD + | typeof SECTION_MENTIONED_FILES + | typeof SECTION_TOOL_OUTPUTS + | typeof SECTION_THINKING_TEXT + | typeof SECTION_TASK_COORDINATION + | typeof SECTION_USER_MESSAGES; + +// ============================================================================= +// CLAUDE.md Group Types +// ============================================================================= + +/** Group category for CLAUDE.md files */ +export type ClaudeMdGroupCategory = 'global' | 'project' | 'directory'; + +interface ClaudeMdGroupConfig { + label: string; + sources: ClaudeMdSource[]; +} + +export const CLAUDE_MD_GROUP_CONFIG: Record = { + global: { + label: 'Global', + sources: ['enterprise', 'user-memory', 'user-rules', 'auto-memory'], + }, + project: { + label: 'Project', + sources: ['project-memory', 'project-rules', 'project-local'], + }, + directory: { + label: 'Directory', + sources: ['directory'], + }, +}; + +export const CLAUDE_MD_GROUP_ORDER: ClaudeMdGroupCategory[] = ['global', 'project', 'directory']; diff --git a/src/renderer/components/chat/SessionContextPanel/utils/formatting.ts b/src/renderer/components/chat/SessionContextPanel/utils/formatting.ts new file mode 100644 index 00000000..364b7bab --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/utils/formatting.ts @@ -0,0 +1,6 @@ +/** + * Formatting utilities for SessionContextPanel. + */ + +// Re-export from shared module for backwards compatibility +export { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting'; diff --git a/src/renderer/components/chat/SessionContextPanel/utils/pathParsing.ts b/src/renderer/components/chat/SessionContextPanel/utils/pathParsing.ts new file mode 100644 index 00000000..a31426fa --- /dev/null +++ b/src/renderer/components/chat/SessionContextPanel/utils/pathParsing.ts @@ -0,0 +1,23 @@ +/** + * Path parsing utilities for SessionContextPanel. + */ + +/** + * Format the firstSeenInGroup value into a human-readable string. + * Converts "ai-0" -> "Turn 1", "ai-1" -> "Turn 2", etc. + */ +export function formatFirstSeen(groupId: string): string { + const turnIndex = parseTurnIndex(groupId); + if (turnIndex < 0) return groupId; + return `Turn ${turnIndex + 1}`; +} + +/** + * Extract turn index from groupId. Returns -1 if invalid. + * "ai-0" -> 0, "ai-1" -> 1, etc. + */ +export function parseTurnIndex(groupId: string): number { + const match = /^ai-(\d+)$/.exec(groupId); + if (!match) return -1; + return parseInt(match[1], 10); +} diff --git a/src/renderer/components/chat/SystemChatGroup.tsx b/src/renderer/components/chat/SystemChatGroup.tsx new file mode 100644 index 00000000..92f03e01 --- /dev/null +++ b/src/renderer/components/chat/SystemChatGroup.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { format } from 'date-fns'; +import { Terminal } from 'lucide-react'; + +import type { SystemGroup } from '@renderer/types/groups'; + +// Module-level constant - safe because .replace() resets lastIndex on g-flagged regexes +const ANSI_ESCAPE_REGEX = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g'); + +interface SystemChatGroupProps { + systemGroup: SystemGroup; +} + +/** + * SystemChatGroup displays command output (e.g., /model response). + * Renders on LEFT side like AI, but with neutral/gray styling. + */ +const SystemChatGroupInner = ({ + systemGroup, +}: Readonly): React.JSX.Element => { + const { commandOutput, timestamp } = systemGroup; + + // Clean ANSI escape codes from output + const cleanOutput = commandOutput.replace(ANSI_ESCAPE_REGEX, ''); + + return ( +
+
+ {/* Header - system icon */} +
+ + + System + + · + {format(timestamp, 'h:mm:ss a')} +
+ + {/* Content - theme-aware neutral styling */} +
+
+            {cleanOutput}
+          
+
+
+
+ ); +}; + +export const SystemChatGroup = React.memo(SystemChatGroupInner); diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx new file mode 100644 index 00000000..7b1a296e --- /dev/null +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -0,0 +1,471 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; + +import { useTabUI } from '@renderer/hooks/useTabUI'; +import { useStore } from '@renderer/store'; +import { createLogger } from '@shared/utils/logger'; +import { format } from 'date-fns'; +import { User } from 'lucide-react'; +import remarkGfm from 'remark-gfm'; +import { useShallow } from 'zustand/react/shallow'; + +import { CopyButton } from '../common/CopyButton'; + +import { + createSearchContext, + highlightSearchInChildren, + type SearchContext, +} from './searchHighlightUtils'; + +import type { UserGroup } from '@renderer/types/groups'; + +const logger = createLogger('Component:UserChatGroup'); + +// Pattern for @paths only (file references) +const PATH_PATTERN = /@([^\s,)}\]]+)/g; + +interface UserChatGroupProps { + userGroup: UserGroup; +} + +/** + * Recursively walks React children and replaces text nodes containing @path + * references with styled spans using validated path state. + */ +// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types +function highlightTextNode(text: string, validatedPaths: Record): React.ReactNode { + const pathPattern = /@[^\s,)}\]]+/g; + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + + pathPattern.lastIndex = 0; + while ((match = pathPattern.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + const fullMatch = match[0]; + const isValid = validatedPaths[fullMatch] === true; + + if (isValid) { + parts.push( + + {fullMatch} + + ); + } else { + parts.push(fullMatch); + } + + lastIndex = match.index + fullMatch.length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + if (parts.length === 0) return text; + if (parts.length === 1) return parts[0]; + return parts; +} + +// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types +function highlightPaths( + children: React.ReactNode, + validatedPaths: Record +): React.ReactNode { + // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types + return React.Children.map(children, (child): React.ReactNode => { + if (typeof child === 'string') { + return highlightTextNode(child, validatedPaths); + } + + if (React.isValidElement<{ children?: React.ReactNode }>(child) && child.props.children) { + return React.cloneElement( + child, + undefined, + highlightPaths(child.props.children, validatedPaths) + ); + } + + return child; + }); +} + +/** + * Creates markdown components for user bubble rendering. + * Uses chat-user CSS variables for consistent styling and wraps + * text-bearing elements through highlightPaths for @path tag injection + * and optional search term highlighting. + */ +function createUserMarkdownComponents( + validatedPaths: Record, + searchCtx: SearchContext | null +): Components { + const userTextColor = 'var(--chat-user-text)'; + + // Compose path highlighting with optional search highlighting + // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types + const hl = (children: React.ReactNode): React.ReactNode => { + const withPaths = highlightPaths(children, validatedPaths); + return searchCtx ? highlightSearchInChildren(withPaths, searchCtx) : withPaths; + }; + + return { + h1: ({ children }) => ( +

+ {hl(children)} +

+ ), + h2: ({ children }) => ( +

+ {hl(children)} +

+ ), + h3: ({ children }) => ( +

+ {hl(children)} +

+ ), + h4: ({ children }) => ( +

+ {hl(children)} +

+ ), + h5: ({ children }) => ( +
+ {hl(children)} +
+ ), + h6: ({ children }) => ( +
+ {hl(children)} +
+ ), + + p: ({ children }) => ( +

+ {hl(children)} +

+ ), + + // Inline elements — no hl(); parent block element's hl() descends here + a: ({ href, children }) => ( + + {children} + + ), + + strong: ({ children }) => ( + + {children} + + ), + + em: ({ children }) => ( + + {children} + + ), + + del: ({ children }) => ( + + {children} + + ), + + code: ({ className, children }) => { + const hasLanguageClass = className?.includes('language-'); + const content = typeof children === 'string' ? children : ''; + const isMultiLine = content.includes('\n'); + const isBlock = (hasLanguageClass ?? false) || isMultiLine; + + if (isBlock) { + return ( + + {hl(children)} + + ); + } + // Inline code — no hl() + return ( + + {children} + + ); + }, + + pre: ({ children }) => ( +
+        {children}
+      
+ ), + + blockquote: ({ children }) => ( +
+ {hl(children)} +
+ ), + + ul: ({ children }) => ( +
    + {children} +
+ ), + ol: ({ children }) => ( +
    + {children} +
+ ), + li: ({ children }) => ( +
  • + {hl(children)} +
  • + ), + + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {hl(children)} + + ), + td: ({ children }) => ( + + {hl(children)} + + ), + + hr: () =>
    , + }; +} + +/** + * UserChatGroup displays a user's input message. + * Features: + * - Right-aligned bubble layout with subtle blue styling + * - Header with user icon, label, and timestamp + * - Markdown rendering with inline highlighted mentions (@paths) + * - Copy button on hover + * - Toggle for long content (>500 chars) + * - Shows image count indicator + */ +const UserChatGroupInner = ({ userGroup }: Readonly): React.JSX.Element => { + const { content, timestamp, id: groupId } = userGroup; + const [isManuallyExpanded, setIsManuallyExpanded] = useState(false); + const [validatedPaths, setValidatedPaths] = useState>({}); + + // Get projectPath from per-tab session data, falling back to global state + const { tabId } = useTabUI(); + const projectPath = useStore((s) => { + const td = tabId ? s.tabSessionData[tabId] : null; + return (td?.sessionDetail ?? s.sessionDetail)?.session?.projectPath; + }); + + // Get search state for highlighting + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => ({ + searchQuery: s.searchQuery, + searchMatches: s.searchMatches, + currentSearchIndex: s.currentSearchIndex, + })) + ); + + const hasImages = content.images.length > 0; + // Use rawText to preserve /commands inline + const textContent = content.rawText ?? content.text ?? ''; + const isLongContent = textContent.length > 500; + + // Extract @path mentions from text + const pathMentions = useMemo(() => { + if (!textContent) return []; + const result: { value: string; raw: string }[] = []; + const pathPattern = new RegExp(PATH_PATTERN.source, PATH_PATTERN.flags); + let match; + while ((match = pathPattern.exec(textContent)) !== null) { + result.push({ value: match[1], raw: match[0] }); + } + return result; + }, [textContent]); + + // Validate @path mentions via IPC + useEffect(() => { + if (pathMentions.length === 0 || !projectPath) return; + let isCurrent = true; + + const validatePaths = async (): Promise => { + try { + const toValidate = pathMentions.map((m) => ({ type: 'path' as const, value: m.value })); + const results = await window.electronAPI.validateMentions(toValidate, projectPath); + if (isCurrent) { + setValidatedPaths(results); + } + } catch (err) { + logger.error('Path validation failed:', err); + if (isCurrent) { + setValidatedPaths({}); + } + } + }; + + void validatePaths(); + return () => { + isCurrent = false; + }; + }, [textContent, projectPath, pathMentions]); + + const effectiveValidatedPaths = useMemo( + () => (pathMentions.length === 0 || !projectPath ? {} : validatedPaths), + [pathMentions.length, projectPath, validatedPaths] + ); + + // Create search context (fresh each render so counter starts at 0) + const searchCtx = searchQuery + ? createSearchContext(searchQuery, groupId, searchMatches, currentSearchIndex) + : null; + + // Base markdown components (no search) — safe to memoize + const userMarkdownComponentsBase = useMemo( + () => createUserMarkdownComponents(effectiveValidatedPaths, null), + [effectiveValidatedPaths] + ); + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const userMarkdownComponents = searchCtx + ? createUserMarkdownComponents(effectiveValidatedPaths, searchCtx) + : userMarkdownComponentsBase; + + // Auto-expand when search is active and this message has ANY matches. + // Without this, the pre-counter searches full text but the renderer only + // shows the first 500 chars — creating phantom matches. + const shouldAutoExpand = useMemo(() => { + if (!searchQuery || !isLongContent) return false; + return searchMatches.some((m) => m.itemId === groupId); + }, [searchQuery, isLongContent, searchMatches, groupId]); + + // Combined expansion state: manual toggle or auto-expand for search + const isExpanded = isManuallyExpanded || shouldAutoExpand; + + // Determine display text + const displayText = + isLongContent && !isExpanded ? textContent.slice(0, 500) + '...' : textContent; + + return ( +
    +
    + {/* Header - right aligned with improved hierarchy */} +
    + + {format(timestamp, 'h:mm:ss a')} + + + You + + +
    + + {/* Content - polished bubble with subtle depth */} + {textContent && ( +
    + + +
    + + {displayText} + +
    + {isLongContent && ( + + )} +
    + )} + + {/* Images indicator */} + {hasImages && ( +
    + {content.images.length} image{content.images.length > 1 ? 's' : ''} attached +
    + )} +
    +
    + ); +}; + +export const UserChatGroup = React.memo(UserChatGroupInner); diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx new file mode 100644 index 00000000..3f160ce4 --- /dev/null +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -0,0 +1,192 @@ +import React from 'react'; + +import { TOOL_ITEM_MUTED } from '@renderer/constants/cssVariables'; +import { getTriggerColorDef, type TriggerColor } from '@shared/constants/triggerColors'; +import { ChevronRight } from 'lucide-react'; + +import { formatDuration, formatTokens, getStatusDotColor } from './baseItemHelpers'; + +// ============================================================================= +// Types +// ============================================================================= + +export type ItemStatus = 'ok' | 'error' | 'pending' | 'orphaned'; + +interface BaseItemProps { + /** Icon component to display */ + icon: React.ReactNode; + /** Primary label (e.g., "Thinking", "Output", tool name) */ + label: string; + /** Summary text shown after the label */ + summary?: string; + /** Token count to display */ + tokenCount?: number; + /** Label for tokens (default: "tokens") */ + tokenLabel?: string; + /** Status indicator (green/red/gray dot) */ + status?: ItemStatus; + /** Duration in milliseconds */ + durationMs?: number; + /** Click handler for toggling */ + onClick: () => void; + /** Whether the item is expanded */ + isExpanded: boolean; + /** Whether the item has expandable content */ + hasExpandableContent?: boolean; + /** Additional classes for highlighting (e.g., error deep linking) */ + highlightClasses?: string; + /** Inline styles for highlighting (used by custom hex colors) */ + highlightStyle?: React.CSSProperties; + /** Notification dot color for custom triggers */ + notificationDotColor?: TriggerColor; + /** Children rendered when expanded */ + children?: React.ReactNode; +} + +// ============================================================================= +// Helper Components +// ============================================================================= + +/** + * Small status dot indicator. + */ +export const StatusDot: React.FC<{ status: ItemStatus }> = ({ status }) => { + return ( + + ); +}; + +// ============================================================================= +// Main Component +// ============================================================================= + +/** + * BaseItem provides a consistent layout for all expandable items in the chat view. + * + * Layout: + * - Clickable header row with icon, label, summary, tokens, status, and chevron + * - Expanded content area with left border indent + * + * Used by: ThinkingItem, TextItem, LinkedToolItem, SlashItem, SubagentItem + */ +export const BaseItem: React.FC = ({ + icon, + label, + summary, + tokenCount, + tokenLabel = 'tokens', + status, + durationMs, + onClick, + isExpanded, + hasExpandableContent = true, + highlightClasses = '', + highlightStyle, + notificationDotColor, + children, +}) => { + return ( +
    + {/* Clickable Header */} +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="group flex cursor-pointer items-center gap-2 rounded px-2 py-1.5" + style={{ backgroundColor: 'transparent' }} + onMouseEnter={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'var(--tool-item-hover-bg)' }) + } + onMouseLeave={(e) => + Object.assign(e.currentTarget.style, { backgroundColor: 'transparent' }) + } + > + {/* Icon */} + + {icon} + + + {/* Label */} + + {label} + + + {/* Separator and Summary */} + {summary && ( + <> + + - + + + {summary} + + + )} + + {/* Spacer if no summary */} + {!summary && } + + {/* Token count badge */} + {tokenCount != null && tokenCount > 0 && ( + + ~{formatTokens(tokenCount)} {tokenLabel} + + )} + + {/* Status indicator - hidden when notification dot replaces it */} + {status && !notificationDotColor && } + + {/* Notification dot (replaces status dot when present) */} + {notificationDotColor && ( + + )} + + {/* Duration */} + {durationMs !== undefined && ( + + {formatDuration(durationMs)} + + )} + + {/* Expand/collapse chevron */} + {hasExpandableContent && ( + + )} +
    + + {/* Expanded Content */} + {isExpanded && children && ( +
    + {children} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx new file mode 100644 index 00000000..d6010405 --- /dev/null +++ b/src/renderer/components/chat/items/ExecutionTrace.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; + +import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; +import { truncateText } from '@renderer/utils/aiGroupEnhancer'; + +import { LinkedToolItem } from './LinkedToolItem'; +import { TextItem } from './TextItem'; +import { ThinkingItem } from './ThinkingItem'; + +import type { AIGroupDisplayItem } from '@renderer/types/groups'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +// ============================================================================= +// Types +// ============================================================================= + +interface ExecutionTraceProps { + items: AIGroupDisplayItem[]; + aiGroupId: string; + highlightToolUseId?: string; + /** Custom highlight color from trigger */ + highlightColor?: TriggerColor; + /** Map of tool use ID to trigger color for notification dots */ + notificationColorMap?: Map; + searchExpandedItemId?: string | null; + /** Optional callback to register tool element refs for scroll targeting */ + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; +} + +// ============================================================================= +// Execution Trace Component +// ============================================================================= + +export const ExecutionTrace: React.FC = ({ + items, + aiGroupId: _aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + searchExpandedItemId, + registerToolRef, +}): React.JSX.Element => { + const [manualExpandedItemId, setManualExpandedItemId] = useState(null); + + // Use searchExpandedItemId if set, otherwise use manually expanded item + const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; + + const handleItemClick = (itemId: string): void => { + setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); + }; + + if (!items || items.length === 0) { + return ( +
    + No execution items +
    + ); + } + + return ( +
    + {items.map((item, index) => { + switch (item.type) { + case 'thinking': { + const itemId = `subagent-thinking-${index}`; + const thinkingStep = { + id: itemId, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'output': { + const itemId = `subagent-output-${index}`; + const textStep = { + id: itemId, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'tool': { + const itemId = `subagent-tool-${item.tool.id}`; + const isExpanded = expandedItemId === itemId; + const isHighlighted = highlightToolUseId === item.tool.id; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + isHighlighted={isHighlighted} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + } + + case 'subagent': + return ( +
    + Nested: {item.subagent.description ?? item.subagent.id} +
    + ); + + default: + return null; + } + })} +
    + ); +}; diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx new file mode 100644 index 00000000..c11798d1 --- /dev/null +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -0,0 +1,207 @@ +/** + * LinkedToolItem + * + * Main component for rendering linked tool calls in the chat view. + * Uses specialized viewers for different tool types and shared utilities + * for summary generation and token calculation. + */ + +import React, { useRef } from 'react'; + +import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { + getToolContextTokens, + getToolStatus, + getToolSummary, + hasEditContent, + hasReadContent, + hasSkillInstructions, + hasWriteContent, +} from '@renderer/utils/toolRendering'; +import { + getToolHighlightProps, + getTriggerColorDef, + isPresetColorKey, + TOOL_HIGHLIGHT_CLASSES, + type TriggerColor, +} from '@shared/constants/triggerColors'; +import { Wrench } from 'lucide-react'; + +import { BaseItem, StatusDot } from './BaseItem'; +import { formatDuration } from './baseItemHelpers'; +import { + DefaultToolViewer, + EditToolViewer, + ReadToolViewer, + SkillToolViewer, + ToolErrorDisplay, + WriteToolViewer, +} from './linkedTool'; + +import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; + +interface LinkedToolItemProps { + linkedTool: LinkedToolItemType; + onClick: () => void; + isExpanded: boolean; + /** Whether this item should be highlighted for error deep linking */ + isHighlighted?: boolean; + /** Custom highlight color from trigger */ + highlightColor?: TriggerColor; + /** Notification dot color for this tool item */ + notificationDotColor?: TriggerColor; + /** Optional ref registration callback for external scroll control */ + registerRef?: (el: HTMLDivElement | null) => void; +} + +export const LinkedToolItem: React.FC = ({ + linkedTool, + onClick, + isExpanded, + isHighlighted, + highlightColor, + notificationDotColor, + registerRef, +}) => { + const status = getToolStatus(linkedTool); + const summary = getToolSummary(linkedTool.name, linkedTool.input); + const elementRef = useRef(null); + + // Combined ref callback - handles both internal ref and external registration + const handleRef = (el: HTMLDivElement | null): void => { + // Update internal ref + (elementRef as React.MutableRefObject).current = el; + // Call external registration if provided + registerRef?.(el); + }; + + // Render teammate_spawned results as a minimal inline row + const isTeammateSpawned = linkedTool.result?.toolUseResult?.status === 'teammate_spawned'; + if (isTeammateSpawned) { + const teamResult = linkedTool.result!.toolUseResult!; + const name = (teamResult.name as string) || 'teammate'; + const color = (teamResult.color as string) || ''; + const colors = getTeamColorSet(color); + return ( +
    + + + {name} + + + Teammate spawned + +
    + ); + } + + // Render SendMessage shutdown_request as a minimal inline row + const isShutdownRequest = + linkedTool.name === 'SendMessage' && linkedTool.input?.type === 'shutdown_request'; + if (isShutdownRequest) { + const target = (linkedTool.input?.recipient as string) || 'teammate'; + return ( +
    + + + Shutdown requested →{' '} + {target} + +
    + ); + } + + // Note: We no longer scroll locally - the navigation coordinator handles this + // via the registered ref. This prevents double-scroll issues. + + // Highlight animation for error deep linking (supports custom hex) + const effectiveColor = highlightColor ?? 'red'; + let highlightClasses = ''; + let highlightStyle: React.CSSProperties | undefined; + if (isHighlighted) { + if (isPresetColorKey(effectiveColor)) { + highlightClasses = TOOL_HIGHLIGHT_CLASSES[effectiveColor]; + } else { + const hp = getToolHighlightProps(effectiveColor); + highlightClasses = hp.className; + highlightStyle = hp.style; + } + } + + // Determine which specialized viewer to use + const useReadViewer = + linkedTool.name === 'Read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; + const useEditViewer = linkedTool.name === 'Edit' && hasEditContent(linkedTool); + const useWriteViewer = + linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; + const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); + const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; + + // Check if we should show error display for Read/Write tools + const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError; + const showWriteError = linkedTool.name === 'Write' && linkedTool.result?.isError; + + return ( +
    + + } + label={linkedTool.name} + summary={summary} + tokenCount={getToolContextTokens(linkedTool)} + status={status} + durationMs={linkedTool.durationMs} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {/* Read tool with CodeBlockViewer */} + {useReadViewer && } + + {/* Edit tool with DiffViewer */} + {useEditViewer && } + + {/* Write tool */} + {useWriteViewer && } + + {/* Skill tool with instructions */} + {useSkillViewer && } + + {/* Default rendering for other tools */} + {useDefaultViewer && } + + {/* Error output for Read tool */} + {showReadError && } + + {/* Error output for Write tool */} + {showWriteError && } + + {/* Orphaned indicator */} + {linkedTool.isOrphaned && ( +
    + + No result received +
    + )} + + {/* Timing */} +
    + Duration: {formatDuration(linkedTool.durationMs)} +
    +
    +
    + ); +}; diff --git a/src/renderer/components/chat/items/MetricsPill.tsx b/src/renderer/components/chat/items/MetricsPill.tsx new file mode 100644 index 00000000..73213a29 --- /dev/null +++ b/src/renderer/components/chat/items/MetricsPill.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { + CARD_ICON_MUTED, + CARD_SEPARATOR, + CARD_TEXT_LIGHT, + COLOR_TEXT_MUTED, + TAG_BG, + TAG_BORDER, + TAG_TEXT, +} from '@renderer/constants/cssVariables'; +import { formatTokensCompact } from '@renderer/utils/formatters'; + +// ============================================================================= +// Types +// ============================================================================= + +interface MetricsPillProps { + mainSessionImpact?: { + callTokens: number; + resultTokens: number; + totalTokens: number; + }; + lastUsage?: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + /** Label override for the right segment (e.g. "Context Window" for team members) */ + isolatedLabel?: string; +} + +// ============================================================================= +// Unified Metrics Pill - Compact monospace pill with tooltip +// ============================================================================= + +export const MetricsPill = ({ + mainSessionImpact, + lastUsage, + isolatedLabel, +}: Readonly): React.ReactElement | null => { + const [showTooltip, setShowTooltip] = useState(false); + const [tooltipStyle, setTooltipStyle] = useState({}); + const containerRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + + const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0; + const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + + const isolatedTotal = lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; + + const clearHideTimeout = (): void => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + const handleMouseEnter = (): void => { + clearHideTimeout(); + setShowTooltip(true); + }; + + const handleMouseLeave = (): void => { + clearHideTimeout(); + hideTimeoutRef.current = setTimeout(() => setShowTooltip(false), 100); + }; + + useEffect(() => { + if (showTooltip && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const tooltipWidth = 220; + let left = rect.left + rect.width / 2 - tooltipWidth / 2; + if (left < 8) left = 8; + if (left + tooltipWidth > window.innerWidth - 8) { + left = window.innerWidth - tooltipWidth - 8; + } + setTooltipStyle({ + position: 'fixed', + bottom: window.innerHeight - rect.top + 6, + left, + width: tooltipWidth, + zIndex: 99999, + }); + } + }, [showTooltip]); + + useEffect(() => { + if (!showTooltip) return; + const handleScroll = (): void => setShowTooltip(false); + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [showTooltip]); + + useEffect(() => { + return () => clearHideTimeout(); + }, []); + + if (!hasMainImpact && !hasIsolated) { + return null; + } + + const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null; + const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null; + const rightLabel = isolatedLabel ?? 'Isolated Usage'; + + return ( + <> +
    + {mainValue && {mainValue}} + {mainValue && isolatedValue && |} + {isolatedValue && {isolatedValue}} +
    + + {showTooltip && + createPortal( +
    +
    + {hasMainImpact && ( +
    + Main Context + + {mainSessionImpact.totalTokens.toLocaleString()} + +
    + )} + {hasIsolated && ( +
    + {rightLabel} + + {isolatedTotal.toLocaleString()} + +
    + )} +
    + {hasMainImpact && hasIsolated + ? 'Left: parent injection · Right: internal' + : hasMainImpact + ? 'Tokens injected to parent' + : 'Internal token usage'} +
    +
    +
    , + document.body + )} + + ); +}; diff --git a/src/renderer/components/chat/items/SlashItem.tsx b/src/renderer/components/chat/items/SlashItem.tsx new file mode 100644 index 00000000..146a35ec --- /dev/null +++ b/src/renderer/components/chat/items/SlashItem.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { Slash } from 'lucide-react'; + +import { MarkdownViewer } from '../viewers'; + +import { BaseItem } from './BaseItem'; + +import type { SlashItem as SlashItemType } from '@renderer/types/groups'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface SlashItemProps { + slash: SlashItemType; + onClick: () => void; + isExpanded: boolean; + /** Additional classes for highlighting (e.g., error deep linking) */ + highlightClasses?: string; + /** Inline styles for highlighting (used by custom hex colors) */ + highlightStyle?: React.CSSProperties; + /** Notification dot color for custom triggers */ + notificationDotColor?: TriggerColor; +} + +/** + * SlashItem displays a slash command invocation. + * This unified component handles all slash types: + * - Skills (e.g., /isolate-context) + * - Built-in commands (e.g., /model, /context) + * - Plugin commands + * - MCP commands + * - User-defined commands + */ +export const SlashItem: React.FC = ({ + slash, + onClick, + isExpanded, + highlightClasses, + highlightStyle, + notificationDotColor, +}) => { + const hasInstructions = !!slash.instructions; + + // Display args or message as the description + const description = slash.args ?? slash.message; + + return ( + } + label={`/${slash.name}`} + summary={description} + tokenCount={slash.instructionsTokenCount} + tokenLabel="tokens" + status={hasInstructions ? 'ok' : undefined} + onClick={onClick} + isExpanded={isExpanded} + hasExpandableContent={hasInstructions} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + {hasInstructions && ( + + )} + + ); +}; diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx new file mode 100644 index 00000000..8112fe6f --- /dev/null +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -0,0 +1,538 @@ +import React, { useCallback, useMemo, useState } from 'react'; + +import { + CARD_BG, + CARD_BORDER_STYLE, + CARD_HEADER_BG, + CARD_HEADER_HOVER, + CARD_ICON_MUTED, + CARD_SEPARATOR, + CARD_TEXT_LIGHT, + CARD_TEXT_LIGHTER, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, + TAG_BG, + TAG_BORDER, + TAG_TEXT, +} from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { useTabUI } from '@renderer/hooks/useTabUI'; +import { useStore } from '@renderer/store'; +import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer'; +import { formatDuration } from '@renderer/utils/formatters'; +import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors'; +import { getModelColorClass, parseModelString } from '@shared/utils/modelParser'; +import { + ArrowUpRight, + Bot, + CheckCircle2, + ChevronRight, + CircleDot, + Loader2, + Sigma, + Terminal, +} from 'lucide-react'; + +import { ExecutionTrace } from './ExecutionTrace'; +import { MetricsPill } from './MetricsPill'; + +import type { Process, SemanticStep } from '@renderer/types/data'; + +// ============================================================================= +// Types +// ============================================================================= + +interface SubagentItemProps { + step: SemanticStep; + subagent: Process; + onClick: () => void; + isExpanded: boolean; + aiGroupId: string; + /** Tool use ID to highlight for error deep linking */ + highlightToolUseId?: string; + /** Custom highlight color from trigger */ + highlightColor?: TriggerColor; + /** Map of tool use ID to trigger color for notification dots */ + notificationColorMap?: Map; + /** Optional callback to register tool element refs for scroll targeting */ + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; +} + +// ============================================================================= +// Main Component - Linear-style DevTools Card +// ============================================================================= + +export const SubagentItem: React.FC = ({ + step, + subagent, + onClick, + isExpanded, + aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, +}) => { + const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; + const subagentType = subagent.subagentType ?? 'Task'; + const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; + + // Team member colors (when this subagent is a team member) + const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + + // Detect shutdown-only team activations (trivial: just a shutdown_response) + const isShutdownOnly = useMemo(() => { + if (!subagent.team || !subagent.messages?.length) return false; + const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); + if (assistantMsgs.length !== 1) return false; + const calls = assistantMsgs[0].toolCalls ?? []; + return ( + calls.length === 1 && + calls[0].name === 'SendMessage' && + calls[0].input?.type === 'shutdown_response' + ); + }, [subagent.team, subagent.messages]); + + // Per-tab trace expansion state (replaces local useState for true per-tab isolation) + const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); + const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); + + // Check if contains highlighted error + // Also matches when the highlight targets the parent Task tool_use that spawned this subagent + const containsHighlightedError = useMemo(() => { + if (!highlightToolUseId) return false; + // Match parent Task tool_use ID (trigger matched the Task call itself) + if (subagent.parentTaskId === highlightToolUseId) return true; + // Match inner tool calls/results within the subagent + if (!subagent.messages) return false; + for (const msg of subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; + if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; + } + return false; + }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); + + // Build display items + const displayItems = useMemo(() => { + if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { + return []; + } + return buildDisplayItemsFromMessages(subagent.messages, []); + }, [isExpanded, containsHighlightedError, subagent.messages]); + + // Build summary + const itemsSummary = useMemo(() => { + if (!isExpanded && !containsHighlightedError) { + const toolCount = + subagent.messages?.filter( + (m) => + m.type === 'assistant' && + Array.isArray(m.content) && + m.content.some((b) => b.type === 'tool_use') + ).length ?? 0; + return toolCount > 0 ? `${toolCount} tools` : ''; + } + return buildSummary(displayItems); + }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); + + // Model info + const modelInfo = useMemo(() => { + const msg = subagent.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + return msg?.model ? parseModelString(msg.model) : null; + }, [subagent.messages]); + + // Last usage + const lastUsage = useMemo(() => { + const messages = subagent.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'assistant' && messages[i].usage) { + return messages[i].usage; + } + } + return null; + }, [subagent.messages]); + + // Search expansion + const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds); + const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); + const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + + // Combine manual expansion with auto-expansion for errors/search + const isTraceExpanded = + isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; + const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); + + // Outer card highlight when this subagent contains the highlighted tool + const outerHighlight = useMemo(() => { + if (!containsHighlightedError) + return { className: '', style: undefined as React.CSSProperties | undefined }; + return getHighlightProps(highlightColor); + }, [containsHighlightedError, highlightColor]); + + // Register outer card as a tool ref target for the parent Task tool_use ID + // so the navigation controller can scroll directly to this SubagentItem + const outerCardRef = useCallback( + (el: HTMLDivElement | null) => { + if (subagent.parentTaskId && registerToolRef) { + registerToolRef(subagent.parentTaskId, el); + } + }, + [subagent.parentTaskId, registerToolRef] + ); + + // Cumulative metrics for team members — show total output generated + const cumulativeMetrics = useMemo(() => { + if (!subagent.team || !subagent.metrics) return undefined; + const turnCount = + subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; + return { + outputTokens: subagent.metrics.outputTokens, + turnCount, + }; + }, [subagent.team, subagent.metrics, subagent.messages]); + + // Computed values for metrics + const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; + const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const isolatedTotal = lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; + + // Shutdown-only team activations: minimal inline row (no metrics, no expand) + if (isShutdownOnly && teamColors && subagent.team) { + return ( +
    + + + {subagent.team.memberName} + + + Shutdown confirmed + + + + {formatDuration(subagent.durationMs)} + +
    + ); + } + + return ( +
    + {/* ========== Level 1: Clickable Header ========== */} +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + }} + > + {/* Expand chevron */} + + + {/* Icon - colored dot for team members, Bot icon for regular subagents */} + {teamColors ? ( + + ) : ( + + )} + + {/* Type badge - team member name or generic type */} + {teamColors && subagent.team ? ( + + {subagent.team.memberName} + + ) : ( + + {subagentType} + + )} + + {/* Model */} + {modelInfo && ( + + {modelInfo.name} + + )} + + {/* Description */} + + {truncatedDesc} + + + {/* Status indicator */} + {subagent.isOngoing ? ( + + ) : ( + + )} + + {/* Unified Metrics Pill — team members don't show mainSessionImpact + (spawn cost only; real main impact comes from teammate messages) */} + + + {/* Duration */} + + {formatDuration(subagent.durationMs)} + +
    + + {/* ========== Level 1 Expanded: Dashboard Content ========== */} + {isExpanded && ( +
    + {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} +
    + + Type{' '} + + {subagentType} + + + + + Duration{' '} + + {formatDuration(subagent.durationMs)} + + + {modelInfo && ( + <> + + + Model{' '} + + {modelInfo.name} + + + + )} + + + ID{' '} + + {subagent.id.slice(0, 8)} + + +
    + + {/* ========== Row 2: Context Usage (Clean List) ========== */} + {(hasMainImpact ?? hasIsolated) && ( +
    + {/* Overline title */} +
    + Context Usage +
    + + {/* Token rows - floating alignment */} +
    + {hasMainImpact && !subagent.team && ( +
    +
    + + + Main Context + +
    + + {subagent.mainSessionImpact!.totalTokens.toLocaleString()} + +
    + )} + + {cumulativeMetrics && ( +
    +
    + + + Total Output + +
    + + {cumulativeMetrics.outputTokens.toLocaleString()} + + {' '} + ({cumulativeMetrics.turnCount} turns) + + +
    + )} + + {hasIsolated && ( +
    +
    + + + {subagent.team ? 'Context Window' : 'Isolated Usage'} + +
    + + {isolatedTotal.toLocaleString()} + +
    + )} +
    +
    + )} + + {/* ========== Level 2: Execution Trace Toggle ========== */} + {displayItems.length > 0 && ( +
    + {/* Trace Header (clickable) */} +
    { + e.stopPropagation(); + toggleSubagentTraceExpansion(subagent.id); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleSubagentTraceExpansion(subagent.id); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', + }} + onMouseEnter={() => setIsTraceHeaderHovered(true)} + onMouseLeave={() => setIsTraceHeaderHovered(false)} + > + + + + Execution Trace + + + · {itemsSummary} + +
    + + {/* Trace Content */} + {isTraceExpanded && ( +
    + +
    + )} +
    + )} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/chat/items/TeammateMessageItem.tsx b/src/renderer/components/chat/items/TeammateMessageItem.tsx new file mode 100644 index 00000000..ebe286cf --- /dev/null +++ b/src/renderer/components/chat/items/TeammateMessageItem.tsx @@ -0,0 +1,263 @@ +import React, { useMemo } from 'react'; + +import { + CARD_BG, + CARD_BORDER_STYLE, + CARD_HEADER_BG, + CARD_ICON_MUTED, + CARD_TEXT_LIGHT, +} from '@renderer/constants/cssVariables'; +import { getTeamColorSet } from '@renderer/constants/teamColors'; +import { formatTokensCompact } from '@renderer/utils/formatters'; +import { ChevronRight, CornerDownLeft, MessageSquare, RefreshCw } from 'lucide-react'; + +import { MarkdownViewer } from '../viewers/MarkdownViewer'; + +import type { TeammateMessage } from '@renderer/types/groups'; + +// ============================================================================= +// Types +// ============================================================================= + +interface TeammateMessageItemProps { + teammateMessage: TeammateMessage; + onClick: () => void; + isExpanded: boolean; + /** Callback to spotlight the reply link: pass toolId on hover, null on leave */ + onReplyHover?: (toolId: string | null) => void; + /** Additional classes for highlighting (e.g., error deep linking) */ + highlightClasses?: string; + /** Inline styles for highlighting (used by custom hex colors) */ + highlightStyle?: React.CSSProperties; +} + +/** Operational noise types that should be rendered minimally */ +const NOISE_TYPES = new Set([ + 'idle_notification', + 'shutdown_approved', + 'teammate_terminated', + 'shutdown_request', +]); + +/** Human-readable labels for noise message types */ +const NOISE_LABELS: Record = { + idle_notification: 'Idle', + shutdown_approved: 'Shutdown confirmed', + teammate_terminated: 'Terminated', + shutdown_request: 'Shutdown requested', +}; + +/** + * Detect operational noise in teammate message content. + * Returns label if noise, null if real content. + */ +function detectNoise(content: string, teammateId: string): string | null { + // System messages are always noise + if (teammateId === 'system') { + const trimmed = content.trim(); + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as { type?: string; message?: string }; + if (parsed.type && NOISE_TYPES.has(parsed.type)) { + return parsed.message ?? NOISE_LABELS[parsed.type] ?? parsed.type; + } + } catch { + // Not JSON, fall through + } + } + return trimmed.length < 200 ? trimmed : null; + } + + // Non-system: check if content is a JSON operational message + const trimmed = content.trim(); + if (!trimmed.startsWith('{')) return null; + try { + const parsed = JSON.parse(trimmed) as { type?: string }; + if (parsed.type && NOISE_TYPES.has(parsed.type)) { + return NOISE_LABELS[parsed.type] ?? parsed.type; + } + } catch { + // Not JSON + } + return null; +} + +// ============================================================================= +// Resend Detection +// ============================================================================= + +const RESEND_PATTERNS = [ + /\bresend/i, + /\bre-send/i, + /\bsent\b.{0,20}\bearlier/i, + /\balready\s+sent/i, + /\bsent\s+in\s+my\s+previous/i, +]; + +function isResendMessage(message: TeammateMessage): boolean { + // Check summary first (cheaper) + if (RESEND_PATTERNS.some((p) => p.test(message.summary))) return true; + // Check first 300 chars of content + const contentSnippet = message.content.slice(0, 300); + return RESEND_PATTERNS.some((p) => p.test(contentSnippet)); +} + +// ============================================================================= +// Component +// ============================================================================= + +/** + * TeammateMessageItem - Card component for teammate messages. + * + * Visual distinction from SubagentItem: + * - Left color accent border (3px) + * - "Message" type label after name badge + * - No metrics pill, no duration, no model info + * + * Operational noise (idle/shutdown/terminated) renders as minimal inline text. + */ +export const TeammateMessageItem: React.FC = ({ + teammateMessage, + onClick, + isExpanded, + onReplyHover, + highlightClasses = '', + highlightStyle, +}) => { + const colors = getTeamColorSet(teammateMessage.color); + + // Detect operational noise + const noiseLabel = useMemo( + () => detectNoise(teammateMessage.content, teammateMessage.teammateId), + [teammateMessage.content, teammateMessage.teammateId] + ); + + // Detect resent/duplicate messages + const isResend = useMemo(() => isResendMessage(teammateMessage), [teammateMessage]); + + // Noise: minimal inline row (no card, no expand) + if (noiseLabel) { + return ( +
    + + + {teammateMessage.teammateId} + + + {noiseLabel} + +
    + ); + } + + // Real message: full card with visual distinction + const truncatedSummary = + teammateMessage.summary.length > 80 + ? teammateMessage.summary.slice(0, 80) + '...' + : teammateMessage.summary; + + return ( +
    + {/* Header */} +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + }} + > + + + {/* Message icon — distinguishes from SubagentItem's Bot/dot icon */} + + + {/* Teammate name badge */} + + {teammateMessage.teammateId} + + + {/* "Message" type label — parallels SubagentItem's model info */} + + Message + + + {/* Reply indicator — shows which SendMessage triggered this response */} + {teammateMessage.replyToSummary && ( + onReplyHover?.(teammateMessage.replyToToolId ?? null)} + onMouseLeave={() => onReplyHover?.(null)} + > + + + {teammateMessage.replyToSummary} + + + )} + + {/* Resend badge — marks duplicate/resent messages */} + {isResend && ( + + + Resent + + )} + + {/* Summary */} + + {truncatedSummary || 'Teammate message'} + + + {/* Context impact — tokens injected into main session */} + {teammateMessage.tokenCount != null && teammateMessage.tokenCount > 0 && ( + + ~{formatTokensCompact(teammateMessage.tokenCount)} tokens + + )} +
    + + {/* Expanded content */} + {isExpanded && ( +
    + +
    + )} +
    + ); +}; diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx new file mode 100644 index 00000000..63b9d17c --- /dev/null +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { MessageSquare } from 'lucide-react'; + +import { MarkdownViewer } from '../viewers'; + +import { BaseItem } from './BaseItem'; +import { truncateText } from './baseItemHelpers'; + +import type { SemanticStep } from '@renderer/types/data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface TextItemProps { + step: SemanticStep; + preview: string; + onClick: () => void; + isExpanded: boolean; + /** Additional classes for highlighting (e.g., error deep linking) */ + highlightClasses?: string; + /** Inline styles for highlighting (used by custom hex colors) */ + highlightStyle?: React.CSSProperties; + /** Notification dot color for custom triggers */ + notificationDotColor?: TriggerColor; +} + +export const TextItem: React.FC = ({ + step, + preview, + onClick, + isExpanded, + highlightClasses, + highlightStyle, + notificationDotColor, +}) => { + const fullContent = step.content.outputText ?? preview; + const truncatedPreview = truncateText(preview, 60); + + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + + return ( + } + label="Output" + summary={truncatedPreview} + tokenCount={tokenCount} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); +}; diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx new file mode 100644 index 00000000..e1034ef4 --- /dev/null +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { Brain } from 'lucide-react'; + +import { MarkdownViewer } from '../viewers'; + +import { BaseItem } from './BaseItem'; +import { truncateText } from './baseItemHelpers'; + +import type { SemanticStep } from '@renderer/types/data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface ThinkingItemProps { + step: SemanticStep; + preview: string; + onClick: () => void; + isExpanded: boolean; + /** Additional classes for highlighting (e.g., error deep linking) */ + highlightClasses?: string; + /** Inline styles for highlighting (used by custom hex colors) */ + highlightStyle?: React.CSSProperties; + /** Notification dot color for custom triggers */ + notificationDotColor?: TriggerColor; +} + +export const ThinkingItem: React.FC = ({ + step, + preview, + onClick, + isExpanded, + highlightClasses, + highlightStyle, + notificationDotColor, +}) => { + const fullContent = step.content.thinkingText ?? preview; + const truncatedPreview = truncateText(preview, 60); + + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + + return ( + } + label="Thinking" + summary={truncatedPreview} + tokenCount={tokenCount} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); +}; diff --git a/src/renderer/components/chat/items/baseItemHelpers.ts b/src/renderer/components/chat/items/baseItemHelpers.ts new file mode 100644 index 00000000..ff2b1724 --- /dev/null +++ b/src/renderer/components/chat/items/baseItemHelpers.ts @@ -0,0 +1,42 @@ +/** + * Helper functions for BaseItem component. + * Extracted to a separate file to comply with react-refresh/only-export-components. + */ + +import { formatTokens } from '@shared/utils/tokenFormatting'; + +import type { ItemStatus } from './BaseItem'; + +// Re-export for backwards compatibility +export { formatTokens }; + +/** + * Formats duration in milliseconds to a human-readable string. + */ +export function formatDuration(ms: number | undefined): string { + if (ms === undefined) return '...'; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Truncates text to a maximum length with ellipsis. + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.slice(0, maxLength) + '...'; +} + +/** + * Get background color for status dot. + * Returns CSS value (hex for semantic colors, CSS variable for neutral). + */ +export function getStatusDotColor(status: ItemStatus): string { + const colors: Record = { + ok: '#22c55e', // green-500 - semantic success + error: '#ef4444', // red-500 - semantic error + pending: '#eab308', // yellow-500 - semantic pending + orphaned: 'var(--tool-item-muted)', // theme-aware neutral + }; + return colors[status]; +} diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx new file mode 100644 index 00000000..1be3f906 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -0,0 +1,67 @@ +/** + * DefaultToolViewer + * + * Default rendering for tools that don't have specialized viewers. + */ + +import React from 'react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +import { renderInput, renderOutput } from './renderHelpers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface DefaultToolViewerProps { + linkedTool: LinkedToolItem; + status: ItemStatus; +} + +export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { + return ( + <> + {/* Input Section */} +
    +
    + Input +
    +
    + {renderInput(linkedTool.name, linkedTool.input)} +
    +
    + + {/* Output Section */} + {!linkedTool.isOrphaned && linkedTool.result && ( +
    +
    + Output + +
    +
    + {renderOutput(linkedTool.result.content)} +
    +
    + )} + + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx new file mode 100644 index 00000000..b924a9be --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/EditToolViewer.tsx @@ -0,0 +1,73 @@ +/** + * EditToolViewer + * + * Renders the Edit tool with DiffViewer. + */ + +import React from 'react'; + +import { DiffViewer } from '@renderer/components/chat/viewers'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; +import { formatTokens } from '../baseItemHelpers'; + +import { renderOutput } from './renderHelpers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface EditToolViewerProps { + linkedTool: LinkedToolItem; + status: ItemStatus; +} + +export const EditToolViewer: React.FC = ({ linkedTool, status }) => { + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; + + const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); + const oldString = + (toolUseResult?.oldString as string) || (linkedTool.input.old_string as string) || ''; + const newString = + (toolUseResult?.newString as string) || (linkedTool.input.new_string as string) || ''; + + return ( +
    + + + {/* Show result status if available */} + {!linkedTool.isOrphaned && linkedTool.result != null && ( +
    +
    + Result + + {linkedTool.result?.tokenCount !== undefined && linkedTool.result.tokenCount > 0 && ( + + ~{formatTokens(linkedTool.result.tokenCount)} tokens + + )} +
    +
    + {renderOutput(linkedTool.result.content)} +
    +
    + )} +
    + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx new file mode 100644 index 00000000..f0eeb688 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -0,0 +1,65 @@ +/** + * ReadToolViewer + * + * Renders the Read tool result using CodeBlockViewer. + */ + +import React from 'react'; + +import { CodeBlockViewer } from '@renderer/components/chat/viewers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface ReadToolViewerProps { + linkedTool: LinkedToolItem; +} + +export const ReadToolViewer: React.FC = ({ linkedTool }) => { + const filePath = linkedTool.input.file_path as string; + + // Prefer enriched toolUseResult data + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; + const fileData = toolUseResult?.file as + | { + content?: string; + startLine?: number; + totalLines?: number; + numLines?: number; + } + | undefined; + + // Get content: prefer enriched file data, fall back to raw result content + let content: string; + if (fileData?.content) { + content = fileData.content; + } else { + const resultContent = linkedTool.result?.content; + content = + typeof resultContent === 'string' + ? resultContent + : Array.isArray(resultContent) + ? resultContent + .map((item: unknown) => (typeof item === 'string' ? item : JSON.stringify(item))) + .join('\n') + : JSON.stringify(resultContent, null, 2); + } + + // Get line range + const startLine = fileData?.startLine ?? (linkedTool.input.offset as number | undefined) ?? 1; + const numLinesRead = fileData?.numLines; + const limit = linkedTool.input.limit as number | undefined; + const endLine = numLinesRead + ? startLine + numLinesRead - 1 + : limit + ? startLine + limit - 1 + : undefined; + + return ( + + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx new file mode 100644 index 00000000..c6447d79 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/SkillToolViewer.tsx @@ -0,0 +1,67 @@ +/** + * SkillToolViewer + * + * Renders the Skill tool with its instructions in a code block viewer style. + */ + +import React from 'react'; + +import { CodeBlockViewer } from '@renderer/components/chat/viewers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface SkillToolViewerProps { + linkedTool: LinkedToolItem; +} + +export const SkillToolViewer: React.FC = ({ linkedTool }) => { + const skillInstructions = linkedTool.skillInstructions; + const skillName = (linkedTool.input.skill as string) || 'Unknown Skill'; + + const resultContent = linkedTool.result?.content; + const resultText = + typeof resultContent === 'string' + ? resultContent + : Array.isArray(resultContent) + ? resultContent + .map((item: unknown) => (typeof item === 'string' ? item : JSON.stringify(item))) + .join('\n') + : ''; + + return ( +
    + {/* Initial result */} + {resultText && ( +
    +
    + Result +
    +
    + {resultText} +
    +
    + )} + + {/* Skill instructions */} + {skillInstructions && ( +
    +
    + Skill Instructions +
    + +
    + )} +
    + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx new file mode 100644 index 00000000..13f3fd76 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/ToolErrorDisplay.tsx @@ -0,0 +1,43 @@ +/** + * ToolErrorDisplay + * + * Displays error output for tool results. + */ + +import React from 'react'; + +import { StatusDot } from '../BaseItem'; + +import { renderOutput } from './renderHelpers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface ToolErrorDisplayProps { + linkedTool: LinkedToolItem; +} + +export const ToolErrorDisplay: React.FC = ({ linkedTool }) => { + if (!linkedTool.result?.isError) return null; + + return ( +
    +
    + Error + +
    +
    + {renderOutput(linkedTool.result.content)} +
    +
    + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx new file mode 100644 index 00000000..22cd90b1 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -0,0 +1,32 @@ +/** + * WriteToolViewer + * + * Renders the Write tool result. + */ + +import React from 'react'; + +import { CodeBlockViewer } from '@renderer/components/chat/viewers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface WriteToolViewerProps { + linkedTool: LinkedToolItem; +} + +export const WriteToolViewer: React.FC = ({ linkedTool }) => { + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; + + const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); + const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; + const isCreate = toolUseResult?.type === 'create'; + + return ( +
    +
    + {isCreate ? 'Created file' : 'Wrote to file'} +
    + +
    + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts new file mode 100644 index 00000000..5c415dac --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -0,0 +1,12 @@ +/** + * Linked Tool Sub-components + * + * Exports all specialized tool viewer components. + */ + +export { DefaultToolViewer } from './DefaultToolViewer'; +export { EditToolViewer } from './EditToolViewer'; +export { ReadToolViewer } from './ReadToolViewer'; +export { SkillToolViewer } from './SkillToolViewer'; +export { ToolErrorDisplay } from './ToolErrorDisplay'; +export { WriteToolViewer } from './WriteToolViewer'; diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx new file mode 100644 index 00000000..634ee641 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -0,0 +1,116 @@ +/** + * Render Helpers + * + * Shared rendering functions for tool input and output. + */ + +import React from 'react'; + +import { + COLOR_TEXT, + COLOR_TEXT_MUTED, + DIFF_ADDED_TEXT, + DIFF_REMOVED_TEXT, +} from '@renderer/constants/cssVariables'; + +/** + * Renders the input section based on tool type with theme-aware styling. + */ +export function renderInput(toolName: string, input: Record): React.ReactElement { + // Special rendering for Edit tool - show diff-like format + if (toolName === 'Edit') { + const filePath = input.file_path as string | undefined; + const oldString = input.old_string as string | undefined; + const newString = input.new_string as string | undefined; + const replaceAll = input.replace_all as boolean | undefined; + + return ( +
    + {filePath && ( +
    + {filePath} + {replaceAll && ( + + (replace all) + + )} +
    + )} + {oldString && ( +
    + {oldString.split('\n').map((line, i) => ( +
    - {line}
    + ))} +
    + )} + {newString && ( +
    + {newString.split('\n').map((line, i) => ( +
    + {line}
    + ))} +
    + )} +
    + ); + } + + // Special rendering for Bash tool + if (toolName === 'Bash') { + const command = input.command as string | undefined; + const description = input.description as string | undefined; + + return ( +
    + {description && ( +
    + {description} +
    + )} + {command && ( + + {command} + + )} +
    + ); + } + + // Special rendering for Read tool + if (toolName === 'Read') { + const filePath = input.file_path as string | undefined; + const offset = input.offset as number | undefined; + const limit = input.limit as number | undefined; + + return ( +
    +
    {filePath}
    + {(offset !== undefined || limit !== undefined) && ( +
    + {offset !== undefined && `offset: ${offset}`} + {offset !== undefined && limit !== undefined && ', '} + {limit !== undefined && `limit: ${limit}`} +
    + )} +
    + ); + } + + // Default: JSON format + return ( +
    +      {JSON.stringify(input, null, 2)}
    +    
    + ); +} + +/** + * Renders the output section with theme-aware styling. + */ +export function renderOutput(content: string | unknown[]): React.ReactElement { + const displayText = typeof content === 'string' ? content : JSON.stringify(content, null, 2); + return ( +
    +      {displayText}
    +    
    + ); +} diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx new file mode 100644 index 00000000..5ab54e9d --- /dev/null +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -0,0 +1,228 @@ +import React from 'react'; + +import { PROSE_BODY } from '@renderer/constants/cssVariables'; + +import { highlightSearchInChildren, type SearchContext } from './searchHighlightUtils'; + +import type { Components } from 'react-markdown'; + +/** + * Create inline markdown components for rendering prose content. + * When searchCtx is provided, search term highlighting is applied + * to text nodes while preserving full markdown rendering. + */ +export function createMarkdownComponents(searchCtx: SearchContext | null): Components { + const hl = (children: React.ReactNode): React.ReactNode => + searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + + return { + // Headings - Bold text with generous spacing to break up content + h1: ({ children }) => ( +

    + {hl(children)} +

    + ), + h2: ({ children }) => ( +

    + {hl(children)} +

    + ), + h3: ({ children }) => ( +

    + {hl(children)} +

    + ), + h4: ({ children }) => ( +

    + {hl(children)} +

    + ), + h5: ({ children }) => ( +
    + {hl(children)} +
    + ), + h6: ({ children }) => ( +
    + {hl(children)} +
    + ), + + // Paragraphs + p: ({ children }) => ( +

    + {hl(children)} +

    + ), + + // Links — inline element, no hl(); parent block element's hl() descends here + a: ({ href, children }) => ( + + {children} + + ), + + // Strong/Bold — inline element, no hl() + strong: ({ children }) => ( + + {children} + + ), + + // Emphasis/Italic — inline element, no hl() + em: ({ children }) => ( + + {children} + + ), + + // Strikethrough — inline element, no hl() + del: ({ children }) => ( + + {children} + + ), + + // Inline code vs block code + code: ({ className, children }) => { + const hasLanguageClass = className?.includes('language-'); + const content = typeof children === 'string' ? children : ''; + const isMultiLine = content.includes('\n'); + const isBlock = (hasLanguageClass ?? false) || isMultiLine; + + if (isBlock) { + return ( + + {hl(children)} + + ); + } + // Inline code — no hl(); parent block element's hl() descends here + return ( + + {children} + + ); + }, + + // Code blocks + pre: ({ children }) => ( +
    +        {children}
    +      
    + ), + + // Blockquotes + blockquote: ({ children }) => ( +
    + {hl(children)} +
    + ), + + // Lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + {hl(children)} +
  • + ), + + // Tables + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {hl(children)} + + ), + td: ({ children }) => ( + + {hl(children)} + + ), + + // Horizontal rule + hr: () =>
    , + }; +} + +/** Default markdown components without search highlighting (used by CompactBoundary) */ +export const markdownComponents: Components = createMarkdownComponents(null); diff --git a/src/renderer/components/chat/searchHighlightUtils.ts b/src/renderer/components/chat/searchHighlightUtils.ts new file mode 100644 index 00000000..873351b4 --- /dev/null +++ b/src/renderer/components/chat/searchHighlightUtils.ts @@ -0,0 +1,147 @@ +/** + * Search highlighting utilities for use within ReactMarkdown components. + * Recursively processes React children to highlight search term matches + * while preserving the markdown-rendered element tree. + */ + +import React from 'react'; + +import type { SearchMatch } from '@renderer/store/types'; + +// Highlight styles matching SearchHighlight.tsx +const baseStyles: React.CSSProperties = { + borderRadius: '0.125rem', + padding: '0 0.125rem', +}; + +const currentHighlightStyles: React.CSSProperties = { + ...baseStyles, + backgroundColor: 'var(--highlight-bg)', + color: 'var(--highlight-text)', + boxShadow: '0 0 0 1px var(--highlight-ring)', +}; + +const inactiveHighlightStyles: React.CSSProperties = { + ...baseStyles, + backgroundColor: 'var(--highlight-bg-inactive)', + color: 'var(--highlight-text-inactive)', +}; + +export interface SearchContext { + itemId: string; + query: string; + lowerQuery: string; + /** Mutable counter tracking match index within the item, incremented as text nodes are processed */ + matchCounter: { current: number }; + isCurrentItem: boolean; + currentMatchIndexInItem: number | null; +} + +/** + * Create a SearchContext from store state. + * Returns null if no search is active. + */ +export function createSearchContext( + searchQuery: string, + itemId: string, + searchMatches: SearchMatch[], + currentSearchIndex: number +): SearchContext | null { + if (!searchQuery || searchQuery.trim().length === 0) return null; + + const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null; + const isCurrentItem = currentMatch?.itemId === itemId; + + return { + itemId, + query: searchQuery, + lowerQuery: searchQuery.toLowerCase(), + matchCounter: { current: 0 }, + isCurrentItem, + currentMatchIndexInItem: isCurrentItem ? (currentMatch?.matchIndexInItem ?? null) : null, + }; +} + +/** + * Highlight search term matches in a text string. + * Increments matchCounter for each match found. + */ +// eslint-disable-next-line sonarjs/function-return-type -- mixed text/element return +function highlightSearchText(text: string, ctx: SearchContext): React.ReactNode { + const lowerText = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let pos = 0; + + while ((pos = lowerText.indexOf(ctx.lowerQuery, pos)) !== -1) { + if (pos > lastIndex) { + parts.push(text.slice(lastIndex, pos)); + } + + const isCurrentResult = + ctx.isCurrentItem && ctx.currentMatchIndexInItem === ctx.matchCounter.current; + + parts.push( + React.createElement( + 'mark', + { + key: `s-${pos}-${ctx.matchCounter.current}`, + style: isCurrentResult ? currentHighlightStyles : inactiveHighlightStyles, + 'data-search-result': isCurrentResult ? 'current' : 'match', + 'data-search-item-id': ctx.itemId, + 'data-search-match-index': ctx.matchCounter.current, + }, + text.slice(pos, pos + ctx.query.length) + ) + ); + + lastIndex = pos + ctx.query.length; + pos = lastIndex; + ctx.matchCounter.current++; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + if (parts.length === 0) return text; + if (parts.length === 1) return parts[0]; + return parts; +} + +/** + * Recursively process React children to highlight search terms in text nodes. + * Preserves the React element tree structure (markdown components, etc.) + * while adding tags to text content. + */ +// eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types +export function highlightSearchInChildren( + children: React.ReactNode, + ctx: SearchContext +): React.ReactNode { + // eslint-disable-next-line sonarjs/function-return-type -- React child manipulation inherently returns mixed node types + return React.Children.map(children, (child): React.ReactNode => { + if (typeof child === 'string') { + return highlightSearchText(child, ctx); + } + + if (React.isValidElement<{ children?: React.ReactNode }>(child)) { + // Skip elements already created by search highlighting to prevent + // double-counting when hl() is applied at multiple markdown component levels + // (e.g., both the `strong` and `p` components process the same text) + if (child.type === 'mark' && (child.props as Record)['data-search-result']) { + return child; + } + + if (child.props.children) { + return React.cloneElement( + child, + undefined, + highlightSearchInChildren(child.props.children, ctx) + ); + } + } + + return child; + }); +} diff --git a/src/renderer/components/chat/viewers/CodeBlockViewer.tsx b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx new file mode 100644 index 00000000..d694f3f1 --- /dev/null +++ b/src/renderer/components/chat/viewers/CodeBlockViewer.tsx @@ -0,0 +1,244 @@ +import React, { useMemo, useState } from 'react'; + +import { getBaseName } from '@renderer/utils/pathUtils'; +import { createLogger } from '@shared/utils/logger'; +import { Check, Copy, FileCode } from 'lucide-react'; + +const logger = createLogger('Component:CodeBlockViewer'); + +import { highlightLine } from './syntaxHighlighter'; + +// ============================================================================= +// Types +// ============================================================================= + +interface CodeBlockViewerProps { + fileName: string; // e.g., "src/components/Header.tsx" + content: string; // The actual file content + language?: string; // Inferred from file extension if not provided + startLine?: number; // If partial read, starting line + endLine?: number; // If partial read, ending line + maxHeight?: string; // CSS max-height class (default: "max-h-96") +} + +// ============================================================================= +// Language Detection +// ============================================================================= + +const EXTENSION_LANGUAGE_MAP: Record = { + // JavaScript/TypeScript + '.ts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.jsx': 'jsx', + '.mjs': 'javascript', + '.cjs': 'javascript', + + // Python + '.py': 'python', + '.pyw': 'python', + '.pyx': 'python', + + // Web + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.scss': 'scss', + '.sass': 'sass', + '.less': 'less', + + // Data formats + '.json': 'json', + '.jsonl': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + + // Shell + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'zsh', + '.fish': 'fish', + + // Systems + '.rs': 'rust', + '.go': 'go', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.hpp': 'hpp', + '.java': 'java', + '.kt': 'kotlin', + '.swift': 'swift', + + // Config + '.env': 'env', + '.gitignore': 'gitignore', + '.dockerignore': 'dockerignore', + '.md': 'markdown', + '.mdx': 'mdx', + + // Other + '.sql': 'sql', + '.graphql': 'graphql', + '.gql': 'graphql', + '.vue': 'vue', + '.svelte': 'svelte', + '.rb': 'ruby', + '.php': 'php', + '.lua': 'lua', + '.r': 'r', + '.R': 'r', +}; + +/** + * Infer language from file name/extension. + */ +function inferLanguage(fileName: string): string { + // Check for dotfiles with specific names + const baseName = getBaseName(fileName); + if (baseName === 'Dockerfile') return 'dockerfile'; + if (baseName === 'Makefile') return 'makefile'; + if (baseName.startsWith('.env')) return 'env'; + + // Extract extension + const extMatch = /(\.[^./]+)$/.exec(fileName); + if (extMatch) { + const ext = extMatch[1].toLowerCase(); + return EXTENSION_LANGUAGE_MAP[ext] ?? 'text'; + } + + return 'text'; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const CodeBlockViewer: React.FC = ({ + fileName, + content, + language, + startLine = 1, + endLine, + maxHeight = 'max-h-96', +}): React.JSX.Element => { + const [isCopied, setIsCopied] = useState(false); + + // Infer language from file extension if not provided + const detectedLanguage = language ?? inferLanguage(fileName); + + // Split content into lines + const lines = useMemo(() => content.split('\n'), [content]); + const totalLines = lines.length; + + // Calculate the actual line range for display + const actualEndLine = endLine ?? startLine + totalLines - 1; + + // Handle copy + const handleCopy = async (): Promise => { + try { + await navigator.clipboard.writeText(content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch { + logger.error('Failed to copy to clipboard'); + } + }; + + // Extract just the filename for display + const displayFileName = getBaseName(fileName) || fileName; + + return ( +
    + {/* Header */} +
    +
    + + + {displayFileName} + + {(startLine > 1 || endLine) && ( + + (lines {startLine}-{actualEndLine}) + + )} + + {detectedLanguage} + +
    + + {/* Copy button */} + +
    + + {/* Code content */} +
    +
    +          
    +            {lines.map((line, index) => {
    +              const lineNumber = startLine + index;
    +              return (
    +                
    + {/* Line number */} + + {lineNumber} + + {/* Code line */} + + {highlightLine(line, detectedLanguage)} + +
    + ); + })} +
    +
    +
    +
    + ); +}; diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx new file mode 100644 index 00000000..719e4395 --- /dev/null +++ b/src/renderer/components/chat/viewers/DiffViewer.tsx @@ -0,0 +1,372 @@ +import React from 'react'; + +import { + CODE_BG, + CODE_BORDER, + CODE_FILENAME, + CODE_HEADER_BG, + CODE_LINE_NUMBER, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, + DIFF_ADDED_BG, + DIFF_ADDED_BORDER, + DIFF_ADDED_TEXT, + DIFF_REMOVED_BG, + DIFF_REMOVED_BORDER, + DIFF_REMOVED_TEXT, + TAG_BG, + TAG_BORDER, + TAG_TEXT, +} from '@renderer/constants/cssVariables'; +import { getBaseName } from '@renderer/utils/pathUtils'; +import { formatTokens } from '@shared/utils/tokenFormatting'; +import { Pencil } from 'lucide-react'; + +// ============================================================================= +// Types +// ============================================================================= + +interface DiffViewerProps { + fileName: string; // The file being edited + oldString: string; // The original text being replaced + newString: string; // The new text + maxHeight?: string; // CSS max-height class (default: "max-h-96") + tokenCount?: number; // Optional token count to display in header +} + +interface DiffLine { + type: 'removed' | 'added' | 'context'; + content: string; + lineNumber: number; +} + +// ============================================================================= +// Diff Algorithm (LCS-based) +// ============================================================================= + +/** + * Computes the Longest Common Subsequence matrix for two arrays of strings. + */ +function computeLCSMatrix(oldLines: string[], newLines: string[]): number[][] { + const m = oldLines.length; + const n = newLines.length; + const matrix: number[][] = Array.from({ length: m + 1 }, () => + Array.from({ length: n + 1 }, () => 0) + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + matrix[i][j] = matrix[i - 1][j - 1] + 1; + } else { + matrix[i][j] = Math.max(matrix[i - 1][j], matrix[i][j - 1]); + } + } + } + + return matrix; +} + +/** + * Backtrack through LCS matrix to generate diff lines. + */ +function generateDiff(oldLines: string[], newLines: string[]): DiffLine[] { + const matrix = computeLCSMatrix(oldLines, newLines); + const result: DiffLine[] = []; + + let i = oldLines.length; + let j = newLines.length; + let lineNumber = 1; + + // Temporary storage for backtracking + const temp: DiffLine[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + // Lines are the same - context + temp.push({ type: 'context', content: oldLines[i - 1], lineNumber: 0 }); + i--; + j--; + } else if (j > 0 && (i === 0 || matrix[i][j - 1] >= matrix[i - 1][j])) { + // Line was added + temp.push({ type: 'added', content: newLines[j - 1], lineNumber: 0 }); + j--; + } else if (i > 0) { + // Line was removed + temp.push({ type: 'removed', content: oldLines[i - 1], lineNumber: 0 }); + i--; + } + } + + // Reverse and assign line numbers + temp.reverse(); + for (const line of temp) { + line.lineNumber = lineNumber++; + result.push(line); + } + + return result; +} + +/** + * Computes diff statistics. + */ +function computeStats(diffLines: DiffLine[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + + for (const line of diffLines) { + if (line.type === 'added') added++; + if (line.type === 'removed') removed++; + } + + return { added, removed }; +} + +// ============================================================================= +// Language Detection +// ============================================================================= + +const EXTENSION_LANGUAGE_MAP: Record = { + // JavaScript/TypeScript + '.ts': 'typescript', + '.tsx': 'tsx', + '.js': 'javascript', + '.jsx': 'jsx', + '.mjs': 'javascript', + '.cjs': 'javascript', + + // Python + '.py': 'python', + '.pyw': 'python', + '.pyx': 'python', + + // Web + '.html': 'html', + '.htm': 'html', + '.css': 'css', + '.scss': 'scss', + '.sass': 'sass', + '.less': 'less', + + // Data formats + '.json': 'json', + '.jsonl': 'json', + '.yaml': 'yaml', + '.yml': 'yaml', + '.toml': 'toml', + '.xml': 'xml', + + // Shell + '.sh': 'bash', + '.bash': 'bash', + '.zsh': 'zsh', + '.fish': 'fish', + + // Systems + '.rs': 'rust', + '.go': 'go', + '.c': 'c', + '.h': 'c', + '.cpp': 'cpp', + '.cc': 'cpp', + '.hpp': 'hpp', + '.java': 'java', + '.kt': 'kotlin', + '.swift': 'swift', + + // Config + '.env': 'env', + '.gitignore': 'gitignore', + '.dockerignore': 'dockerignore', + '.md': 'markdown', + '.mdx': 'mdx', + + // Other + '.sql': 'sql', + '.graphql': 'graphql', + '.gql': 'graphql', + '.vue': 'vue', + '.svelte': 'svelte', + '.rb': 'ruby', + '.php': 'php', + '.lua': 'lua', + '.r': 'r', + '.R': 'r', +}; + +/** + * Infer language from file name/extension. + */ +function inferLanguage(fileName: string): string { + // Check for dotfiles with specific names + const baseName = getBaseName(fileName); + if (baseName === 'Dockerfile') return 'dockerfile'; + if (baseName === 'Makefile') return 'makefile'; + if (baseName.startsWith('.env')) return 'env'; + + // Extract extension + const extMatch = /(\.[^./]+)$/.exec(fileName); + if (extMatch) { + const ext = extMatch[1].toLowerCase(); + return EXTENSION_LANGUAGE_MAP[ext] ?? 'text'; + } + + return 'text'; +} + +// ============================================================================= +// Diff Line Component +// ============================================================================= + +interface DiffLineRowProps { + line: DiffLine; +} + +const DiffLineRow: React.FC = ({ line }): React.JSX.Element => { + // Theme-aware styles using CSS variables + const getStyles = ( + type: DiffLine['type'] + ): { bg: string; text: string; border: string; prefix: string } => { + switch (type) { + case 'removed': + return { + bg: DIFF_REMOVED_BG, + text: DIFF_REMOVED_TEXT, + border: DIFF_REMOVED_BORDER, + prefix: '-', + }; + case 'added': + return { + bg: DIFF_ADDED_BG, + text: DIFF_ADDED_TEXT, + border: DIFF_ADDED_BORDER, + prefix: '+', + }; + default: + return { + bg: 'transparent', + text: COLOR_TEXT_SECONDARY, + border: 'transparent', + prefix: ' ', + }; + } + }; + + const style = getStyles(line.type); + + return ( +
    + {/* Line number */} + + {line.lineNumber} + + {/* Prefix */} + + {style.prefix} + + {/* Content */} + + {line.content || ' '} + +
    + ); +}; + +// ============================================================================= +// Main Component +// ============================================================================= + +export const DiffViewer: React.FC = ({ + fileName, + oldString, + newString, + maxHeight = 'max-h-96', + tokenCount, +}): React.JSX.Element => { + // Compute diff + const oldLines = oldString.split('\n'); + const newLines = newString.split('\n'); + const diffLines = generateDiff(oldLines, newLines); + const stats = computeStats(diffLines); + + // Infer language from file extension + const detectedLanguage = inferLanguage(fileName); + + // Format summary + const displayName = getBaseName(fileName); + + return ( +
    + {/* Header - matches CodeBlockViewer style */} +
    + + + {displayName} + + + {detectedLanguage} + + - + + {stats.added > 0 && ( + + +{stats.added} + + )} + {stats.removed > 0 && -{stats.removed}} + {stats.added === 0 && stats.removed === 0 && ( + Changed + )} + + {tokenCount !== undefined && tokenCount > 0 && ( + + ~{formatTokens(tokenCount)} tokens + + )} +
    + + {/* Diff content */} +
    +
    + {diffLines.map((line, index) => ( + + ))} + {diffLines.length === 0 && ( +
    + No changes detected +
    + )} +
    +
    +
    + ); +}; diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx new file mode 100644 index 00000000..80b5e52e --- /dev/null +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -0,0 +1,339 @@ +import React from 'react'; +import ReactMarkdown, { type Components } from 'react-markdown'; + +import { CopyButton } from '@renderer/components/common/CopyButton'; +import { + CODE_BG, + CODE_BORDER, + CODE_HEADER_BG, + COLOR_TEXT, + COLOR_TEXT_MUTED, + COLOR_TEXT_SECONDARY, + PROSE_BLOCKQUOTE_BORDER, + PROSE_BODY, + PROSE_CODE_BG, + PROSE_CODE_TEXT, + PROSE_HEADING, + PROSE_LINK, + PROSE_MUTED, + PROSE_PRE_BG, + PROSE_PRE_BORDER, + PROSE_TABLE_BORDER, + PROSE_TABLE_HEADER_BG, +} from '@renderer/constants/cssVariables'; +import { useStore } from '@renderer/store'; +import { FileText } from 'lucide-react'; +import remarkGfm from 'remark-gfm'; +import { useShallow } from 'zustand/react/shallow'; + +import { + createSearchContext, + highlightSearchInChildren, + type SearchContext, +} from '../searchHighlightUtils'; + +// ============================================================================= +// Types +// ============================================================================= + +interface MarkdownViewerProps { + content: string; + maxHeight?: string; // e.g., "max-h-64" or "max-h-96" + className?: string; + label?: string; // Optional label like "Thinking", "Output", etc. + /** When provided, enables search term highlighting within the markdown */ + itemId?: string; + /** When true, shows a copy button (overlay when no label, inline in header when label exists) */ + copyable?: boolean; +} + +// ============================================================================= +// Component factories +// ============================================================================= + +function createViewerMarkdownComponents(searchCtx: SearchContext | null): Components { + const hl = (children: React.ReactNode): React.ReactNode => + searchCtx ? highlightSearchInChildren(children, searchCtx) : children; + + return { + // Headings + h1: ({ children }) => ( +

    + {hl(children)} +

    + ), + h2: ({ children }) => ( +

    + {hl(children)} +

    + ), + h3: ({ children }) => ( +

    + {hl(children)} +

    + ), + h4: ({ children }) => ( +

    + {hl(children)} +

    + ), + h5: ({ children }) => ( +
    + {hl(children)} +
    + ), + h6: ({ children }) => ( +
    + {hl(children)} +
    + ), + + // Paragraphs + p: ({ children }) => ( +

    + {hl(children)} +

    + ), + + // Links — inline element, no hl(); parent block element's hl() descends here + a: ({ href, children }) => ( + { + e.preventDefault(); + if (href) { + void window.electronAPI.openExternal(href); + } + }} + > + {children} + + ), + + // Strong/Bold — inline element, no hl() + strong: ({ children }) => ( + + {children} + + ), + + // Emphasis/Italic — inline element, no hl() + em: ({ children }) => ( + + {children} + + ), + + // Strikethrough — inline element, no hl() + del: ({ children }) => ( + + {children} + + ), + + // Code: inline vs block detection + code: (props) => { + const { + className: codeClassName, + children, + node, + } = props as { + className?: string; + children?: React.ReactNode; + node?: { position?: { start: { line: number }; end: { line: number } } }; + }; + const hasLanguage = codeClassName?.includes('language-'); + const isMultiLine = + (node?.position && node.position.end.line > node.position.start.line) ?? false; + const isBlock = (hasLanguage ?? false) || isMultiLine; + + if (isBlock) { + return ( + + {hl(children)} + + ); + } + // Inline code — no hl(); parent block element's hl() descends here + return ( + + {children} + + ); + }, + + // Code blocks + pre: ({ children }) => ( +
    +        {children}
    +      
    + ), + + // Blockquotes + blockquote: ({ children }) => ( +
    + {hl(children)} +
    + ), + + // Lists + ul: ({ children }) => ( +
      + {children} +
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + {hl(children)} +
  • + ), + + // Tables + table: ({ children }) => ( +
    + + {children} +
    +
    + ), + thead: ({ children }) => ( + {children} + ), + th: ({ children }) => ( + + {hl(children)} + + ), + td: ({ children }) => ( + + {hl(children)} + + ), + + // Horizontal rule + hr: () =>
    , + }; +} + +/** Default components without search highlighting */ +const defaultComponents = createViewerMarkdownComponents(null); + +// ============================================================================= +// Component +// ============================================================================= + +export const MarkdownViewer: React.FC = ({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + copyable = false, +}) => { + // Only subscribe to search store when itemId is provided + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => ({ + searchQuery: itemId ? s.searchQuery : '', + searchMatches: itemId ? s.searchMatches : [], + currentSearchIndex: itemId ? s.currentSearchIndex : -1, + })) + ); + + // Create search context (fresh each render so counter starts at 0) + const searchCtx = + searchQuery && itemId + ? createSearchContext(searchQuery, itemId, searchMatches, currentSearchIndex) + : null; + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents; + + return ( +
    + {/* Copy button overlay (when no label header) */} + {copyable && !label && } + + {/* Optional header - matches CodeBlockViewer style */} + {label && ( +
    + + + {label} + + {copyable && ( + <> + + + + )} +
    + )} + + {/* Markdown content with scroll */} +
    +
    + + {content} + +
    +
    +
    + ); +}; diff --git a/src/renderer/components/chat/viewers/index.ts b/src/renderer/components/chat/viewers/index.ts new file mode 100644 index 00000000..bfc60a1a --- /dev/null +++ b/src/renderer/components/chat/viewers/index.ts @@ -0,0 +1,3 @@ +export { CodeBlockViewer } from './CodeBlockViewer'; +export { DiffViewer } from './DiffViewer'; +export { MarkdownViewer } from './MarkdownViewer'; diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts new file mode 100644 index 00000000..2271d08b --- /dev/null +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -0,0 +1,373 @@ +import React from 'react'; + +// ============================================================================= +// Syntax Highlighting (Basic Token-based) +// ============================================================================= + +// Basic keyword sets for common languages +const KEYWORDS: Record> = { + typescript: new Set([ + 'import', + 'export', + 'from', + 'const', + 'let', + 'var', + 'function', + 'class', + 'interface', + 'type', + 'enum', + 'return', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'try', + 'catch', + 'finally', + 'throw', + 'new', + 'this', + 'super', + 'extends', + 'implements', + 'async', + 'await', + 'public', + 'private', + 'protected', + 'static', + 'readonly', + 'abstract', + 'as', + 'typeof', + 'instanceof', + 'in', + 'of', + 'keyof', + 'void', + 'never', + 'unknown', + 'any', + 'null', + 'undefined', + 'true', + 'false', + 'default', + ]), + javascript: new Set([ + 'import', + 'export', + 'from', + 'const', + 'let', + 'var', + 'function', + 'class', + 'return', + 'if', + 'else', + 'for', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'try', + 'catch', + 'finally', + 'throw', + 'new', + 'this', + 'super', + 'extends', + 'async', + 'await', + 'typeof', + 'instanceof', + 'in', + 'of', + 'void', + 'null', + 'undefined', + 'true', + 'false', + 'default', + ]), + python: new Set([ + 'import', + 'from', + 'as', + 'def', + 'class', + 'return', + 'if', + 'elif', + 'else', + 'for', + 'while', + 'break', + 'continue', + 'try', + 'except', + 'finally', + 'raise', + 'with', + 'as', + 'pass', + 'lambda', + 'yield', + 'global', + 'nonlocal', + 'assert', + 'and', + 'or', + 'not', + 'in', + 'is', + 'True', + 'False', + 'None', + 'async', + 'await', + 'self', + 'cls', + ]), + rust: new Set([ + 'fn', + 'let', + 'mut', + 'const', + 'static', + 'struct', + 'enum', + 'impl', + 'trait', + 'pub', + 'mod', + 'use', + 'crate', + 'self', + 'super', + 'where', + 'for', + 'loop', + 'while', + 'if', + 'else', + 'match', + 'return', + 'break', + 'continue', + 'move', + 'ref', + 'as', + 'in', + 'unsafe', + 'async', + 'await', + 'dyn', + 'true', + 'false', + 'type', + 'extern', + ]), + go: new Set([ + 'package', + 'import', + 'func', + 'var', + 'const', + 'type', + 'struct', + 'interface', + 'map', + 'chan', + 'go', + 'defer', + 'return', + 'if', + 'else', + 'for', + 'range', + 'switch', + 'case', + 'default', + 'break', + 'continue', + 'fallthrough', + 'select', + 'nil', + 'true', + 'false', + ]), +}; + +// Extend tsx/jsx to use typescript/javascript keywords +KEYWORDS.tsx = KEYWORDS.typescript; +KEYWORDS.jsx = KEYWORDS.javascript; + +/** + * Very basic tokenization for syntax highlighting. + * This is a simple approach without a full parser. + */ +export function highlightLine(line: string, language: string): React.ReactNode[] { + const keywords = KEYWORDS[language] || new Set(); + + // If no highlighting support, return plain text as single-element array + if (keywords.size === 0 && !['json', 'css', 'html', 'bash', 'markdown'].includes(language)) { + return [line]; + } + + const segments: React.ReactNode[] = []; + let currentPos = 0; + const lineLength = line.length; + + while (currentPos < lineLength) { + const remaining = line.slice(currentPos); + + // Check for string (double quote) + if (remaining.startsWith('"')) { + const endQuote = remaining.indexOf('"', 1); + if (endQuote !== -1) { + const str = remaining.slice(0, endQuote + 1); + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-string)' } }, + str + ) + ); + currentPos += str.length; + continue; + } + } + + // Check for string (single quote) + if (remaining.startsWith("'")) { + const endQuote = remaining.indexOf("'", 1); + if (endQuote !== -1) { + const str = remaining.slice(0, endQuote + 1); + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-string)' } }, + str + ) + ); + currentPos += str.length; + continue; + } + } + + // Check for template literal (backtick) + if (remaining.startsWith('`')) { + const endQuote = remaining.indexOf('`', 1); + if (endQuote !== -1) { + const str = remaining.slice(0, endQuote + 1); + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-string)' } }, + str + ) + ); + currentPos += str.length; + continue; + } + } + + // Check for comment (// style) + if (remaining.startsWith('//')) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for comment (# style for Python/Shell) + if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for numbers + const numberMatch = /^(\d+\.?\d*)/.exec(remaining); + if (numberMatch && (currentPos === 0 || /\W/.test(line[currentPos - 1]))) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-number)' } }, + numberMatch[1] + ) + ); + currentPos += numberMatch[1].length; + continue; + } + + // Check for keywords and identifiers + const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining); + if (wordMatch) { + const word = wordMatch[1]; + if (keywords.has(word)) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-keyword)', fontWeight: 500 } }, + word + ) + ); + } else if ((word[0]?.toUpperCase() ?? '') === word[0] && word.length > 1) { + // Likely a type/class name + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-type)' } }, + word + ) + ); + } else { + segments.push(word); + } + currentPos += word.length; + continue; + } + + // Check for operators and punctuation + const opMatch = /^([=<>!+\-*/%&|^~?:;,.{}()[\]])/.exec(remaining); + if (opMatch) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-operator)' } }, + opMatch[1] + ) + ); + currentPos += 1; + continue; + } + + // Default: just add the character + segments.push(remaining[0]); + currentPos += 1; + } + + return segments; +} diff --git a/src/renderer/components/common/CopyButton.tsx b/src/renderer/components/common/CopyButton.tsx new file mode 100644 index 00000000..63b6917d --- /dev/null +++ b/src/renderer/components/common/CopyButton.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +interface CopyButtonProps { + /** Text to copy to clipboard */ + text: string; + /** Background color the gradient fades into (must match parent surface) */ + bgColor?: string; + /** Render as inline element instead of absolute overlay */ + inline?: boolean; +} + +/** + * Copy-to-clipboard button with two modes: + * + * **Overlay** (default): Absolute-positioned in top-right corner, visible on + * group hover. A horizontal gradient fades from transparent to `bgColor` so + * text behind the button isn't abruptly covered. + * Requires an ancestor with `group` and `relative` classes. + * + * **Inline** (`inline`): Normal-flow button for use inside headers/toolbars. + */ +export const CopyButton: React.FC = ({ + text, + bgColor = 'var(--code-bg)', + inline = false, +}) => { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = async (): Promise => { + try { + await navigator.clipboard.writeText(text); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch { + // Silently fail — clipboard API may be unavailable + } + }; + + const icon = isCopied ? ( + + ) : ( + + ); + + if (inline) { + return ( + + ); + } + + return ( +
    + {/* Gradient fade from transparent to bgColor so text isn't obscured */} +
    + {/* Solid background holding the button */} +
    + +
    +
    + ); +}; diff --git a/src/renderer/components/common/CopyablePath.tsx b/src/renderer/components/common/CopyablePath.tsx new file mode 100644 index 00000000..b6f1d587 --- /dev/null +++ b/src/renderer/components/common/CopyablePath.tsx @@ -0,0 +1,68 @@ +/** + * CopyablePath - Path display with copy-to-clipboard on hover. + * Click anywhere on the path row to copy the full absolute path. + * A small icon appears on hover as visual affordance. + */ + +import React, { useCallback, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +interface CopyablePathProps { + /** Shortened path for display */ + displayText: string; + /** Full absolute path for clipboard */ + copyText: string; + /** CSS classes for the text span */ + className?: string; + /** Inline style for the text span */ + style?: React.CSSProperties; +} + +export const CopyablePath = ({ + displayText, + copyText, + className = '', + style, +}: Readonly): React.ReactElement => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + try { + await navigator.clipboard.writeText(copyText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API may not be available in all contexts + } + }, + [copyText] + ); + + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') void handleCopy(e as unknown as React.MouseEvent); + }} + > + + {displayText} + + +
    + ); +}; diff --git a/src/renderer/components/common/ErrorBoundary.tsx b/src/renderer/components/common/ErrorBoundary.tsx new file mode 100644 index 00000000..bf885222 --- /dev/null +++ b/src/renderer/components/common/ErrorBoundary.tsx @@ -0,0 +1,109 @@ +import React, { Component, type ErrorInfo, type ReactNode } from 'react'; + +import { createLogger } from '@shared/utils/logger'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +const logger = createLogger('Component:ErrorBoundary'); + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + logger.error('ErrorBoundary caught an error:', error, errorInfo); + this.setState({ errorInfo }); + } + + handleReload = (): void => { + window.location.reload(); + }; + + handleReset = (): void => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + // eslint-disable-next-line sonarjs/function-return-type -- Error boundaries inherently return different content based on error state + render(): ReactNode { + const { hasError, error, errorInfo } = this.state; + const { children, fallback } = this.props; + + if (hasError) { + if (fallback) { + return fallback; + } + + return ( +
    +
    + +

    Something went wrong

    +
    + +

    + An unexpected error occurred in the application. You can try reloading the page or + resetting the error state. +

    + + {error && ( +
    +

    {error.message}

    + {errorInfo?.componentStack && ( +
    + + Component Stack + +
    +                    {errorInfo.componentStack}
    +                  
    +
    + )} +
    + )} + +
    + + +
    +
    + ); + } + + return children; + } +} diff --git a/src/renderer/components/common/OngoingIndicator.tsx b/src/renderer/components/common/OngoingIndicator.tsx new file mode 100644 index 00000000..81245738 --- /dev/null +++ b/src/renderer/components/common/OngoingIndicator.tsx @@ -0,0 +1,67 @@ +/** + * OngoingIndicator - Pulsing green dot for sessions/groups in progress. + * Shared across SessionItem (sidebar) and LastOutputDisplay (chat). + */ + +import React from 'react'; + +import { Loader2 } from 'lucide-react'; + +interface OngoingIndicatorProps { + /** Size variant */ + size?: 'sm' | 'md'; + /** Whether to show text label */ + showLabel?: boolean; + /** Custom label text */ + label?: string; +} + +/** + * Pulsing green dot indicator for ongoing sessions. + * Use size="sm" for compact displays (sidebar), size="md" for larger displays (chat). + */ +export const OngoingIndicator = ({ + size = 'sm', + showLabel = false, + label = 'Session in progress...', +}: Readonly): React.JSX.Element => { + const dotSize = size === 'sm' ? 'h-2 w-2' : 'h-2.5 w-2.5'; + + return ( + + + + + + {showLabel && ( + + {label} + + )} + + ); +}; + +/** + * OngoingBanner - Full-width banner variant for the LastOutputDisplay. + * Shows animated spinner and text. + */ +export const OngoingBanner = (): React.JSX.Element => { + return ( +
    + + + Session is in progress... + +
    + ); +}; diff --git a/src/renderer/components/common/RepositoryDropdown.tsx b/src/renderer/components/common/RepositoryDropdown.tsx new file mode 100644 index 00000000..9ac81741 --- /dev/null +++ b/src/renderer/components/common/RepositoryDropdown.tsx @@ -0,0 +1,230 @@ +/** + * RepositoryDropdown - Dropdown for selecting repository groups. + * + * Features: + * - Shows repository groups (not individual worktrees) + * - Displays worktree count and total sessions + * - Click outside to close + * - Keyboard navigation (Escape to close) + * - Filter out already selected items + */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { ChevronDown, FolderOpen, GitBranch } from 'lucide-react'; + +import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; + +interface RepositoryDropdownProps { + /** Callback when a repository is selected */ + onSelect: (item: RepositoryDropdownItem) => void; + /** IDs of items to exclude from the list */ + excludeIds?: string[]; + /** Placeholder text */ + placeholder?: string; + /** Whether the dropdown is disabled */ + disabled?: boolean; + /** Whether to drop up instead of down */ + dropUp?: boolean; + /** Custom class for the container */ + className?: string; +} + +export const RepositoryDropdown = ({ + onSelect, + excludeIds = [], + placeholder = 'Select repository...', + disabled = false, + dropUp = false, + className = '', +}: Readonly): React.JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + // Get repository groups from store + const repositoryGroups = useStore((state) => state.repositoryGroups); + const fetchRepositoryGroups = useStore((state) => state.fetchRepositoryGroups); + + // Fetch data if not loaded + useEffect(() => { + if (repositoryGroups.length === 0) { + void fetchRepositoryGroups(); + } + }, [repositoryGroups.length, fetchRepositoryGroups]); + + // Convert repository groups to dropdown items + const allItems = useMemo((): RepositoryDropdownItem[] => { + return repositoryGroups.map((group) => ({ + id: group.id, + name: group.name, + path: group.worktrees[0]?.path ?? '', + worktreeCount: group.worktrees.length, + totalSessions: group.totalSessions, + })); + }, [repositoryGroups]); + + // Filter out excluded items + const availableItems = useMemo(() => { + return allItems.filter((item) => !excludeIds.includes(item.id)); + }, [allItems, excludeIds]); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + // Close on escape + useEffect(() => { + const handleEscape = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + } + }, [isOpen]); + + const handleSelect = (item: RepositoryDropdownItem): void => { + onSelect(item); + setIsOpen(false); + }; + + const isEmpty = availableItems.length === 0; + + return ( +
    + {/* Trigger Button */} + + + {/* Dropdown Menu */} + {isOpen && !isEmpty && ( +
    + {availableItems.map((item) => ( + handleSelect(item)} + /> + ))} +
    + )} +
    + ); +}; + +/** + * Individual item in the dropdown. + */ +const RepositoryDropdownItemComponentInner = ({ + item, + onSelect, +}: Readonly<{ + item: RepositoryDropdownItem; + onSelect: () => void; +}>): React.JSX.Element => { + return ( + + ); +}; + +const RepositoryDropdownItemComponent = React.memo(RepositoryDropdownItemComponentInner); + +/** + * Selected repository item with remove button. + */ +const SelectedRepositoryItemInner = ({ + item, + onRemove, + disabled = false, +}: Readonly<{ + item: RepositoryDropdownItem; + onRemove: () => void; + disabled?: boolean; +}>): React.JSX.Element => { + return ( +
    + +
    +
    + {item.name} + {item.worktreeCount > 1 && ( + + + {item.worktreeCount} + + )} +
    + + {item.path} + +
    + +
    + ); +}; + +export const SelectedRepositoryItem = React.memo(SelectedRepositoryItemInner); diff --git a/src/renderer/components/common/TokenUsageDisplay.tsx b/src/renderer/components/common/TokenUsageDisplay.tsx new file mode 100644 index 00000000..d38690f7 --- /dev/null +++ b/src/renderer/components/common/TokenUsageDisplay.tsx @@ -0,0 +1,572 @@ +/** + * TokenUsageDisplay - Compact token usage display with detailed breakdown on hover. + * Shows total tokens with an info icon that reveals a popover with: + * - Input tokens breakdown + * - Cache read/write tokens + * - Output tokens + * - Optional model information + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables'; +import { getModelColorClass } from '@shared/utils/modelParser'; +import { + formatTokensCompact as formatTokens, + formatTokensDetailed, +} from '@shared/utils/tokenFormatting'; +import { ChevronRight, Info } from 'lucide-react'; + +import type { ClaudeMdStats } from '@renderer/types/claudeMd'; +import type { ContextStats } from '@renderer/types/contextInjection'; +import type { ModelInfo } from '@shared/utils/modelParser'; + +interface TokenUsageDisplayProps { + /** Input tokens count */ + inputTokens: number; + /** Output tokens count */ + outputTokens: number; + /** Cache read tokens count */ + cacheReadTokens: number; + /** Cache creation/write tokens count */ + cacheCreationTokens: number; + /** Thinking tokens (extended thinking content) - estimated from content */ + thinkingTokens?: number; + /** Text output tokens (Claude's text responses) - estimated from content */ + textOutputTokens?: number; + /** Optional model name for display */ + modelName?: string; + /** Optional model family for color styling */ + modelFamily?: ModelInfo['family']; + /** Size variant - 'sm' for compact, 'md' for slightly larger */ + size?: 'sm' | 'md'; + /** Optional CLAUDE.md injection statistics (deprecated, use contextStats) */ + claudeMdStats?: ClaudeMdStats; + /** Optional unified context statistics */ + contextStats?: ContextStats; + /** Phase number for this AI group */ + phaseNumber?: number; + /** Total number of phases in the session */ + totalPhases?: number; +} + +/** + * Expandable section showing session-wide context breakdown. + * Shows accumulated totals for CLAUDE.md, mentioned files, tool outputs, and thinking+text. + */ +const SessionContextSection = ({ + contextStats, + totalTokens, + thinkingTokens = 0, + textOutputTokens = 0, +}: Readonly<{ + contextStats: ContextStats; + totalTokens: number; + thinkingTokens?: number; + textOutputTokens?: number; +}>): React.JSX.Element => { + const [expanded, setExpanded] = useState(false); + + const { tokensByCategory } = contextStats; + + // Calculate combined thinking+text tokens and include in context total + const thinkingTextTokens = thinkingTokens + textOutputTokens; + const adjustedContextTotal = contextStats.totalEstimatedTokens + thinkingTextTokens; + const contextPercent = + totalTokens > 0 ? Math.min((adjustedContextTotal / totalTokens) * 100, 100).toFixed(1) : '0.0'; + + // Count accumulated injections by category + const claudeMdCount = contextStats.accumulatedInjections.filter( + (inj) => inj.category === 'claude-md' + ).length; + const mentionedFilesCount = contextStats.accumulatedInjections.filter( + (inj) => inj.category === 'mentioned-file' + ).length; + const toolOutputsCount = contextStats.accumulatedInjections.filter( + (inj) => inj.category === 'tool-output' + ).length; + const taskCoordinationCount = contextStats.accumulatedInjections.filter( + (inj) => inj.category === 'task-coordination' + ).length; + const userMessagesCount = contextStats.accumulatedInjections.filter( + (inj) => inj.category === 'user-message' + ).length; + + // Calculate percentages for each category + const claudeMdPercent = + totalTokens > 0 + ? Math.min((tokensByCategory.claudeMd / totalTokens) * 100, 100).toFixed(1) + : '0.0'; + const mentionedFilesPercent = + totalTokens > 0 + ? Math.min((tokensByCategory.mentionedFiles / totalTokens) * 100, 100).toFixed(1) + : '0.0'; + const toolOutputsPercent = + totalTokens > 0 + ? Math.min((tokensByCategory.toolOutputs / totalTokens) * 100, 100).toFixed(1) + : '0.0'; + const thinkingTextPercent = + totalTokens > 0 ? Math.min((thinkingTextTokens / totalTokens) * 100, 100).toFixed(1) : '0.0'; + const taskCoordinationPercent = + totalTokens > 0 + ? Math.min((tokensByCategory.taskCoordination / totalTokens) * 100, 100).toFixed(1) + : '0.0'; + const userMessagesPercent = + totalTokens > 0 + ? Math.min((tokensByCategory.userMessages / totalTokens) * 100, 100).toFixed(1) + : '0.0'; + + return ( +
    + {/* Divider */} +
    + + {/* Header - clickable to expand */} +
    setExpanded(!expanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(!expanded); + } + }} + > +
    + + Visible Context +
    + + {formatTokens(adjustedContextTotal)} ({contextPercent}%) + +
    + + {/* Expanded details */} + {expanded && ( +
    + {/* CLAUDE.md */} + {tokensByCategory.claudeMd > 0 && ( +
    + + CLAUDE.md ×{claudeMdCount} + + + {formatTokens(tokensByCategory.claudeMd)}{' '} + ({claudeMdPercent}%) + +
    + )} + + {/* Mentioned Files */} + {tokensByCategory.mentionedFiles > 0 && ( +
    + + @files ×{mentionedFilesCount} + + + {formatTokens(tokensByCategory.mentionedFiles)}{' '} + ({mentionedFilesPercent}%) + +
    + )} + + {/* Tool Outputs */} + {tokensByCategory.toolOutputs > 0 && ( +
    + + Tool Outputs ×{toolOutputsCount} + + + {formatTokens(tokensByCategory.toolOutputs)}{' '} + ({toolOutputsPercent}%) + +
    + )} + + {/* Task Coordination */} + {tokensByCategory.taskCoordination > 0 && ( +
    + + Task Coordination ×{taskCoordinationCount} + + + {formatTokens(tokensByCategory.taskCoordination)}{' '} + ({taskCoordinationPercent}%) + +
    + )} + + {/* User Messages */} + {tokensByCategory.userMessages > 0 && ( +
    + + User Messages ×{userMessagesCount} + + + {formatTokens(tokensByCategory.userMessages)}{' '} + ({userMessagesPercent}%) + +
    + )} + + {/* Thinking + Text */} + {thinkingTextTokens > 0 && ( +
    + Thinking + Text + + {formatTokens(thinkingTextTokens)}{' '} + ({thinkingTextPercent}%) + +
    + )} + + {/* Hint about session scope */} +
    + Accumulated across entire session without duplication +
    +
    + )} +
    + ); +}; + +export const TokenUsageDisplay = ({ + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreationTokens, + thinkingTokens = 0, + textOutputTokens = 0, + modelName, + modelFamily, + size = 'sm', + claudeMdStats, + contextStats, + phaseNumber, + totalPhases, +}: Readonly): React.JSX.Element => { + const totalTokens = inputTokens + cacheReadTokens + cacheCreationTokens + outputTokens; + const formattedTotal = formatTokens(totalTokens); + + // Size-based classes + const textSize = size === 'sm' ? 'text-xs' : 'text-sm'; + const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-3.5 h-3.5'; + + // Model color based on family + const modelColorClass = modelFamily ? getModelColorClass(modelFamily) : ''; + + // Use React state for hover instead of CSS group-hover to avoid + // interference with parent components that also use the 'group' class + const [showPopover, setShowPopover] = useState(false); + const [popoverStyle, setPopoverStyle] = useState({}); + const [arrowStyle, setArrowStyle] = useState({}); + const containerRef = useRef(null); + const popoverRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + const isDraggingRef = useRef(false); + + // Clear timeout helper + const clearHideTimeout = (): void => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + }; + + // Show popover immediately, clear any pending hide + const handleMouseEnter = (): void => { + clearHideTimeout(); + setShowPopover(true); + }; + + // Hide popover with delay (allows mouse to move to popover) + const handleMouseLeave = (): void => { + // Don't hide while dragging inside the popover + if (isDraggingRef.current) return; + clearHideTimeout(); + hideTimeoutRef.current = setTimeout(() => { + setShowPopover(false); + }, 150); + }; + + // Cleanup timeout on unmount and close on scroll + useEffect(() => { + return () => clearHideTimeout(); + }, []); + + // Close popover on scroll + useEffect(() => { + if (!showPopover) return; + + const handleScroll = (e: Event): void => { + // Don't close if scrolling inside the popover + if (popoverRef.current && e.target instanceof Node && popoverRef.current.contains(e.target)) { + return; + } + setShowPopover(false); + }; + + window.addEventListener('scroll', handleScroll, true); + return () => window.removeEventListener('scroll', handleScroll, true); + }, [showPopover]); + + // Calculate popover position based on trigger element + useEffect(() => { + if (showPopover && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const popoverWidth = 220; + const margin = 12; + + // Determine if popover should open left or right + const openLeft = rect.left + popoverWidth > viewportWidth - 20; + + // Determine if popover should open above or below + const spaceBelow = viewportHeight - rect.bottom - margin; + const spaceAbove = rect.top - margin; + const openAbove = spaceBelow < 200 && spaceAbove > spaceBelow; + + const maxHeight = Math.max(openAbove ? spaceAbove : spaceBelow, 120) - 8; + + queueMicrotask(() => { + setPopoverStyle({ + position: 'fixed', + ...(openAbove ? { bottom: viewportHeight - rect.top + 4 } : { top: rect.bottom + 4 }), + left: openLeft ? rect.right - popoverWidth : rect.left, + minWidth: 200, + maxWidth: 280, + maxHeight, + overflowY: 'auto', + zIndex: 99999, + }); + + setArrowStyle({ + position: 'absolute', + ...(openAbove + ? { + bottom: -4, + borderRight: '1px solid var(--color-border)', + borderBottom: '1px solid var(--color-border)', + borderLeft: 'none', + borderTop: 'none', + } + : { + top: -4, + borderLeft: '1px solid var(--color-border)', + borderTop: '1px solid var(--color-border)', + borderRight: 'none', + borderBottom: 'none', + }), + [openLeft ? 'right' : 'left']: 8, + width: 8, + height: 8, + transform: 'rotate(45deg)', + backgroundColor: 'var(--color-surface-raised)', + }); + }); + } + }, [showPopover]); + + return ( +
    + {formattedTotal} + {totalPhases && totalPhases > 1 && phaseNumber && ( + + Phase {phaseNumber}/{totalPhases} + + )} +
    { + // Don't close if focus moved into the popover + if (popoverRef.current?.contains(e.relatedTarget as Node)) return; + handleMouseLeave(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setShowPopover(!showPopover); + } + }} + aria-expanded={showPopover} + aria-haspopup="true" + > + + {/* Popover - rendered via Portal to escape stacking context */} + {showPopover && + createPortal( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events -- tooltip uses mouse handlers for hover/drag behavior, not interactive +
    { + e.stopPropagation(); + isDraggingRef.current = true; + const handleMouseUp = (): void => { + isDraggingRef.current = false; + document.removeEventListener('mouseup', handleMouseUp); + }; + document.addEventListener('mouseup', handleMouseUp); + }} + onClick={(e) => e.stopPropagation()} + > + {/* Arrow pointer */} +
    + +
    + {/* Input Tokens */} +
    + Input Tokens + + {formatTokensDetailed(inputTokens)} + +
    + + {/* Cache Read */} +
    + Cache Read + + {formatTokensDetailed(cacheReadTokens)} + +
    + + {/* Cache Write/Creation */} +
    + Cache Write + + {formatTokensDetailed(cacheCreationTokens)} + +
    + + {/* Output Tokens */} +
    + Output Tokens + + {formatTokensDetailed(outputTokens)} + +
    + + {/* Divider before Total */} +
    + + {/* Total */} +
    + + Total + + + {formatTokensDetailed(totalTokens)} + +
    + + {/* Visible Context Breakdown - expandable section */} + {contextStats && + (contextStats.totalEstimatedTokens > 0 || + thinkingTokens > 0 || + textOutputTokens > 0) && ( + + )} + + {/* CLAUDE.md Breakdown - fallback when contextStats not provided (deprecated) */} + {!contextStats && claudeMdStats && ( +
    + + incl. CLAUDE.md ×{claudeMdStats.accumulatedCount} + + + {totalTokens > 0 + ? ((claudeMdStats.totalEstimatedTokens / totalTokens) * 100).toFixed(1) + : '0.0'} + % + +
    + )} + + {/* Model Info (optional) */} + {modelName && ( + <> +
    +
    + Model + + {modelName} + +
    + + )} +
    +
    , + document.body + )} +
    +
    + ); +}; diff --git a/src/renderer/components/common/WorktreeBadge.tsx b/src/renderer/components/common/WorktreeBadge.tsx new file mode 100644 index 00000000..ad6febc0 --- /dev/null +++ b/src/renderer/components/common/WorktreeBadge.tsx @@ -0,0 +1,118 @@ +/** + * WorktreeBadge - Displays a compact badge indicating the worktree source. + * Shows subtle, muted colors for each worktree type. + */ + +import { WORKTREE_BADGE_BG, WORKTREE_BADGE_TEXT } from '@renderer/constants/cssVariables'; + +import type { WorktreeSource } from '@renderer/types/data'; + +interface WorktreeBadgeProps { + source: WorktreeSource; + /** Whether this is the main worktree */ + isMain?: boolean; + /** Additional CSS classes */ + className?: string; +} + +/** + * Configuration for each worktree source type. + * Uses muted, subtle colors to avoid being too flashy. + */ +interface SourceConfig { + label: string; + bgColor: string; + textColor: string; +} + +// Muted color palette - all using zinc/neutral tones with subtle tints +const SOURCE_CONFIG: Record = { + 'vibe-kanban': { + label: 'Vibe', + bgColor: WORKTREE_BADGE_BG, // zinc-400 + textColor: WORKTREE_BADGE_TEXT, // zinc-400 + }, + conductor: { + label: 'Conductor', + bgColor: WORKTREE_BADGE_BG, + textColor: WORKTREE_BADGE_TEXT, + }, + 'auto-claude': { + label: 'Auto', + bgColor: WORKTREE_BADGE_BG, + textColor: WORKTREE_BADGE_TEXT, + }, + '21st': { + label: '21st', + bgColor: WORKTREE_BADGE_BG, + textColor: WORKTREE_BADGE_TEXT, + }, + 'claude-desktop': { + label: 'Desktop', + bgColor: WORKTREE_BADGE_BG, + textColor: WORKTREE_BADGE_TEXT, + }, + ccswitch: { + label: 'ccswitch', + bgColor: WORKTREE_BADGE_BG, + textColor: WORKTREE_BADGE_TEXT, + }, + git: { + label: '', + bgColor: 'transparent', + textColor: 'transparent', + }, + unknown: { + label: '', + bgColor: 'transparent', + textColor: 'transparent', + }, +}; + +// Default worktree badge config (not "Main" to avoid confusion with main branch) +const DEFAULT_CONFIG: SourceConfig = { + label: 'Default', + bgColor: 'rgba(82, 82, 91, 0.3)', // zinc-600 + textColor: '#71717a', // zinc-500 +}; + +export const WorktreeBadge = ({ + source, + isMain = false, + className = '', +}: Readonly): React.ReactElement | null => { + // Show Default badge if isMain is true (the default/primary worktree) + if (isMain) { + return ( + + {DEFAULT_CONFIG.label} + + ); + } + + const config = SOURCE_CONFIG[source]; + + // Don't render badge for standard git or unknown sources + if (source === 'git' || source === 'unknown' || !config.label) { + return null; + } + + return ( + + {config.label} + + ); +}; diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx new file mode 100644 index 00000000..dd5bca27 --- /dev/null +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -0,0 +1,404 @@ +/** + * DashboardView - Main dashboard with "Productivity Luxury" aesthetic. + * Inspired by Linear, Vercel, and Raycast design patterns. + * Features: + * - Subtle spotlight gradient + * - Centralized command search with inline project filtering + * - Border-first project cards with minimal backgrounds + */ + +import React, { useEffect, useMemo, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { createLogger } from '@shared/utils/logger'; +import { useShallow } from 'zustand/react/shallow'; + +const logger = createLogger('Component:DashboardView'); +import { formatDistanceToNow } from 'date-fns'; +import { Command, FolderGit2, FolderOpen, GitBranch, Search } from 'lucide-react'; + +import type { RepositoryGroup } from '@renderer/types/data'; + +// ============================================================================= +// Command Search Input +// ============================================================================= + +interface CommandSearchProps { + value: string; + onChange: (value: string) => void; +} + +const CommandSearch = ({ value, onChange }: Readonly): React.JSX.Element => { + const [isFocused, setIsFocused] = useState(false); + const { openCommandPalette, selectedProjectId } = useStore( + useShallow((s) => ({ + openCommandPalette: s.openCommandPalette, + selectedProjectId: s.selectedProjectId, + })) + ); + + // Handle Cmd+K to open full command palette + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + openCommandPalette(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [openCommandPalette]); + + return ( +
    + {/* Search container with glow effect on focus */} +
    + + onChange(e.target.value)} + placeholder="Search projects..." + className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text-muted" + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + /> + {/* Keyboard shortcut badge - opens full command palette */} + +
    +
    + ); +}; + +// ============================================================================= +// Repository Card +// ============================================================================= + +interface RepositoryCardProps { + repo: RepositoryGroup; + onClick: () => void; + isHighlighted?: boolean; +} + +/** + * Truncate path to show ~/relative/path format + */ +function formatProjectPath(path: string): string { + const p = path.replace(/\\/g, '/'); + + if (p.startsWith('/Users/') || p.startsWith('/home/')) { + const parts = p.split('/').filter(Boolean); + if (parts.length >= 2) { + const rest = parts.slice(2).join('/'); + return rest ? `~/${rest}` : '~'; + } + } + + if (isWindowsUserPath(path)) { + const parts = p.split('/').filter(Boolean); + if (parts.length >= 3) { + const rest = parts.slice(3).join('/'); + return rest ? `~/${rest}` : '~'; + } + } + + return p; +} + +function isWindowsUserPath(input: string): boolean { + if (input.length < 10) { + return false; + } + + const drive = input.charCodeAt(0); + const hasDriveLetter = + ((drive >= 65 && drive <= 90) || (drive >= 97 && drive <= 122)) && input[1] === ':'; + + return hasDriveLetter && input.startsWith('\\Users\\', 2); +} + +const RepositoryCard = ({ + repo, + onClick, + isHighlighted, +}: Readonly): React.JSX.Element => { + const lastActivity = repo.mostRecentSession + ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) + : 'No recent activity'; + + const worktreeCount = repo.worktrees.length; + const hasMultipleWorktrees = worktreeCount > 1; + + // Get the path from the first worktree + const projectPath = repo.worktrees[0]?.path || ''; + const formattedPath = formatProjectPath(projectPath); + + return ( + + ); +}; + +// ============================================================================= +// Ghost Card (New Project) +// ============================================================================= + +const NewProjectCard = (): React.JSX.Element => { + const { repositoryGroups, selectRepository } = useStore( + useShallow((s) => ({ + repositoryGroups: s.repositoryGroups, + selectRepository: s.selectRepository, + })) + ); + + const handleClick = async (): Promise => { + try { + const selectedPaths = await window.electronAPI.config.selectFolders(); + if (!selectedPaths || selectedPaths.length === 0) { + return; // User cancelled + } + + const selectedPath = selectedPaths[0]; + + // Match selected path against known repository worktrees + for (const repo of repositoryGroups) { + for (const worktree of repo.worktrees) { + if (worktree.path === selectedPath) { + selectRepository(repo.id); + return; + } + } + } + + // No match found - open the folder in file manager as fallback + const result = await window.electronAPI.openPath(selectedPath); + if (!result.success) { + logger.error('Failed to open folder:', result.error); + } + } catch (error) { + logger.error('Error selecting folder:', error); + } + }; + + return ( + + ); +}; + +// ============================================================================= +// Projects Grid +// ============================================================================= + +interface ProjectsGridProps { + searchQuery: string; + maxProjects?: number; +} + +const ProjectsGrid = ({ + searchQuery, + maxProjects = 12, +}: Readonly): React.JSX.Element => { + const { repositoryGroups, repositoryGroupsLoading, fetchRepositoryGroups, selectRepository } = + useStore( + useShallow((s) => ({ + repositoryGroups: s.repositoryGroups, + repositoryGroupsLoading: s.repositoryGroupsLoading, + fetchRepositoryGroups: s.fetchRepositoryGroups, + selectRepository: s.selectRepository, + })) + ); + + useEffect(() => { + if (repositoryGroups.length === 0) { + void fetchRepositoryGroups(); + } + }, [repositoryGroups.length, fetchRepositoryGroups]); + + // Filter projects based on search query + const filteredRepos = useMemo(() => { + if (!searchQuery.trim()) { + return repositoryGroups.slice(0, maxProjects); + } + + const query = searchQuery.toLowerCase().trim(); + return repositoryGroups + .filter((repo) => { + // Match by name + if (repo.name.toLowerCase().includes(query)) return true; + // Match by path + const path = repo.worktrees[0]?.path || ''; + if (path.toLowerCase().includes(query)) return true; + return false; + }) + .slice(0, maxProjects); + }, [repositoryGroups, searchQuery, maxProjects]); + + if (repositoryGroupsLoading) { + return ( +
    + {Array.from({ length: 8 }).map((_, i) => ( +
    + {/* Icon placeholder */} +
    + {/* Title placeholder */} +
    + {/* Path placeholder */} +
    + {/* Meta row placeholder */} +
    +
    +
    +
    +
    + ))} +
    + ); + } + + if (filteredRepos.length === 0 && searchQuery.trim()) { + return ( +
    +
    + +
    +

    No projects found

    +

    No matches for "{searchQuery}"

    +
    + ); + } + + if (repositoryGroups.length === 0) { + return ( +
    +
    + +
    +

    No projects found

    +

    ~/.claude/projects/

    +
    + ); + } + + return ( +
    + {filteredRepos.map((repo) => ( + selectRepository(repo.id)} + isHighlighted={!!searchQuery.trim()} + /> + ))} + {!searchQuery.trim() && } +
    + ); +}; + +// ============================================================================= +// Dashboard View +// ============================================================================= + +export const DashboardView = (): React.JSX.Element => { + const [searchQuery, setSearchQuery] = useState(''); + + return ( +
    + {/* Spotlight gradient background */} + + ); +}; diff --git a/src/renderer/components/layout/MiddlePanel.tsx b/src/renderer/components/layout/MiddlePanel.tsx new file mode 100644 index 00000000..8579bed3 --- /dev/null +++ b/src/renderer/components/layout/MiddlePanel.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import { ChatHistory } from '../chat/ChatHistory'; +import { SearchBar } from '../search/SearchBar'; + +interface MiddlePanelProps { + /** Tab ID for per-tab state isolation (scroll position, etc.) */ + tabId?: string; +} + +export const MiddlePanel: React.FC = ({ tabId }) => { + return ( +
    + + +
    + ); +}; diff --git a/src/renderer/components/layout/PaneContainer.tsx b/src/renderer/components/layout/PaneContainer.tsx new file mode 100644 index 00000000..951c4686 --- /dev/null +++ b/src/renderer/components/layout/PaneContainer.tsx @@ -0,0 +1,152 @@ +/** + * PaneContainer - Horizontal flex container that renders panes side by side. + * Wraps children with @dnd-kit DndContext provider for tab drag-and-drop. + * + * DnD interactions: + * - Drag within same TabBar → reorder tabs (reorderTabInPane) + * - Drag to another pane's TabBar → move tab to target pane (moveTabToPane) + * - Drag to pane edge zone → create new split pane (moveTabToNewPane) + * - Drag last tab out of pane → source pane auto-closes + */ + +import { Fragment, useCallback, useState } from 'react'; + +import { + DndContext, + DragOverlay, + PointerSensor, + pointerWithin, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { useStore } from '@renderer/store'; + +import { PaneResizeHandle } from './PaneResizeHandle'; +import { PaneView } from './PaneView'; +import { DragOverlayTab } from './SortableTab'; + +import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'; +import type { Tab } from '@renderer/types/tabs'; + +export const PaneContainer = (): React.JSX.Element => { + const panes = useStore((s) => s.paneLayout.panes); + + // Track the currently dragged tab for DragOverlay + const [activeTab, setActiveTab] = useState(null); + + // Configure pointer sensor with activation distance to avoid conflict with clicks + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px drag distance before activating + }, + }) + ); + + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + const data = active.data.current; + + if (data?.type === 'tab') { + const sourcePaneId = data.paneId as string; + const tabId = data.tabId as string; + + // Find the tab in the source pane + const pane = panes.find((p) => p.id === sourcePaneId); + const tab = pane?.tabs.find((t) => t.id === tabId); + if (tab) { + setActiveTab(tab); + } + } + }, + [panes] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + setActiveTab(null); + + if (!over || !active.data.current) return; + + const activeData = active.data.current; + const overData = over.data.current; + + if (activeData.type !== 'tab') return; + + const draggedTabId = activeData.tabId as string; + const sourcePaneId = activeData.paneId as string; + const state = useStore.getState(); + + // Case 1: Drop on a split-zone (edge of pane) → create new pane + if (overData?.type === 'split-zone') { + const targetPaneId = overData.paneId as string; + const side = overData.side as 'left' | 'right'; + state.moveTabToNewPane(draggedTabId, sourcePaneId, targetPaneId, side); + return; + } + + // Case 2: Drop on a tabbar (different pane) → move tab to that pane + if (overData?.type === 'tabbar') { + const targetPaneId = overData.paneId as string; + if (sourcePaneId !== targetPaneId) { + state.moveTabToPane(draggedTabId, sourcePaneId, targetPaneId); + } + return; + } + + // Case 3: Drop on another sortable tab + // This can mean either reorder within same pane or move to another pane's tab position + if (overData?.type === 'tab') { + const overTabId = overData.tabId as string; + const overPaneId = overData.paneId as string; + + if (sourcePaneId === overPaneId) { + // Reorder within the same pane + const pane = panes.find((p) => p.id === sourcePaneId); + if (!pane) return; + + const fromIndex = pane.tabs.findIndex((t) => t.id === draggedTabId); + const toIndex = pane.tabs.findIndex((t) => t.id === overTabId); + + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + state.reorderTabInPane(sourcePaneId, fromIndex, toIndex); + } + } else { + // Move to another pane, inserting at the over tab's position + const targetPane = panes.find((p) => p.id === overPaneId); + if (!targetPane) return; + + const insertIndex = targetPane.tabs.findIndex((t) => t.id === overTabId); + state.moveTabToPane(draggedTabId, sourcePaneId, overPaneId, insertIndex); + } + } + }, + [panes] + ); + + return ( + +
    + {panes.map((pane, i) => ( + + {i > 0 && } + + + ))} +
    + + {/* Drag overlay - semi-transparent ghost of the dragged tab */} + + {activeTab ? : null} + +
    + ); +}; diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx new file mode 100644 index 00000000..8abf16d5 --- /dev/null +++ b/src/renderer/components/layout/PaneContent.tsx @@ -0,0 +1,55 @@ +/** + * PaneContent - Renders tab content for a single pane. + * Uses CSS display-toggle to keep all tabs mounted (preserving state). + */ + +import { TabUIProvider } from '@renderer/contexts/TabUIContext'; + +import { DashboardView } from '../dashboard/DashboardView'; +import { NotificationsView } from '../notifications/NotificationsView'; +import { SettingsView } from '../settings/SettingsView'; + +import { SessionTabContent } from './SessionTabContent'; + +import type { Pane } from '@renderer/types/panes'; + +interface PaneContentProps { + pane: Pane; +} + +export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { + const activeTabId = pane.activeTabId; + + // Show default dashboard if no tabs are open in this pane + const showDefaultDashboard = !activeTabId && pane.tabs.length === 0; + + return ( +
    + {showDefaultDashboard && ( +
    + +
    + )} + + {pane.tabs.map((tab) => { + const isActive = tab.id === activeTabId; + return ( +
    + {tab.type === 'dashboard' && } + {tab.type === 'notifications' && } + {tab.type === 'settings' && } + {tab.type === 'session' && ( + + + + )} +
    + ); + })} +
    + ); +}; diff --git a/src/renderer/components/layout/PaneResizeHandle.tsx b/src/renderer/components/layout/PaneResizeHandle.tsx new file mode 100644 index 00000000..848f3e43 --- /dev/null +++ b/src/renderer/components/layout/PaneResizeHandle.tsx @@ -0,0 +1,84 @@ +/** + * PaneResizeHandle - Draggable divider between adjacent panes. + * Uses the same mouse-event pattern as Sidebar.tsx for resize. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { useStore } from '@renderer/store'; + +interface PaneResizeHandleProps { + leftPaneId: string; + rightPaneId: string; +} + +export const PaneResizeHandle = ({ leftPaneId }: PaneResizeHandleProps): React.JSX.Element => { + const [isResizing, setIsResizing] = useState(false); + const resizePanes = useStore((s) => s.resizePanes); + const paneLayout = useStore((s) => s.paneLayout); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + // Calculate the new width fraction based on mouse position relative to container + const container = document.getElementById('pane-container'); + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const relativeX = e.clientX - containerRect.left; + const newFraction = relativeX / containerRect.width; + + // Calculate the cumulative width of all panes before the left pane + const leftPaneIndex = paneLayout.panes.findIndex((p) => p.id === leftPaneId); + if (leftPaneIndex === -1) return; + + let cumulativeWidth = 0; + for (let i = 0; i < leftPaneIndex; i++) { + cumulativeWidth += paneLayout.panes[i].widthFraction; + } + + const leftPaneNewWidth = newFraction - cumulativeWidth; + resizePanes(leftPaneId, leftPaneNewWidth); + }, + [isResizing, leftPaneId, paneLayout.panes, resizePanes] + ); + + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + useEffect(() => { + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + const handleMouseDown = (e: React.MouseEvent): void => { + e.preventDefault(); + setIsResizing(true); + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- resize handle requires mouse interaction +
    + ); +}; diff --git a/src/renderer/components/layout/PaneSplitDropZone.tsx b/src/renderer/components/layout/PaneSplitDropZone.tsx new file mode 100644 index 00000000..7b2c06a5 --- /dev/null +++ b/src/renderer/components/layout/PaneSplitDropZone.tsx @@ -0,0 +1,54 @@ +/** + * PaneSplitDropZone - Half-pane drop zones for creating new panes via tab drag. + * Covers the left or right half of the pane. When a tab is dragged over a half, + * a semi-transparent accent overlay highlights the target area. + */ + +import { useDroppable } from '@dnd-kit/core'; + +interface PaneSplitDropZoneProps { + paneId: string; + side: 'left' | 'right'; + isActive: boolean; +} + +export const PaneSplitDropZone = ({ + paneId, + side, + isActive, +}: PaneSplitDropZoneProps): React.JSX.Element => { + const { setNodeRef, isOver } = useDroppable({ + id: `split-${side}-${paneId}`, + data: { + type: 'split-zone', + paneId, + side, + }, + }); + + return ( +
    + {/* Semi-transparent overlay highlight when hovering */} + {isOver && ( +
    + )} +
    + ); +}; diff --git a/src/renderer/components/layout/PaneView.tsx b/src/renderer/components/layout/PaneView.tsx new file mode 100644 index 00000000..e06bc3cb --- /dev/null +++ b/src/renderer/components/layout/PaneView.tsx @@ -0,0 +1,88 @@ +/** + * PaneView - Single pane wrapper with focus management. + * Handles click-to-focus, visual focus indicator, width, + * and edge split drop zones for DnD. + */ + +import { useDndContext } from '@dnd-kit/core'; +import { useStore } from '@renderer/store'; +import { MAX_PANES } from '@renderer/types/panes'; +import { useShallow } from 'zustand/react/shallow'; + +import { PaneContent } from './PaneContent'; +import { PaneSplitDropZone } from './PaneSplitDropZone'; +import { TabBar } from './TabBar'; + +interface PaneViewProps { + paneId: string; +} + +export const PaneView = ({ paneId }: PaneViewProps): React.JSX.Element => { + const { pane, isFocused, paneCount, focusPane } = useStore( + useShallow((s) => ({ + pane: s.paneLayout.panes.find((p) => p.id === paneId), + isFocused: s.paneLayout.focusedPaneId === paneId, + paneCount: s.paneLayout.panes.length, + focusPane: s.focusPane, + })) + ); + + // Check if a drag is active to show/hide edge drop zones + const { active } = useDndContext(); + const isDragging = active !== null; + const canSplit = paneCount < MAX_PANES; + const showSplitZones = isDragging && canSplit; + + if (!pane) return
    ; + + const handleMouseDown = (): void => { + if (!isFocused) { + focusPane(paneId); + } + }; + + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- pane focus management requires mousedown +
    + {/* Focus indicator - accent border on top of focused pane's TabBar */} +
    1 + ? '2px solid var(--color-accent, #6366f1)' + : '2px solid transparent', + }} + > + +
    + + + + {/* Edge split drop zones - visible only during active drag when under MAX_PANES */} + + + + {/* Max pane indicator - shown during drag when at limit */} + {isDragging && !canSplit && ( +
    +
    + Maximum {MAX_PANES} panes reached +
    +
    + )} +
    + ); +}; diff --git a/src/renderer/components/layout/SessionTabContent.tsx b/src/renderer/components/layout/SessionTabContent.tsx new file mode 100644 index 00000000..3bf89e98 --- /dev/null +++ b/src/renderer/components/layout/SessionTabContent.tsx @@ -0,0 +1,102 @@ +/** + * SessionTabContent - Renders session content with loading/error states. + * Each session tab has its own instance to preserve state. + */ + +import { useEffect } from 'react'; + +import { useStore } from '@renderer/store'; +import { AlertCircle, RefreshCw } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { MiddlePanel } from './MiddlePanel'; + +import type { Tab } from '@renderer/types/tabs'; + +export const SessionTabContent = ({ + tab, + isActive, +}: Readonly<{ tab: Tab; isActive: boolean }>): React.JSX.Element => { + const { fetchSessionDetail, closeTab, initTabUIState } = useStore( + useShallow((s) => ({ + fetchSessionDetail: s.fetchSessionDetail, + closeTab: s.closeTab, + initTabUIState: s.initTabUIState, + })) + ); + + // Read loading/error from per-tab data, falling back to global state + const { sessionDetailError, sessionDetailLoading } = useStore( + useShallow((s) => { + const td = s.tabSessionData[tab.id]; + return { + sessionDetailError: td?.sessionDetailError ?? s.sessionDetailError, + sessionDetailLoading: td?.sessionDetailLoading ?? s.sessionDetailLoading, + }; + }) + ); + + // Initialize per-tab UI state when this tab is first mounted + useEffect(() => { + initTabUIState(tab.id); + }, [tab.id, initTabUIState]); + + // Only show loading/error states when this tab is active + if (!isActive) { + return ( +
    + +
    + ); + } + + if (sessionDetailError) { + return ( +
    +
    + +

    Failed to load session

    +

    + {sessionDetailError} +

    +
    + + +
    +
    +
    + ); + } + + if (sessionDetailLoading) { + return ( +
    +
    +
    +

    Loading session...

    +
    +
    + ); + } + + return ( +
    + +
    + ); +}; diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx new file mode 100644 index 00000000..6f8f5706 --- /dev/null +++ b/src/renderer/components/layout/Sidebar.tsx @@ -0,0 +1,120 @@ +/** + * Sidebar - Breadcrumb-style navigation with project/worktree hierarchy. + * + * Structure: + * - Fixed Header: Project selector (Row 1) + Worktree selector (Row 2, conditional) + * - Scrollable Body: Date-grouped session list + * - Resizable: Drag right edge to resize + * - Collapsible: Cmd+B to toggle (Notion-style) + * + * Provides clear hierarchy visibility: Project -> Worktree -> Session + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; + +import { SidebarHeader } from './SidebarHeader'; + +const MIN_WIDTH = 200; +const MAX_WIDTH = 500; +const DEFAULT_WIDTH = 280; + +export const Sidebar = (): React.JSX.Element | null => { + const { projects, projectsLoading, fetchProjects, sidebarCollapsed } = useStore( + useShallow((s) => ({ + projects: s.projects, + projectsLoading: s.projectsLoading, + fetchProjects: s.fetchProjects, + sidebarCollapsed: s.sidebarCollapsed, + })) + ); + const [width, setWidth] = useState(DEFAULT_WIDTH); + const [isResizing, setIsResizing] = useState(false); + const sidebarRef = useRef(null); + + // Fetch projects on mount if not loaded + useEffect(() => { + if (projects.length === 0 && !projectsLoading) { + void fetchProjects(); + } + }, [projects.length, projectsLoading, fetchProjects]); + + // Handle mouse move during resize + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!isResizing) return; + + const newWidth = e.clientX; + if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { + setWidth(newWidth); + } + }, + [isResizing] + ); + + // Handle mouse up to stop resizing + const handleMouseUp = useCallback(() => { + setIsResizing(false); + }, []); + + // Add/remove event listeners for resize + useEffect(() => { + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing, handleMouseMove, handleMouseUp]); + + const handleResizeStart = (e: React.MouseEvent): void => { + e.preventDefault(); + setIsResizing(true); + }; + + // Collapsed state - sidebar is completely hidden (expand button is in TabBar) + if (sidebarCollapsed) { + return null; + } + + return ( +
    + {/* Sidebar header with project dropdown */} + + + {/* Date-grouped session list */} +
    + +
    + + {/* Resize handle */} +
    + ); +}; diff --git a/src/renderer/components/layout/SidebarHeader.tsx b/src/renderer/components/layout/SidebarHeader.tsx new file mode 100644 index 00000000..d2c12e48 --- /dev/null +++ b/src/renderer/components/layout/SidebarHeader.tsx @@ -0,0 +1,541 @@ +/** + * SidebarHeader - Linear-style header with project name and worktree selector. + * + * Layout (2 stacked horizontal bars): + * - Row 1: Project name (left-aligned after macOS traffic lights) + * - Row 2: Worktree selector (full-width button) + * + * Visual requirements: + * - Row 1 is the drag region for window movement + * - Row 1 reserves left space for macOS traffic lights via shared layout CSS variable + * - Row 2 is a full-width button with no side margins + */ + +import { useEffect, useRef, useState } from 'react'; + +import { HEADER_ROW1_HEIGHT, HEADER_ROW2_HEIGHT } from '@renderer/constants/layout'; +import { useStore } from '@renderer/store'; +import { truncateMiddle } from '@renderer/utils/stringUtils'; +import { Check, ChevronDown, GitBranch, PanelLeft } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { WorktreeBadge } from '../common/WorktreeBadge'; + +import type { Worktree, WorktreeSource } from '@renderer/types/data'; + +/** + * Group worktrees by source for organized dropdown display. + * Returns: main worktree first, then groups sorted by most recent activity. + */ +interface WorktreeGroup { + source: WorktreeSource; + label: string; + worktrees: Worktree[]; + mostRecent: number; +} + +const SOURCE_LABELS: Record = { + 'vibe-kanban': 'Vibe Kanban', + conductor: 'Conductor', + 'auto-claude': 'Auto Claude', + '21st': '21st', + 'claude-desktop': 'Claude Desktop', + ccswitch: 'ccswitch', + git: 'Git', + unknown: 'Other', +}; + +function groupWorktreesBySource(worktrees: Worktree[]): { + mainWorktree: Worktree | null; + groups: WorktreeGroup[]; +} { + // Find main worktree + const mainWorktree = worktrees.find((w) => w.isMainWorktree) ?? null; + + // Group remaining worktrees by source + const groupMap = new Map(); + + for (const wt of worktrees) { + if (wt.isMainWorktree) continue; // Skip main, handled separately + + const existing = groupMap.get(wt.source) ?? []; + existing.push(wt); + groupMap.set(wt.source, existing); + } + + // Convert to array and sort each group internally by most recent + const groups: WorktreeGroup[] = []; + + for (const [source, wts] of groupMap) { + // Sort worktrees within group by most recent + const sorted = [...wts].sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0)); + + const mostRecent = Math.max(...sorted.map((w) => w.mostRecentSession ?? 0)); + + groups.push({ + source, + label: SOURCE_LABELS[source] ?? source, + worktrees: sorted, + mostRecent, + }); + } + + // Sort groups by most recent activity + groups.sort((a, b) => b.mostRecent - a.mostRecent); + + return { mainWorktree, groups }; +} + +/** + * Individual worktree item in the dropdown. + */ +interface WorktreeItemProps { + worktree: Worktree; + isSelected: boolean; + onSelect: () => void; +} + +const WorktreeItem = ({ + worktree, + isSelected, + onSelect, +}: Readonly): React.JSX.Element => { + const [isHovered, setIsHovered] = useState(false); + + const buttonStyle: React.CSSProperties = isSelected + ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } + : { + backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', + opacity: isHovered ? 0.5 : 1, + }; + + return ( + + ); +}; + +/** + * Individual project/repository item in the dropdown. + */ +interface ProjectDropdownItemProps { + name: string; + path?: string; + sessionCount: number; + isSelected: boolean; + onSelect: () => void; +} + +const ProjectDropdownItem = ({ + name, + path, + sessionCount, + isSelected, + onSelect, +}: Readonly): React.JSX.Element => { + const [isHovered, setIsHovered] = useState(false); + + const buttonStyle: React.CSSProperties = isSelected + ? { backgroundColor: 'var(--color-surface-raised)', color: 'var(--color-text)' } + : { + backgroundColor: isHovered ? 'var(--color-surface-raised)' : 'transparent', + opacity: isHovered ? 0.5 : 1, + }; + + return ( + + ); +}; + +export const SidebarHeader = (): React.JSX.Element => { + const { + repositoryGroups, + selectedRepositoryId, + selectedWorktreeId, + selectWorktree, + selectRepository, + viewMode, + projects, + activeProjectId, + setActiveProject, + fetchRepositoryGroups, + fetchProjects, + toggleSidebar, + } = useStore( + useShallow((s) => ({ + repositoryGroups: s.repositoryGroups, + selectedRepositoryId: s.selectedRepositoryId, + selectedWorktreeId: s.selectedWorktreeId, + selectWorktree: s.selectWorktree, + selectRepository: s.selectRepository, + viewMode: s.viewMode, + projects: s.projects, + activeProjectId: s.activeProjectId, + setActiveProject: s.setActiveProject, + fetchRepositoryGroups: s.fetchRepositoryGroups, + fetchProjects: s.fetchProjects, + toggleSidebar: s.toggleSidebar, + })) + ); + + // Fetch data on mount based on view mode + useEffect(() => { + if (viewMode === 'grouped' && repositoryGroups.length === 0) { + void fetchRepositoryGroups(); + } else if (viewMode === 'flat' && projects.length === 0) { + void fetchProjects(); + } + }, [viewMode, repositoryGroups.length, projects.length, fetchRepositoryGroups, fetchProjects]); + + const [isWorktreeDropdownOpen, setIsWorktreeDropdownOpen] = useState(false); + const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false); + const worktreeDropdownRef = useRef(null); + const projectDropdownRef = useRef(null); + + // Find the active repository and worktree + const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); + const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId); + // Filter worktrees to only show those with sessions + const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0); + const hasMultipleWorktrees = worktrees.length > 1; + + // Group worktrees by source for organized dropdown + const worktreeGroupingResult = groupWorktreesBySource(worktrees); + const mainWorktree = worktreeGroupingResult.mainWorktree; + const worktreeGroups = worktreeGroupingResult.groups; + + // For flat mode + const activeProject = projects.find((p) => p.id === activeProjectId); + + // Get display name + const projectName = + viewMode === 'grouped' + ? (activeRepo?.name ?? 'Select Project') + : (activeProject?.name ?? 'Select Project'); + + const worktreeName = activeWorktree?.name ?? 'main'; + const hasSelection = viewMode === 'grouped' ? !!activeRepo : !!activeProject; + + // Close dropdowns on outside click + useEffect(() => { + function handleClickOutside(event: MouseEvent): void { + if ( + worktreeDropdownRef.current && + !worktreeDropdownRef.current.contains(event.target as Node) + ) { + setIsWorktreeDropdownOpen(false); + } + if ( + projectDropdownRef.current && + !projectDropdownRef.current.contains(event.target as Node) + ) { + setIsProjectDropdownOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Close on escape + useEffect(() => { + function handleEscape(event: KeyboardEvent): void { + if (event.key === 'Escape') { + setIsWorktreeDropdownOpen(false); + setIsProjectDropdownOpen(false); + } + } + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, []); + + const handleSelectWorktree = (worktree: Worktree): void => { + selectWorktree(worktree.id); + setIsWorktreeDropdownOpen(false); + }; + + const handleSelectRepo = (repoId: string): void => { + selectRepository(repoId); + setIsProjectDropdownOpen(false); + }; + + const handleSelectProject = (projectId: string): void => { + setActiveProject(projectId); + setIsProjectDropdownOpen(false); + }; + + // Items for project dropdown - filter out repositories/projects with 0 sessions + const projectItems = + viewMode === 'grouped' + ? repositoryGroups.filter((r) => r.totalSessions > 0) + : projects.filter((p) => p.sessions.length > 0); + + const [isCollapseHovered, setIsCollapseHovered] = useState(false); + + return ( +
    + {/* ROW 1: Project Identity (Title Bar / Drag Region) */} +
    + + + {/* Collapse sidebar button */} + + + {/* Project Dropdown */} + {isProjectDropdownOpen && ( + <> +
    setIsProjectDropdownOpen(false)} + /> +
    +
    + Switch {viewMode === 'grouped' ? 'Repository' : 'Project'} +
    + + {projectItems.length === 0 ? ( +
    + No {viewMode === 'grouped' ? 'repositories' : 'projects'} found +
    + ) : ( + projectItems.map((item) => { + const isSelected = + viewMode === 'grouped' + ? item.id === selectedRepositoryId + : item.id === activeProjectId; + const itemSessions = + viewMode === 'grouped' + ? (item as (typeof repositoryGroups)[0]).totalSessions + : (item as (typeof projects)[0]).sessions.length; + // Get path for display + const itemPath = + viewMode === 'grouped' + ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path + : (item as (typeof projects)[0]).path; + + return ( + + viewMode === 'grouped' + ? handleSelectRepo(item.id) + : handleSelectProject(item.id) + } + /> + ); + }) + )} +
    + + )} +
    + + {/* ROW 2: Worktree Selector (Full Width) */} + {viewMode === 'grouped' && activeRepo && ( +
    + + + {/* Worktree Dropdown */} + {isWorktreeDropdownOpen && hasMultipleWorktrees && ( + <> +
    setIsWorktreeDropdownOpen(false)} + /> +
    +
    + Switch Worktree +
    + + {/* Main worktree first */} + {mainWorktree && ( + handleSelectWorktree(mainWorktree)} + /> + )} + + {/* Grouped worktrees by source */} + {worktreeGroups.map((group) => ( +
    + {/* Group header */} +
    + {group.label} +
    + {/* Worktrees in group */} + {group.worktrees.map((worktree) => ( + handleSelectWorktree(worktree)} + /> + ))} +
    + ))} +
    + + )} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx new file mode 100644 index 00000000..1a9758c4 --- /dev/null +++ b/src/renderer/components/layout/SortableTab.tsx @@ -0,0 +1,160 @@ +/** + * SortableTab - A draggable tab item used within SortableContext. + * Wraps useSortable from @dnd-kit for tab reordering and cross-pane movement. + */ + +import { useCallback, useState } from 'react'; + +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useStore } from '@renderer/store'; +import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import type { Tab } from '@renderer/types/tabs'; + +interface SortableTabProps { + tab: Tab; + paneId: string; + isActive: boolean; + isSelected: boolean; + onTabClick: (tabId: string, e: React.MouseEvent) => void; + onMouseDown: (tabId: string, e: React.MouseEvent) => void; + onContextMenu: (tabId: string, e: React.MouseEvent) => void; + onClose: (tabId: string) => void; + setRef: (tabId: string, el: HTMLDivElement | null) => void; +} + +const TAB_ICONS = { + dashboard: LayoutDashboard, + notifications: Bell, + settings: Settings, + session: FileText, +} as const; + +export const SortableTab = ({ + tab, + paneId, + isActive, + isSelected, + onTabClick, + onMouseDown, + onContextMenu, + onClose, + setRef, +}: SortableTabProps): React.JSX.Element => { + const [isHovered, setIsHovered] = useState(false); + + const isPinned = useStore( + useShallow((s) => + tab.type === 'session' && tab.sessionId ? s.pinnedSessionIds.includes(tab.sessionId) : false + ) + ); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: tab.id, + data: { + type: 'tab', + tabId: tab.id, + paneId, + }, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition: isDragging ? 'none' : transition, + opacity: isDragging ? 0.3 : 1, + backgroundColor: isActive + ? 'var(--color-surface-raised)' + : isHovered + ? 'var(--color-surface-overlay)' + : 'transparent', + color: isActive || isHovered ? 'var(--color-text)' : 'var(--color-text-muted)', + outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', + outlineOffset: '-1px', + }; + + const Icon = TAB_ICONS[tab.type]; + + const handleRef = useCallback( + (el: HTMLDivElement | null) => { + setNodeRef(el); + setRef(tab.id, el); + }, + [setNodeRef, setRef, tab.id] + ); + + return ( +
    onTabClick(tab.id, e)} + onMouseDown={(e) => onMouseDown(tab.id, e)} + onContextMenu={(e) => onContextMenu(tab.id, e)} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onTabClick(tab.id, e as unknown as React.MouseEvent); + } + }} + > + + {tab.fromSearch && ( + + + + )} + {isPinned && ( + + + + )} + {tab.label} + +
    + ); +}; + +/** + * DragOverlayTab - Semi-transparent ghost of a tab shown during drag. + */ +export const DragOverlayTab = ({ tab }: { tab: Tab }): React.JSX.Element => { + const Icon = TAB_ICONS[tab.type]; + + return ( +
    + + {tab.label} +
    + ); +}; diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx new file mode 100644 index 00000000..fae7e16c --- /dev/null +++ b/src/renderer/components/layout/TabBar.tsx @@ -0,0 +1,430 @@ +/** + * TabBar - Displays open tabs with close buttons and action buttons. + * Accepts a paneId prop to scope to a specific pane's tabs. + * Supports tab switching, closing, horizontal scrolling on overflow, + * right-click context menu, middle-click to close, Shift/Ctrl+click multi-select, + * and drag-and-drop reordering/cross-pane movement via @dnd-kit. + * When sidebar is collapsed, shows expand button on the left with macOS traffic light spacing. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useDroppable } from '@dnd-kit/core'; +import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable'; +import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; +import { useStore } from '@renderer/store'; +import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { SortableTab } from './SortableTab'; +import { TabContextMenu } from './TabContextMenu'; + +interface TabBarProps { + paneId: string; +} + +export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { + const { + pane, + isFocused, + paneCount, + setActiveTab, + closeTab, + closeOtherTabs, + closeAllTabs, + closeTabs, + setSelectedTabIds, + clearTabSelection, + openDashboard, + fetchSessionDetail, + fetchSessions, + openCommandPalette, + unreadCount, + openNotificationsTab, + openSettingsTab, + sidebarCollapsed, + toggleSidebar, + splitPane, + togglePinSession, + pinnedSessionIds, + } = useStore( + useShallow((s) => ({ + pane: s.paneLayout.panes.find((p) => p.id === paneId), + isFocused: s.paneLayout.focusedPaneId === paneId, + paneCount: s.paneLayout.panes.length, + setActiveTab: s.setActiveTab, + closeTab: s.closeTab, + closeOtherTabs: s.closeOtherTabs, + closeAllTabs: s.closeAllTabs, + closeTabs: s.closeTabs, + setSelectedTabIds: s.setSelectedTabIds, + clearTabSelection: s.clearTabSelection, + openDashboard: s.openDashboard, + fetchSessionDetail: s.fetchSessionDetail, + fetchSessions: s.fetchSessions, + openCommandPalette: s.openCommandPalette, + unreadCount: s.unreadCount, + openNotificationsTab: s.openNotificationsTab, + openSettingsTab: s.openSettingsTab, + sidebarCollapsed: s.sidebarCollapsed, + toggleSidebar: s.toggleSidebar, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + pinnedSessionIds: s.pinnedSessionIds, + })) + ); + + const openTabs = useMemo(() => pane?.tabs ?? [], [pane?.tabs]); + const activeTabId = pane?.activeTabId ?? null; + const selectedTabIds = useMemo(() => pane?.selectedTabIds ?? [], [pane?.selectedTabIds]); + + // Derive Set for O(1) lookups + const selectedSet = useMemo(() => new Set(selectedTabIds), [selectedTabIds]); + + // Derive stable tab IDs array for SortableContext + const tabIds = useMemo(() => openTabs.map((t) => t.id), [openTabs]); + + // Hover states for buttons + const [expandHover, setExpandHover] = useState(false); + const [refreshHover, setRefreshHover] = useState(false); + const [newTabHover, setNewTabHover] = useState(false); + const [searchHover, setSearchHover] = useState(false); + const [notificationsHover, setNotificationsHover] = useState(false); + const [settingsHover, setSettingsHover] = useState(false); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>( + null + ); + + // Track last clicked tab for Shift range selection + const lastClickedTabIdRef = useRef(null); + + // Get the active tab + const activeTab = openTabs.find((tab) => tab.id === activeTabId); + + // Refs for auto-scrolling to active tab + const tabRefsMap = useRef>(new Map()); + const scrollContainerRef = useRef(null); + + // Make the tab bar area droppable for cross-pane drops + const { setNodeRef: setDroppableRef, isOver: isDroppableOver } = useDroppable({ + id: `tabbar-${paneId}`, + data: { + type: 'tabbar', + paneId, + }, + }); + + // Auto-scroll to active tab when it changes + useEffect(() => { + if (!activeTabId) return; + + const tabElement = tabRefsMap.current.get(activeTabId); + if (tabElement && scrollContainerRef.current) { + tabElement.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', + }); + } + }, [activeTabId]); + + // Clear selection on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && selectedTabIds.length > 0) { + clearTabSelection(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectedTabIds.length, clearTabSelection]); + + // Handle tab click with multi-select support + const handleTabClick = useCallback( + (tabId: string, e: React.MouseEvent) => { + const isMeta = e.metaKey || e.ctrlKey; + const isShift = e.shiftKey; + + if (isMeta) { + // Ctrl/Cmd+click: toggle tab in selection + if (selectedSet.has(tabId)) { + setSelectedTabIds(selectedTabIds.filter((id) => id !== tabId)); + } else { + setSelectedTabIds([...selectedTabIds, tabId]); + } + lastClickedTabIdRef.current = tabId; + return; + } + + if (isShift && lastClickedTabIdRef.current) { + // Shift+click: range selection from last clicked to current + const lastIndex = openTabs.findIndex((t) => t.id === lastClickedTabIdRef.current); + const currentIndex = openTabs.findIndex((t) => t.id === tabId); + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeIds = openTabs.slice(start, end + 1).map((t) => t.id); + // Merge with existing selection + const merged = new Set([...selectedTabIds, ...rangeIds]); + setSelectedTabIds([...merged]); + } + return; + } + + // Plain click: clear selection, switch tab + clearTabSelection(); + lastClickedTabIdRef.current = tabId; + setActiveTab(tabId); + }, + [openTabs, selectedTabIds, selectedSet, setActiveTab, setSelectedTabIds, clearTabSelection] + ); + + // Middle-click to close + prevent text selection on Shift/Cmd click + const handleMouseDown = useCallback( + (tabId: string, e: React.MouseEvent) => { + if (e.button === 1) { + e.preventDefault(); + closeTab(tabId); + return; + } + // Prevent native text selection when Shift or Cmd/Ctrl clicking tabs + if (e.button === 0 && (e.shiftKey || e.metaKey || e.ctrlKey)) { + e.preventDefault(); + } + }, + [closeTab] + ); + + // Right-click context menu + const handleContextMenu = useCallback((tabId: string, e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId }); + }, []); + + // Handle refresh for active session tab + const handleRefresh = async (): Promise => { + if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) { + await Promise.all([ + fetchSessionDetail(activeTab.projectId, activeTab.sessionId, activeTabId ?? undefined), + fetchSessions(activeTab.projectId), + ]); + } + }; + + // Ref setter for SortableTab + const setTabRef = useCallback((tabId: string, el: HTMLDivElement | null) => { + if (el) { + tabRefsMap.current.set(tabId, el); + } else { + tabRefsMap.current.delete(tabId); + } + }, []); + + // Context menu helpers + const contextMenuTabId = contextMenu?.tabId ?? null; + const effectiveSelectedCount = + contextMenuTabId && selectedSet.has(contextMenuTabId) ? selectedTabIds.length : 0; + + // Pin state for context menu tab + const contextMenuTab = contextMenuTabId ? openTabs.find((t) => t.id === contextMenuTabId) : null; + const isContextMenuTabSession = contextMenuTab?.type === 'session'; + const isContextMenuTabPinned = + isContextMenuTabSession && contextMenuTab?.sessionId + ? pinnedSessionIds.includes(contextMenuTab.sessionId) + : false; + + // Show sidebar expand button only in the leftmost pane + const isLeftmostPane = useStore( + (s) => s.paneLayout.panes.length === 0 || s.paneLayout.panes[0]?.id === paneId + ); + + return ( +
    + {/* Expand sidebar button - show when collapsed (only in leftmost pane) */} + {sidebarCollapsed && isLeftmostPane && ( + + )} + + {/* Tab list with horizontal scroll, sortable DnD, and droppable area */} +
    { + scrollContainerRef.current = el; + setDroppableRef(el); + }} + className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto" + style={ + { + WebkitAppRegion: 'no-drag', + outline: isDroppableOver ? '1px dashed var(--color-accent, #6366f1)' : 'none', + outlineOffset: '-1px', + } as React.CSSProperties + } + > + + {openTabs.map((tab) => ( + + ))} + + + {/* Refresh button - show only for session tabs */} + {activeTab?.type === 'session' && ( + + )} +
    + + {/* Right side actions */} +
    + {/* New tab button */} + + + {/* Search button (icon only) */} + + + {/* Notifications bell icon */} + + + {/* Settings gear icon */} + +
    + + {/* Context menu */} + {contextMenu && contextMenuTabId && ( + setContextMenu(null)} + onCloseTab={() => closeTab(contextMenuTabId)} + onCloseOtherTabs={() => closeOtherTabs(contextMenuTabId)} + onCloseAllTabs={() => closeAllTabs()} + onCloseSelectedTabs={ + effectiveSelectedCount > 1 ? () => closeTabs([...selectedTabIds]) : undefined + } + onSplitRight={() => splitPane(paneId, contextMenuTabId, 'right')} + onSplitLeft={() => splitPane(paneId, contextMenuTabId, 'left')} + disableSplit={paneCount >= 4} + isSessionTab={isContextMenuTabSession} + isPinned={isContextMenuTabPinned} + onTogglePin={ + isContextMenuTabSession && contextMenuTab?.sessionId + ? () => togglePinSession(contextMenuTab.sessionId!) + : undefined + } + /> + )} +
    + ); +}; diff --git a/src/renderer/components/layout/TabContextMenu.tsx b/src/renderer/components/layout/TabContextMenu.tsx new file mode 100644 index 00000000..dd7165ea --- /dev/null +++ b/src/renderer/components/layout/TabContextMenu.tsx @@ -0,0 +1,149 @@ +/** + * TabContextMenu - Right-click context menu for tab actions. + * Supports close, close others, close all, bulk close for multi-select, + * and split left/right for pane management. + * Shows keyboard shortcut hints for actions that have them. + */ + +import { useEffect, useRef } from 'react'; + +interface TabContextMenuProps { + x: number; + y: number; + tabId: string; + paneId: string; + selectedCount: number; + onClose: () => void; + onCloseTab: () => void; + onCloseOtherTabs: () => void; + onCloseAllTabs: () => void; + onCloseSelectedTabs?: () => void; + onSplitRight: () => void; + onSplitLeft: () => void; + disableSplit: boolean; + /** Whether this tab is a session tab (pin only applies to sessions) */ + isSessionTab?: boolean; + /** Whether this session is currently pinned in the sidebar */ + isPinned?: boolean; + /** Callback to toggle pin state */ + onTogglePin?: () => void; +} + +export const TabContextMenu = ({ + x, + y, + selectedCount, + onClose, + onCloseTab, + onCloseOtherTabs, + onCloseAllTabs, + onCloseSelectedTabs, + onSplitRight, + onSplitLeft, + disableSplit, + isSessionTab, + isPinned, + onTogglePin, +}: TabContextMenuProps): React.JSX.Element => { + const menuRef = useRef(null); + + // Close on click-outside and Escape + useEffect(() => { + const handleMouseDown = (e: MouseEvent): void => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + // Viewport clamping + const menuWidth = 240; + const menuHeight = selectedCount > 1 ? 220 : 196; + const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); + const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); + + const handleClick = (action: () => void) => () => { + action(); + onClose(); + }; + + return ( +
    + {selectedCount > 1 && onCloseSelectedTabs ? ( + + ) : ( + + )} + +
    + + + {isSessionTab && onTogglePin && ( + <> +
    + + + )} +
    + +
    + ); +}; + +const MenuItem = ({ + label, + shortcut, + onClick, + disabled, +}: { + label: string; + shortcut?: string; + onClick: () => void; + disabled?: boolean; +}): React.JSX.Element => { + return ( + + ); +}; diff --git a/src/renderer/components/layout/TabbedLayout.tsx b/src/renderer/components/layout/TabbedLayout.tsx new file mode 100644 index 00000000..fcde1397 --- /dev/null +++ b/src/renderer/components/layout/TabbedLayout.tsx @@ -0,0 +1,41 @@ +/** + * TabbedLayout - Main layout with project-centric sidebar and multi-pane tabbed content. + * + * Layout structure: + * - Sidebar (280px): Project dropdown + date-grouped sessions + * - Main content: PaneContainer with one or more panes, each with TabBar + content + */ + +import { getTrafficLightPaddingForZoom } from '@renderer/constants/layout'; +import { useKeyboardShortcuts } from '@renderer/hooks/useKeyboardShortcuts'; +import { useZoomFactor } from '@renderer/hooks/useZoomFactor'; + +import { CommandPalette } from '../search/CommandPalette'; + +import { PaneContainer } from './PaneContainer'; +import { Sidebar } from './Sidebar'; + +export const TabbedLayout = (): React.JSX.Element => { + // Enable keyboard shortcuts + useKeyboardShortcuts(); + const zoomFactor = useZoomFactor(); + const trafficLightPadding = getTrafficLightPaddingForZoom(zoomFactor); + + return ( +
    + {/* Command Palette (Cmd+K) */} + + + {/* Sidebar - Project dropdown + Sessions (280px) */} + + + {/* Multi-pane content area */} + +
    + ); +}; diff --git a/src/renderer/components/notifications/NotificationRow.tsx b/src/renderer/components/notifications/NotificationRow.tsx new file mode 100644 index 00000000..69bf8a4e --- /dev/null +++ b/src/renderer/components/notifications/NotificationRow.tsx @@ -0,0 +1,213 @@ +/** + * NotificationRow - Linear Inbox-style notification row. + * Compact, high-density layout with hover actions. + */ + +import { useState } from 'react'; + +import { getTriggerColorDef } from '@shared/constants/triggerColors'; +import { formatDistanceToNow } from 'date-fns'; +import { ArrowRight, Bot, Check, Trash2 } from 'lucide-react'; + +import type { DetectedError } from '@renderer/types/data'; + +interface NotificationRowProps { + error: DetectedError; + onRowClick: () => void; + onArchive: () => void; + onDelete: () => void; +} + +/** + * Truncates a string to a maximum length, adding ellipsis if truncated. + */ +function truncateMessage(message: string, maxLength: number = 100): string { + if (message.length <= maxLength) return message; + return message.slice(0, maxLength).trim() + '...'; +} + +export const NotificationRow = ({ + error, + onRowClick, + onArchive, + onDelete, +}: Readonly): React.JSX.Element => { + const [isHovered, setIsHovered] = useState(false); + const isUnread = !error.isRead; + const projectName = error.context?.projectName || 'Unknown Project'; + const relativeTime = formatDistanceToNow(new Date(error.timestamp), { + addSuffix: true, + }); + const truncatedMessage = truncateMessage(error.message); + const colorDef = getTriggerColorDef(error.triggerColor); + const displayName = error.triggerName ?? error.source; + + const handleArchiveClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + onArchive(); + }; + + const handleDeleteClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + onDelete(); + }; + + const handleNavigateClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + onRowClick(); + }; + + return ( +
    { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRowClick(); + } + }} + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="flex h-full cursor-pointer items-center gap-3 border-b px-4 transition-colors" + style={{ + borderColor: 'var(--color-border)', + backgroundColor: isHovered ? 'var(--color-surface-raised)' : undefined, + }} + > + {/* Color Dot — always visible, opacity indicates read state */} +
    + +
    + + {/* Content */} +
    + {/* Title Row */} +
    + + {displayName} + + · + + {projectName} + + {error.subagentId && ( + + + subagent + + )} +
    + {/* Description */} +

    + {truncatedMessage} +

    +
    + + {/* Right Side: Time or Hover Actions */} +
    + {isHovered ? ( + + ) : ( + + {relativeTime} + + )} +
    +
    + ); +}; + +/** + * HoverActions - Action buttons shown on hover. + */ +interface HoverActionsProps { + isUnread: boolean; + onArchiveClick: (e: React.MouseEvent) => void; + onDeleteClick: (e: React.MouseEvent) => void; + onNavigateClick: (e: React.MouseEvent) => void; +} + +const HoverActions = ({ + isUnread, + onArchiveClick, + onDeleteClick, + onNavigateClick, +}: HoverActionsProps): React.JSX.Element => { + const [hoveredButton, setHoveredButton] = useState(null); + + const getButtonStyle = (buttonId: string, isDelete = false): React.CSSProperties => ({ + color: + hoveredButton === buttonId + ? isDelete + ? 'var(--tool-result-error-text)' + : 'var(--color-text)' + : 'var(--color-text-muted)', + backgroundColor: hoveredButton === buttonId ? 'var(--color-border-emphasis)' : undefined, + }); + + return ( + <> + {/* Archive Button (mark as read) */} + {isUnread && ( + + )} + {/* Delete Button */} + + {/* Navigate Button */} + + + ); +}; diff --git a/src/renderer/components/notifications/NotificationsView.tsx b/src/renderer/components/notifications/NotificationsView.tsx new file mode 100644 index 00000000..e7303e0b --- /dev/null +++ b/src/renderer/components/notifications/NotificationsView.tsx @@ -0,0 +1,351 @@ +/** + * NotificationsView - Linear Inbox-style notifications page. + * Single list showing all notifications with unread indicator. + * Includes a filter chip bar to filter by trigger name. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { getTriggerColorDef } from '@shared/constants/triggerColors'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { CheckCheck, Inbox, Loader2, Trash2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { NotificationRow } from './NotificationRow'; + +import type { DetectedError } from '@renderer/types/data'; + +// Virtual list constants +const ROW_HEIGHT = 56; +const OVERSCAN = 5; + +/** Label used for notifications without a triggerName */ +const OTHER_LABEL = 'Other'; + +interface FilterChip { + label: string; + count: number; + colorHex: string; +} + +export const NotificationsView = (): React.JSX.Element => { + const { + notifications, + unreadCount, + fetchNotifications, + markNotificationRead, + markAllNotificationsRead, + deleteNotification, + clearNotifications, + navigateToError, + } = useStore( + useShallow((s) => ({ + notifications: s.notifications, + unreadCount: s.unreadCount, + fetchNotifications: s.fetchNotifications, + markNotificationRead: s.markNotificationRead, + markAllNotificationsRead: s.markAllNotificationsRead, + deleteNotification: s.deleteNotification, + clearNotifications: s.clearNotifications, + navigateToError: s.navigateToError, + })) + ); + + const parentRef = useRef(null); + const [isLoading, setIsLoading] = useState(true); + const [showClearConfirm, setShowClearConfirm] = useState(false); + const [activeFilter, setActiveFilter] = useState(null); + + // Fetch notifications on mount + useEffect(() => { + const loadNotifications = async (): Promise => { + setIsLoading(true); + try { + await fetchNotifications(); + } finally { + setIsLoading(false); + } + }; + void loadNotifications(); + }, [fetchNotifications]); + + // Sort notifications by timestamp (most recent first) + const sortedNotifications = useMemo(() => { + return [...notifications].sort((a, b) => b.timestamp - a.timestamp); + }, [notifications]); + + // Derive filter chips from notifications + const filterChips = useMemo((): FilterChip[] => { + const counts = new Map(); + for (const n of sortedNotifications) { + const label = n.triggerName ?? OTHER_LABEL; + const existing = counts.get(label); + if (existing) { + existing.count++; + } else { + counts.set(label, { + count: 1, + colorHex: getTriggerColorDef(n.triggerColor).hex, + }); + } + } + // Sort by frequency descending + return Array.from(counts.entries()) + .sort((a, b) => b[1].count - a[1].count) + .map(([label, { count, colorHex }]) => ({ label, count, colorHex })); + }, [sortedNotifications]); + + // Reset filter when all notifications are cleared + useEffect(() => { + if (notifications.length === 0) { + setActiveFilter(null); + } + }, [notifications.length]); + + // Apply filter + const filteredNotifications = useMemo(() => { + if (activeFilter === null) return sortedNotifications; + return sortedNotifications.filter((n) => { + const label = n.triggerName ?? OTHER_LABEL; + return label === activeFilter; + }); + }, [sortedNotifications, activeFilter]); + + // Estimate item size + const estimateSize = useCallback(() => ROW_HEIGHT, []); + + // Set up virtualizer + const rowVirtualizer = useVirtualizer({ + count: filteredNotifications.length, + getScrollElement: () => parentRef.current, + estimateSize, + overscan: OVERSCAN, + }); + + // Scroll to top when filter changes + useEffect(() => { + rowVirtualizer.scrollToIndex(0); + }, [activeFilter, rowVirtualizer]); + + // Handle mark all read + const handleMarkAllRead = async (): Promise => { + await markAllNotificationsRead(); + }; + + // Handle clear all with confirmation + const handleClearAll = async (): Promise => { + if (showClearConfirm) { + await clearNotifications(); + setShowClearConfirm(false); + } else { + setShowClearConfirm(true); + // Auto-hide confirmation after 3 seconds + setTimeout(() => setShowClearConfirm(false), 3000); + } + }; + + // Handle archive (mark as read) + const handleArchive = async (id: string): Promise => { + await markNotificationRead(id); + }; + + // Handle delete + const handleDelete = async (id: string): Promise => { + await deleteNotification(id); + }; + + // Handle row click - navigate to error + const handleRowClick = (error: DetectedError): void => { + // Mark as read when navigating + if (!error.isRead) { + void markNotificationRead(error.id); + } + navigateToError(error); + }; + + // Handle filter chip click + const handleFilterClick = (label: string): void => { + setActiveFilter((prev) => (prev === label ? null : label)); + }; + + // Loading state + if (isLoading) { + return ( +
    +
    + + + Loading notifications... + +
    +
    + ); + } + + return ( +
    + {/* Header */} +
    +
    + {/* Title */} +
    + + + Notifications + + {notifications.length > 0 && ( + + {unreadCount > 0 ? `${unreadCount} unread` : `${notifications.length} total`} + + )} +
    + + {/* Action Buttons */} + {notifications.length > 0 && ( +
    + {/* Mark all read */} + {unreadCount > 0 && ( + + )} + {/* Clear all */} + +
    + )} +
    +
    + + {/* Filter Chip Bar */} + {filterChips.length > 1 && ( +
    +
    + {/* All chip */} + + {/* Trigger chips */} + {filterChips.map((chip) => ( + + ))} +
    +
    + )} + + {/* Notifications List */} +
    + {filteredNotifications.length === 0 ? ( +
    + +

    + {activeFilter !== null ? 'No matching notifications' : 'No notifications'} +

    +

    + {activeFilter !== null ? 'Try a different filter' : "You're all caught up!"} +

    +
    + ) : ( +
    + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const notification = filteredNotifications[virtualRow.index]; + if (!notification) return null; + + return ( +
    + handleRowClick(notification)} + onArchive={() => handleArchive(notification.id)} + onDelete={() => handleDelete(notification.id)} + /> +
    + ); + })} +
    + )} +
    +
    + ); +}; diff --git a/src/renderer/components/search/CommandPalette.tsx b/src/renderer/components/search/CommandPalette.tsx new file mode 100644 index 00000000..056d15c4 --- /dev/null +++ b/src/renderer/components/search/CommandPalette.tsx @@ -0,0 +1,497 @@ +/** + * CommandPalette - Spotlight/Alfred-like search modal. + * Triggered by Cmd+K. + * + * Behavior: + * - When NO project is selected: Searches projects by name/path + * - When a project IS selected: Searches conversations within that project + */ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { createLogger } from '@shared/utils/logger'; +import { useShallow } from 'zustand/react/shallow'; + +const logger = createLogger('Component:CommandPalette'); +import { formatDistanceToNow } from 'date-fns'; +import { Bot, FileText, FolderGit2, Loader2, MessageSquare, Search, User, X } from 'lucide-react'; + +import type { RepositoryGroup, SearchResult } from '@renderer/types/data'; + +// ============================================================================= +// Search Mode Type +// ============================================================================= + +type SearchMode = 'projects' | 'sessions'; + +// ============================================================================= +// Project Search Result Item +// ============================================================================= + +interface ProjectResultItemProps { + repo: RepositoryGroup; + isSelected: boolean; + onClick: () => void; +} + +const ProjectResultItemInner = ({ + repo, + isSelected, + onClick, +}: Readonly): React.JSX.Element => { + const lastActivity = repo.mostRecentSession + ? formatDistanceToNow(new Date(repo.mostRecentSession), { addSuffix: true }) + : 'No recent activity'; + + return ( + + ); +}; + +const ProjectResultItem = React.memo(ProjectResultItemInner); + +// ============================================================================= +// Session Search Result Item +// ============================================================================= + +interface SessionResultItemProps { + result: SearchResult; + isSelected: boolean; + onClick: () => void; + highlightMatch: (context: string, matchedText: string) => React.ReactNode; +} + +const SessionResultItemInner = ({ + result, + isSelected, + onClick, + highlightMatch, +}: Readonly): React.JSX.Element => { + return ( + + ); +}; + +const SessionResultItem = React.memo(SessionResultItemInner); + +// ============================================================================= +// Main Component +// ============================================================================= + +export const CommandPalette = (): React.JSX.Element | null => { + const { + commandPaletteOpen, + closeCommandPalette, + selectedProjectId, + navigateToSession, + repositoryGroups, + fetchRepositoryGroups, + selectRepository, + } = useStore( + useShallow((s) => ({ + commandPaletteOpen: s.commandPaletteOpen, + closeCommandPalette: s.closeCommandPalette, + selectedProjectId: s.selectedProjectId, + navigateToSession: s.navigateToSession, + repositoryGroups: s.repositoryGroups, + fetchRepositoryGroups: s.fetchRepositoryGroups, + selectRepository: s.selectRepository, + })) + ); + + const inputRef = useRef(null); + const [query, setQuery] = useState(''); + const [sessionResults, setSessionResults] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [totalMatches, setTotalMatches] = useState(0); + const latestSearchRequestRef = useRef(0); + + // Determine search mode based on whether a project is selected + const searchMode: SearchMode = selectedProjectId ? 'sessions' : 'projects'; + + // Filter projects for project search mode + const filteredProjects = useMemo(() => { + if (searchMode !== 'projects' || query.trim().length < 1) { + return repositoryGroups.slice(0, 10); + } + + const q = query.toLowerCase().trim(); + return repositoryGroups + .filter((repo) => { + if (repo.name.toLowerCase().includes(q)) return true; + const path = repo.worktrees[0]?.path || ''; + if (path.toLowerCase().includes(q)) return true; + return false; + }) + .slice(0, 10); + }, [repositoryGroups, query, searchMode]); + + // Results count for current mode + const resultsCount = searchMode === 'projects' ? filteredProjects.length : sessionResults.length; + + // Fetch repository groups if needed + useEffect(() => { + if (commandPaletteOpen && searchMode === 'projects' && repositoryGroups.length === 0) { + void fetchRepositoryGroups(); + } + }, [commandPaletteOpen, searchMode, repositoryGroups.length, fetchRepositoryGroups]); + + // Focus input when palette opens + useEffect(() => { + if (commandPaletteOpen && inputRef.current) { + inputRef.current.focus(); + setQuery(''); + setSessionResults([]); + setSelectedIndex(0); + setTotalMatches(0); + } + }, [commandPaletteOpen]); + + // Search sessions with debounce (only in session mode) + useEffect(() => { + if ( + !commandPaletteOpen || + searchMode !== 'sessions' || + !selectedProjectId || + query.trim().length < 2 + ) { + setSessionResults([]); + setTotalMatches(0); + return; + } + + const timeoutId = setTimeout(async () => { + const requestId = latestSearchRequestRef.current + 1; + latestSearchRequestRef.current = requestId; + setLoading(true); + try { + const searchResult = await window.electronAPI.searchSessions( + selectedProjectId, + query.trim(), + 50 + ); + if (latestSearchRequestRef.current !== requestId) { + return; + } + setSessionResults(searchResult.results); + setTotalMatches(searchResult.totalMatches); + setSelectedIndex(0); + } catch (error) { + if (latestSearchRequestRef.current !== requestId) { + return; + } + logger.error('Search error:', error); + setSessionResults([]); + setTotalMatches(0); + } finally { + if (latestSearchRequestRef.current === requestId) { + setLoading(false); + } + } + }, 200); + + return () => clearTimeout(timeoutId); + }, [query, selectedProjectId, commandPaletteOpen, searchMode]); + + // Reset selected index when results change + useEffect(() => { + setSelectedIndex(0); + }, [filteredProjects, sessionResults]); + + // Handle project click + const handleProjectClick = useCallback( + (repo: RepositoryGroup) => { + closeCommandPalette(); + selectRepository(repo.id); + }, + [closeCommandPalette, selectRepository] + ); + + // Handle session result click + const handleSessionResultClick = useCallback( + (result: SearchResult) => { + closeCommandPalette(); + navigateToSession(result.projectId, result.sessionId, true, { + query: query.trim(), + messageTimestamp: result.timestamp, + matchedText: result.matchedText, + targetGroupId: result.groupId, + targetMatchIndexInItem: result.matchIndexInItem, + targetMatchStartOffset: result.matchStartOffset, + targetMessageUuid: result.messageUuid, + }); + }, + [closeCommandPalette, navigateToSession, query] + ); + + // Handle backdrop click + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeCommandPalette(); + } + }, + [closeCommandPalette] + ); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + closeCommandPalette(); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, resultsCount - 1)); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + return; + } + + if (e.key === 'Enter' && resultsCount > 0) { + e.preventDefault(); + if (searchMode === 'projects') { + const selected = filteredProjects[selectedIndex]; + if (selected) { + handleProjectClick(selected); + } + } else { + const selected = sessionResults[selectedIndex]; + if (selected) { + handleSessionResultClick(selected); + } + } + } + }, + [ + resultsCount, + selectedIndex, + closeCommandPalette, + searchMode, + filteredProjects, + sessionResults, + handleProjectClick, + handleSessionResultClick, + ] + ); + + // Highlight matched text in context + const highlightMatch = useCallback((context: string, matchedText: string) => { + const lowerContext = context.toLowerCase(); + const lowerMatch = matchedText.toLowerCase(); + const matchIndex = lowerContext.indexOf(lowerMatch); + + if (matchIndex === -1) { + return {context}; + } + + const before = context.slice(0, matchIndex); + const match = context.slice(matchIndex, matchIndex + matchedText.length); + const after = context.slice(matchIndex + matchedText.length); + + return ( + <> + {before} + + {match} + + {after} + + ); + }, []); + + if (!commandPaletteOpen) { + return null; + } + + return ( +
    +
    + {/* Mode indicator */} +
    +
    + {searchMode === 'projects' ? ( + <> + + Search projects + + ) : ( + <> + + Search in projects + · + + {repositoryGroups.find((r) => r.worktrees.some((w) => w.id === selectedProjectId)) + ?.name ?? 'Current project'} + + + )} +
    +
    + + {/* Search input */} +
    + + setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={ + searchMode === 'projects' ? 'Search projects...' : 'Search conversations...' + } + className="placeholder:text-text-muted/50 flex-1 bg-transparent text-base text-text focus:outline-none" + /> + {loading && } + +
    + + {/* Results */} +
    + {searchMode === 'projects' ? ( + // Project search results + filteredProjects.length === 0 ? ( +
    + {query.trim() ? `No projects found for "${query}"` : 'No projects found'} +
    + ) : ( +
    + {filteredProjects.map((repo, index) => ( + handleProjectClick(repo)} + /> + ))} +
    + ) + ) : // Session search results + query.trim().length < 2 ? ( +
    + Type at least 2 characters to search +
    + ) : sessionResults.length === 0 && !loading ? ( +
    + No results found for "{query}" +
    + ) : ( +
    + {sessionResults.map((result, index) => ( + handleSessionResultClick(result)} + highlightMatch={highlightMatch} + /> + ))} +
    + )} +
    + + {/* Footer */} +
    + + {searchMode === 'projects' + ? `${filteredProjects.length} project${filteredProjects.length !== 1 ? 's' : ''}` + : totalMatches > 0 + ? `${totalMatches} result${totalMatches !== 1 ? 's' : ''}` + : 'Type to search'} + +
    + + ↑↓{' '} + navigate + + + {' '} + {searchMode === 'projects' ? 'select' : 'open'} + + + esc close + +
    +
    +
    +
    + ); +}; diff --git a/src/renderer/components/search/SearchBar.tsx b/src/renderer/components/search/SearchBar.tsx new file mode 100644 index 00000000..d7e6b448 --- /dev/null +++ b/src/renderer/components/search/SearchBar.tsx @@ -0,0 +1,122 @@ +/** + * SearchBar - In-session search interface component. + * Appears at the top of the chat view when Cmd+F is pressed. + */ + +import { useEffect, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { ChevronDown, ChevronUp, X } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +interface SearchBarProps { + tabId?: string; +} + +export const SearchBar = ({ tabId }: SearchBarProps): React.JSX.Element | null => { + const { + searchQuery, + searchVisible, + searchResultCount, + currentSearchIndex, + conversation, + setSearchQuery, + hideSearch, + nextSearchResult, + previousSearchResult, + } = useStore( + useShallow((s) => ({ + searchQuery: s.searchQuery, + searchVisible: s.searchVisible, + searchResultCount: s.searchResultCount, + currentSearchIndex: s.currentSearchIndex, + conversation: tabId + ? (s.tabSessionData[tabId]?.conversation ?? s.conversation) + : s.conversation, + setSearchQuery: s.setSearchQuery, + hideSearch: s.hideSearch, + nextSearchResult: s.nextSearchResult, + previousSearchResult: s.previousSearchResult, + })) + ); + + const inputRef = useRef(null); + + // Auto-focus input when search becomes visible + useEffect(() => { + if (searchVisible && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [searchVisible]); + + // Handle keyboard shortcuts within search bar + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape') { + hideSearch(); + } else if (e.key === 'Enter') { + if (e.shiftKey) { + previousSearchResult(); + } else { + nextSearchResult(); + } + } + }; + + if (!searchVisible) { + return null; + } + + return ( +
    + {/* Search input */} + setSearchQuery(e.target.value, conversation)} + onKeyDown={handleKeyDown} + placeholder="Find in conversation..." + className="w-48 rounded border border-border bg-surface-raised px-3 py-1.5 text-sm text-text focus:border-text-secondary focus:outline-none" + /> + + {/* Result count */} + {searchQuery && ( + + {searchResultCount > 0 + ? `${currentSearchIndex + 1} of ${searchResultCount}` + : 'No results'} + + )} + + {/* Navigation buttons */} +
    + + +
    + + {/* Close button */} + +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx new file mode 100644 index 00000000..bf824f94 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/AddTriggerForm.tsx @@ -0,0 +1,233 @@ +/** + * AddTriggerForm - Form to add a new custom trigger. + */ + +import { useCallback } from 'react'; + +import { ChevronDown, ChevronUp, Loader2, Plus } from 'lucide-react'; + +import { useAddTriggerFormHandlers } from '../hooks/useAddTriggerFormHandlers'; +import { useAddTriggerFormState } from '../hooks/useAddTriggerFormState'; +import { useRepositoryLookup } from '../hooks/useRepositoryLookup'; +import { useTriggerForm } from '../hooks/useTriggerForm'; +import { generateId } from '../utils/trigger'; + +import { ColorPaletteSelector } from './ColorPaletteSelector'; +import { DynamicConfigSection } from './DynamicConfigSection'; +import { GeneralInfoSection } from './GeneralInfoSection'; +import { IgnorePatternsSection } from './IgnorePatternsSection'; +import { ModeSelector } from './ModeSelector'; +import { RepositoryScopeSection } from './RepositoryScopeSection'; +import { SectionHeader } from './SectionHeader'; +import { TriggerPreview } from './TriggerPreview'; + +import type { NotificationTrigger } from '@renderer/types/data'; + +interface AddTriggerFormProps { + saving: boolean; + onAdd: (trigger: Omit) => Promise; +} + +export const AddTriggerForm = ({ + saving, + onAdd, +}: Readonly): React.JSX.Element => { + // Use form state hook + const formState = useAddTriggerFormState(); + const { + name, + toolName, + mode, + contentType, + matchField, + matchPattern, + tokenThreshold, + tokenType, + ignorePatterns, + repositoryIds, + color, + isExpanded, + setName, + setMatchField, + setTokenType, + setColor, + setIsExpanded, + resetForm, + } = formState; + + // Use shared form hook for validation and preview + const { + patternError, + validatePattern, + previewResult, + handleTestTrigger, + handleViewSession, + clearPreview, + buildTriggerForTest, + } = useTriggerForm({}); + + // Use handlers hook + const handlers = useAddTriggerFormHandlers({ + formState, + validatePattern, + clearPreview, + }); + + // Convert repositoryIds to RepositoryDropdownItem[] for display + const selectedRepositoryItems = useRepositoryLookup(repositoryIds); + + // Test trigger using the shared hook + const handleTest = useCallback(async (): Promise => { + if (mode === 'content_match' && !validatePattern(matchPattern)) return; + + const testTrigger = buildTriggerForTest({ + name, + contentType, + mode, + matchField, + matchPattern, + tokenThreshold, + tokenType, + toolName, + ignorePatterns, + repositoryIds, + }); + + await handleTestTrigger(testTrigger); + }, [ + mode, + matchPattern, + validatePattern, + buildTriggerForTest, + name, + contentType, + matchField, + tokenThreshold, + tokenType, + toolName, + ignorePatterns, + repositoryIds, + handleTestTrigger, + ]); + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + if (!name.trim()) return; + if (mode === 'content_match' && !validatePattern(matchPattern)) return; + + const newTrigger = handlers.buildNewTrigger(generateId); + await onAdd(newTrigger); + resetForm(); + clearPreview(); + setIsExpanded(false); + }; + + return ( +
    + {/* Header */} + + + {/* Form */} + {isExpanded && ( +
    + {/* Section 1: General Info */} + + + {/* Dot Color */} +
    + + +
    + + {/* Section 2: Trigger Condition */} +
    + + +
    + + {/* Section 3: Dynamic Configuration */} + + + {/* Section 4: Advanced (Collapsible) */} + + + {/* Section 5: Repository Scope (Collapsible) */} + + + {/* Preview Section */} + + + {/* Submit button */} +
    + + +
    + + )} +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx new file mode 100644 index 00000000..f5d250b3 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ColorPaletteSelector.tsx @@ -0,0 +1,144 @@ +/** + * ColorPaletteSelector - Color picker with preset palette and custom hex input. + * Renders a row of preset colored circles plus a hex input for custom colors. + * + * Hex input commits on blur/Enter only (not on every keystroke) to avoid + * triggering config saves while the user is still typing. + */ + +import { useCallback, useState } from 'react'; + +import { + isPresetColorKey, + resolveColorHex, + TRIGGER_COLORS, + type TriggerColor, +} from '@shared/constants/triggerColors'; + +interface ColorPaletteSelectorProps { + value: TriggerColor | undefined; + onChange: (color: TriggerColor) => void; + disabled?: boolean; +} + +const HEX_RE = /^#[0-9a-fA-F]{3,8}$/; + +export const ColorPaletteSelector = ({ + value, + onChange, + disabled, +}: Readonly): React.JSX.Element => { + const isCustom = !!value && !isPresetColorKey(value); + const [hexInput, setHexInput] = useState(isCustom ? value : ''); + const [showHexInput, setShowHexInput] = useState(isCustom); + + // Only update local state on each keystroke — do NOT call onChange here. + const handleHexInputChange = useCallback((raw: string) => { + const v = raw.startsWith('#') ? raw : raw.length > 0 ? `#${raw}` : ''; + setHexInput(v); + }, []); + + // Commit hex value on blur or Enter + const commitHex = useCallback(() => { + if (hexInput && HEX_RE.test(hexInput)) { + onChange(hexInput as `#${string}`); + } + }, [hexInput, onChange]); + + const handleHexKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitHex(); + } + }, + [commitHex] + ); + + const handlePresetClick = useCallback( + (color: TriggerColor) => { + onChange(color); + setShowHexInput(false); + }, + [onChange] + ); + + const handleCustomClick = useCallback(() => { + setShowHexInput(true); + if (hexInput && HEX_RE.test(hexInput)) { + onChange(hexInput as `#${string}`); + } + }, [hexInput, onChange]); + + // Preview swatch shows live hex input (local state) when typing, otherwise the committed value + const previewHex = + showHexInput && hexInput && HEX_RE.test(hexInput) ? hexInput : resolveColorHex(value); + + return ( +
    + {/* Color preview + presets row */} +
    + {/* Live preview swatch */} + + + {/* Preset palette */} + {TRIGGER_COLORS.map((color) => { + const isSelected = value === color.key || (!value && color.key === 'red'); + return ( + +
    + + {/* Hex input row */} + {showHexInput && ( +
    + handleHexInputChange(e.target.value)} + onBlur={commitHex} + onKeyDown={handleHexKeyDown} + placeholder="#ff6600" + maxLength={9} + disabled={disabled} + className={`w-24 rounded border bg-transparent px-2 py-1 font-mono text-xs text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${ + hexInput && !HEX_RE.test(hexInput) ? 'border-red-500' : 'border-border' + }`} + /> + {hexInput && !HEX_RE.test(hexInput) && ( + Invalid hex + )} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx new file mode 100644 index 00000000..5ca1cc8a --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/DynamicConfigSection.tsx @@ -0,0 +1,191 @@ +/** + * DynamicConfigSection - Mode-specific configuration for AddTriggerForm. + * Renders different UI based on the selected trigger mode. + */ + +import { + getCursorClass, + SELECT_INPUT_BASE, + SELECT_OPTION_BG, +} from '@renderer/constants/cssVariables'; +import { AlertCircle } from 'lucide-react'; + +import { CONTENT_TYPE_OPTIONS } from '../utils/constants'; +import { getAvailableMatchFields } from '../utils/trigger'; + +import { SectionHeader } from './SectionHeader'; + +import type { TriggerContentType, TriggerMode, TriggerTokenType } from '@renderer/types/data'; + +interface DynamicConfigSectionProps { + mode: TriggerMode; + contentType: TriggerContentType; + toolName: string; + matchField: string; + matchPattern: string; + patternError: string | null; + tokenThreshold: number; + tokenType: TriggerTokenType; + saving: boolean; + onContentTypeChange: (contentType: TriggerContentType) => void; + onMatchFieldChange: (matchField: string) => void; + onMatchPatternChange: (value: string) => void; + onTokenThresholdChange: (value: string) => void; + onTokenTypeChange: (tokenType: TriggerTokenType) => void; +} + +export const DynamicConfigSection = ({ + mode, + contentType, + toolName, + matchField, + matchPattern, + patternError, + tokenThreshold, + tokenType, + saving, + onContentTypeChange, + onMatchFieldChange, + onMatchPatternChange, + onTokenThresholdChange, + onTokenTypeChange, +}: Readonly): React.JSX.Element => { + // Get available match fields based on content type and tool name + const availableMatchFields = getAvailableMatchFields(contentType, toolName || undefined); + + return ( +
    + + + {/* Error Status Mode */} + {mode === 'error_status' && ( +
    +

    + Triggers when a tool execution reports an error (is_error: true). +

    +
    + )} + + {/* Content Match Mode */} + {mode === 'content_match' && ( +
    + {/* Content Type */} +
    + + +
    + + {/* Match Field */} + {availableMatchFields.length > 0 && ( +
    + + +
    + )} + + {/* Match Pattern */} +
    +
    + +
    + onMatchPatternChange(e.target.value)} + placeholder="e.g., error|failed|exception" + disabled={saving} + className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `} + /> + {patternError && ( +

    + + {patternError} +

    + )} +

    + Leave empty to match all content. Uses JavaScript regex syntax. +

    +
    +
    + )} + + {/* Token Threshold Mode */} + {mode === 'token_threshold' && ( +
    +
    + + +
    +
    + +
    + Alert if > + onTokenThresholdChange(e.target.value)} + placeholder="0" + disabled={saving} + className={`w-20 rounded border border-border bg-transparent px-2 py-1 text-right text-sm text-text focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${saving ? 'cursor-not-allowed opacity-50' : ''} `} + /> + tokens +
    +
    +
    + )} +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx new file mode 100644 index 00000000..facf11af --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/GeneralInfoSection.tsx @@ -0,0 +1,68 @@ +/** + * GeneralInfoSection - Name input and tool select for AddTriggerForm. + */ + +import { TOOL_NAME_OPTIONS } from '../utils/constants'; + +import { SectionHeader } from './SectionHeader'; + +interface GeneralInfoSectionProps { + name: string; + toolName: string; + saving: boolean; + onNameChange: (name: string) => void; + onToolNameChange: (toolName: string) => void; +} + +export const GeneralInfoSection = ({ + name, + toolName, + saving, + onNameChange, + onToolNameChange, +}: Readonly): React.JSX.Element => { + return ( +
    + + + {/* Trigger Name */} +
    +
    + +
    + onNameChange(e.target.value)} + placeholder="e.g., Build Failure Alert" + disabled={saving} + required + className={`w-full rounded border border-border bg-transparent px-2 py-1.5 text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${saving ? 'cursor-not-allowed opacity-50' : ''} `} + /> +
    + + {/* Scope/Tool Name */} +
    + + +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx new file mode 100644 index 00000000..a15d2a2b --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/IgnorePatternsSection.tsx @@ -0,0 +1,73 @@ +/** + * IgnorePatternsSection - Collapsible section for ignore patterns - Linear style. + */ + +import { X } from 'lucide-react'; + +interface IgnorePatternsSectionProps { + patterns: string[]; + onAdd: (pattern: string) => void; + onRemove: (index: number) => void; + disabled: boolean; +} + +export const IgnorePatternsSection = ({ + patterns, + onAdd, + onRemove, + disabled, +}: Readonly): React.JSX.Element => { + return ( +
    + + Advanced: Exclusion Rules + +
    + + Ignore Patterns (skip if matches) + + {patterns.map((pattern, idx) => ( +
    + + {pattern} + + +
    + ))} +
    + { + if (e.key === 'Enter' && e.currentTarget.value.trim()) { + e.preventDefault(); + try { + const input = e.currentTarget; + const value = input.value.trim(); + new RegExp(value); + onAdd(value); + input.value = ''; + } catch { + // Invalid regex + } + } + }} + /> +
    +

    + Press Enter to add. Notification is skipped if any pattern matches. +

    +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx new file mode 100644 index 00000000..c8e06f2c --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/ModeSelector.tsx @@ -0,0 +1,45 @@ +/** + * ModeSelector - Segmented control for selecting trigger mode - Linear style. + */ + +import { MODE_OPTIONS } from '../utils/constants'; + +import type { TriggerMode } from '@renderer/types/data'; + +interface ModeSelectorProps { + value: TriggerMode; + onChange: (mode: TriggerMode) => void; + disabled?: boolean; +} + +export const ModeSelector = ({ + value, + onChange, + disabled = false, +}: Readonly): React.JSX.Element => { + return ( +
    + {MODE_OPTIONS.map((mode) => { + const Icon = mode.icon; + const isActive = value === mode.value; + + return ( + + ); + })} +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx new file mode 100644 index 00000000..bd4df655 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/RepositoryScopeSection.tsx @@ -0,0 +1,67 @@ +/** + * RepositoryScopeSection - Section for limiting trigger to specific repositories. + * Uses the shared RepositoryDropdown component. + */ + +import { + RepositoryDropdown, + SelectedRepositoryItem, +} from '@renderer/components/common/RepositoryDropdown'; + +import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; + +interface RepositoryScopeSectionProps { + repositoryIds: string[]; + selectedItems: RepositoryDropdownItem[]; + onAdd: (item: RepositoryDropdownItem) => void; + onRemove: (index: number) => void; + disabled: boolean; +} + +export const RepositoryScopeSection = ({ + repositoryIds, + selectedItems, + onAdd, + onRemove, + disabled, +}: Readonly): React.JSX.Element => { + return ( +
    + + Advanced: Repository Scope + +
    + + Limit to Repositories (applies only to selected repositories) + + {selectedItems.length === 0 ? ( +

    + No repositories selected - trigger applies to all repositories +

    + ) : ( + selectedItems.map((item, idx) => ( + onRemove(idx)} + disabled={disabled} + /> + )) + )} + + {/* Repository selector dropdown */} + + +

    + When repositories are selected, this trigger only fires for errors in those repositories. +

    +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/SectionHeader.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/SectionHeader.tsx new file mode 100644 index 00000000..185ca9d7 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/SectionHeader.tsx @@ -0,0 +1,15 @@ +/** + * Section header component - Linear style. + */ + +interface SectionHeaderProps { + title: string; +} + +export const SectionHeader = ({ title }: Readonly): React.JSX.Element => { + return ( +

    + {title} +

    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCard.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCard.tsx new file mode 100644 index 00000000..21acf01d --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCard.tsx @@ -0,0 +1,150 @@ +/** + * TriggerCard - Individual trigger display/edit card component. + * Memoized to prevent unnecessary re-renders when other triggers change. + */ + +import { memo, useCallback } from 'react'; + +import { useRepositoryLookup } from '../hooks/useRepositoryLookup'; +import { useTriggerCardState } from '../hooks/useTriggerCardState'; +import { useTriggerForm } from '../hooks/useTriggerForm'; + +import { IgnorePatternsSection } from './IgnorePatternsSection'; +import { RepositoryScopeSection } from './RepositoryScopeSection'; +import { TriggerCardHeader } from './TriggerCardHeader'; +import { TriggerConfiguration } from './TriggerConfiguration'; +import { TriggerPreview } from './TriggerPreview'; + +import type { NotificationTrigger } from '@renderer/types/data'; + +interface TriggerCardProps { + trigger: NotificationTrigger; + saving: boolean; + onUpdate: (triggerId: string, updates: Partial) => Promise; + onRemove: (triggerId: string) => Promise; +} + +const TriggerCardInner = ({ + trigger, + saving, + onUpdate, + onRemove, +}: Readonly): React.JSX.Element => { + // Wrap callbacks to include trigger.id + const handleUpdate = useCallback( + (updates: Partial) => onUpdate(trigger.id, updates), + [onUpdate, trigger.id] + ); + + const handleRemove = useCallback(() => onRemove(trigger.id), [onRemove, trigger.id]); + + // Use shared form hook for validation and preview + const { patternError, validatePattern, previewResult, handleTestTrigger, handleViewSession } = + useTriggerForm({ trigger, onUpdate: handleUpdate }); + + // Use extracted state and handlers hook + const { + isExpanded, + setIsExpanded, + editingName, + setEditingName, + localName, + setLocalName, + localPattern, + localMode, + localTokenThreshold, + localTokenType, + handleToggleEnabled, + handleNameSave, + handlePatternBlur, + handlePatternChange, + handleContentTypeChange, + handleToolNameChange, + handleMatchFieldChange, + handleModeChange, + handleTokenThresholdChange, + handleTokenThresholdBlur, + handleTokenTypeChange, + handleAddIgnorePattern, + handleRemoveIgnorePattern, + handleAddRepository, + handleRemoveRepository, + handleColorChange, + } = useTriggerCardState({ trigger, onUpdate: handleUpdate, validatePattern }); + + // Convert repositoryIds to RepositoryDropdownItem[] for display + const selectedRepositoryItems = useRepositoryLookup(trigger.repositoryIds ?? []); + + return ( +
    + {/* Header row */} + setIsExpanded(!isExpanded)} + onRemove={handleRemove} + /> + + {/* Expanded details */} + {isExpanded && ( +
    + {/* Configuration sections */} + + + {/* Section 4: Advanced (Collapsible) */} + + + {/* Section 5: Repository Scope (Collapsible) */} + + + {/* Preview Section */} + handleTestTrigger(trigger)} + onViewSession={handleViewSession} + /> +
    + )} +
    + ); +}; + +// Memoize to prevent re-rendering when other triggers change +export const TriggerCard = memo(TriggerCardInner); diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx new file mode 100644 index 00000000..7f122a22 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerCardHeader.tsx @@ -0,0 +1,125 @@ +/** + * TriggerCardHeader - Header row for TriggerCard with name, badges, toggle, and actions. + */ + +import { SettingsToggle } from '@renderer/components/settings/components'; +import { getTriggerColorDef } from '@shared/constants/triggerColors'; +import { ChevronDown, ChevronUp, Pencil, Shield, X } from 'lucide-react'; + +import { CONTENT_TYPE_OPTIONS, MODE_OPTIONS } from '../utils/constants'; + +import type { NotificationTrigger, TriggerMode } from '@renderer/types/data'; + +interface TriggerCardHeaderProps { + trigger: NotificationTrigger; + saving: boolean; + localMode: TriggerMode; + editingName: boolean; + localName: string; + isExpanded: boolean; + onSetEditingName: (value: boolean) => void; + onSetLocalName: (value: string) => void; + onNameSave: () => void; + onToggleEnabled: () => void; + onToggleExpanded: () => void; + onRemove: () => Promise; +} + +export const TriggerCardHeader = ({ + trigger, + saving, + localMode, + editingName, + localName, + isExpanded, + onSetEditingName, + onSetLocalName, + onNameSave, + onToggleEnabled, + onToggleExpanded, + onRemove, +}: Readonly): React.JSX.Element => { + return ( +
    + {/* Left side: Name and badges */} +
    +
    + {editingName && !trigger.isBuiltin ? ( + onSetLocalName(e.target.value)} + onBlur={onNameSave} + onKeyDown={(e) => { + if (e.key === 'Enter') onNameSave(); + if (e.key === 'Escape') { + onSetLocalName(trigger.name); + onSetEditingName(false); + } + }} + autoFocus + className="w-full rounded border border-border bg-transparent px-2 py-1 text-sm text-text focus:outline-none focus:ring-1 focus:ring-indigo-500" + /> + ) : ( +
    + + {trigger.name} + {trigger.isBuiltin && ( + + + Builtin + + )} + {!trigger.isBuiltin && ( + + )} +
    + )} + {/* Description line showing mode and content type */} +
    + {MODE_OPTIONS.find((m) => m.value === localMode)?.label ?? localMode} + - + + {CONTENT_TYPE_OPTIONS.find((o) => o.value === trigger.contentType)?.label ?? + trigger.contentType} + +
    +
    +
    + + {/* Right side: Toggle and actions */} +
    + + + + + {!trigger.isBuiltin && ( + + )} +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx new file mode 100644 index 00000000..a05e5027 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerConfiguration.tsx @@ -0,0 +1,342 @@ +/** + * TriggerConfiguration - Mode-specific configuration sections for TriggerCard. + * Handles error status, content match, and token threshold mode configurations. + */ + +import { + getCursorClass, + SELECT_INPUT_BASE, + SELECT_OPTION_BG, +} from '@renderer/constants/cssVariables'; +import { AlertCircle } from 'lucide-react'; + +import { CONTENT_TYPE_OPTIONS, TOOL_NAME_OPTIONS } from '../utils/constants'; +import { getAvailableMatchFields } from '../utils/trigger'; + +import { ColorPaletteSelector } from './ColorPaletteSelector'; +import { ModeSelector } from './ModeSelector'; +import { SectionHeader } from './SectionHeader'; + +import type { + NotificationTrigger, + TriggerContentType, + TriggerMode, + TriggerTokenType, +} from '@renderer/types/data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface TriggerConfigurationProps { + trigger: NotificationTrigger; + saving: boolean; + localMode: TriggerMode; + localPattern: string; + localTokenThreshold: number; + localTokenType: TriggerTokenType; + patternError: string | null; + onModeChange: (mode: TriggerMode) => void; + onContentTypeChange: (value: TriggerContentType) => void; + onToolNameChange: (value: string) => void; + onMatchFieldChange: (value: string) => void; + onPatternChange: (value: string) => void; + onPatternBlur: () => void; + onTokenThresholdChange: (value: number) => void; + onTokenThresholdBlur?: () => void; + onTokenTypeChange: (value: TriggerTokenType) => void; + onColorChange: (color: TriggerColor) => void; +} + +export const TriggerConfiguration = ({ + trigger, + saving, + localMode, + localPattern, + localTokenThreshold, + localTokenType, + patternError, + onModeChange, + onContentTypeChange, + onToolNameChange, + onMatchFieldChange, + onPatternChange, + onPatternBlur, + onTokenThresholdChange, + onTokenThresholdBlur, + onTokenTypeChange, + onColorChange, +}: Readonly): React.JSX.Element => { + const availableMatchFields = getAvailableMatchFields(trigger.contentType, trigger.toolName); + + return ( + <> + {/* Section 1: General Info */} +
    + + + {/* Scope/Tool Name */} + {(trigger.contentType === 'tool_use' || trigger.contentType === 'tool_result') && ( +
    + + +
    + )} +
    + + {/* Dot Color */} +
    + + +
    + + {/* Section 2: Trigger Condition (Mode Selector) */} +
    + + +
    + + {/* Section 3: Dynamic Configuration */} +
    + + + {/* Error Status Mode */} + {localMode === 'error_status' && ( +
    +

    + Triggers when a tool execution reports an error (is_error: true). +

    +
    + )} + + {/* Content Match Mode */} + {localMode === 'content_match' && ( + <> + {/* Content Type */} +
    + + +
    + + + )} + + {/* Token Threshold Mode */} + {localMode === 'token_threshold' && ( + + )} +
    + + ); +}; + +// ============================================================================= +// Content Match Configuration +// ============================================================================= + +interface ContentMatchConfigProps { + triggerId: string; + matchField?: string; + availableMatchFields: { value: string; label: string }[]; + localPattern: string; + patternError: string | null; + saving: boolean; + onMatchFieldChange: (value: string) => void; + onPatternChange: (value: string) => void; + onPatternBlur: () => void; +} + +const ContentMatchConfig = ({ + triggerId, + matchField, + availableMatchFields, + localPattern, + patternError, + saving, + onMatchFieldChange, + onPatternChange, + onPatternBlur, +}: Readonly): React.JSX.Element => { + return ( +
    + {/* Match Field */} + {availableMatchFields.length > 0 && ( +
    + + +
    + )} + + {/* Match Pattern */} +
    +
    + +
    + onPatternChange(e.target.value)} + onBlur={onPatternBlur} + placeholder="e.g., error|failed|exception" + disabled={saving} + className={`w-full rounded border bg-transparent px-2 py-1.5 font-mono text-sm text-text placeholder:text-text-muted focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${patternError ? 'border-red-500' : 'border-border'} ${saving ? 'cursor-not-allowed opacity-50' : ''} `} + /> + {patternError && ( +

    + + {patternError} +

    + )} +

    + Leave empty to match all content. Uses JavaScript regex syntax. +

    +
    +
    + ); +}; + +// ============================================================================= +// Token Threshold Configuration +// ============================================================================= + +interface TokenThresholdConfigProps { + triggerId: string; + localTokenType: TriggerTokenType; + localTokenThreshold: number; + saving: boolean; + onTokenTypeChange: (value: TriggerTokenType) => void; + onTokenThresholdChange: (value: number) => void; + onTokenThresholdBlur?: () => void; +} + +const TokenThresholdConfig = ({ + triggerId, + localTokenType, + localTokenThreshold, + saving, + onTokenTypeChange, + onTokenThresholdChange, + onTokenThresholdBlur, +}: Readonly): React.JSX.Element => { + return ( +
    +
    + + +
    +
    + +
    + Alert if > + { + const val = e.target.value.replace(/\D/g, ''); + onTokenThresholdChange(parseInt(val) || 0); + }} + onBlur={onTokenThresholdBlur} + placeholder="0" + disabled={saving} + className={`w-20 rounded border border-border bg-transparent px-2 py-1 text-right text-sm text-text focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500 ${saving ? 'cursor-not-allowed opacity-50' : ''} `} + /> + tokens +
    +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx new file mode 100644 index 00000000..8a48ed31 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/components/TriggerPreview.tsx @@ -0,0 +1,103 @@ +/** + * TriggerPreview - Displays test results for a trigger. + * Used by both TriggerCard and AddTriggerForm. + */ + +import { AlertTriangle, Loader2 } from 'lucide-react'; + +import type { PreviewResult } from '../types'; +import type { TriggerTestResult } from '@renderer/types/data'; + +interface TriggerPreviewProps { + previewResult: PreviewResult | null; + loading?: boolean; + onTest: () => void; + onViewSession: (error: TriggerTestResult['errors'][0]) => void; + /** Whether this is inside a form (affects button type) */ + isFormContext?: boolean; +} + +export const TriggerPreview = ({ + previewResult, + loading, + onTest, + onViewSession, + isFormContext = false, +}: Readonly): React.JSX.Element => { + const isLoading = loading ?? previewResult?.loading; + + // Safeguard: ensure count is at least the errors array length (handles edge cases where totalCount is 0 but errors exist) + const effectiveCount = previewResult + ? Math.max(previewResult.totalCount, previewResult.errors.length) + : 0; + + return ( +
    +
    + Preview + +
    + + {previewResult && !previewResult.loading && ( +
    +

    + + {previewResult.truncated && effectiveCount >= 10_000 ? '10,000+' : effectiveCount} + {' '} + errors would have been detected +

    + + {/* Truncation warning - only shown when timeout or count limit hit */} + {previewResult.truncated && ( +
    + + + Search stopped early (timeout or count limit). Actual matches may be higher. + +
    + )} + + {previewResult.errors.slice(0, 10).map((error, idx) => ( +
    +
    + {error.context.projectName} + | + + {error.message.length > 60 ? `${error.message.slice(0, 60)}...` : error.message} + +
    + +
    + ))} + + {effectiveCount > 10 && ( +

    ...and {effectiveCount - 10} more

    + )} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormHandlers.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormHandlers.ts new file mode 100644 index 00000000..72963cd1 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormHandlers.ts @@ -0,0 +1,218 @@ +/** + * Hook for AddTriggerForm event handlers. + * Extracts handler logic from AddTriggerForm for mode changes, content type changes, etc. + */ + +import { useCallback } from 'react'; + +import { getAvailableMatchFields } from '../utils/trigger'; + +import type { AddTriggerFormStateReturn } from './useAddTriggerFormState'; +import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; +import type { + NotificationTrigger, + TriggerContentType, + TriggerMatchField, + TriggerMode, +} from '@renderer/types/data'; + +interface UseAddTriggerFormHandlersOptions { + formState: AddTriggerFormStateReturn; + validatePattern: (pattern: string) => boolean; + clearPreview: () => void; +} + +export interface AddTriggerFormHandlersReturn { + handleModeChange: (newMode: TriggerMode) => void; + handleContentTypeChange: (newContentType: TriggerContentType) => void; + handleToolNameChange: (newToolName: string) => void; + handleAddRepository: (item: RepositoryDropdownItem) => void; + handleRemoveIgnorePattern: (idx: number) => void; + handleAddIgnorePattern: (pattern: string) => void; + handleRemoveRepository: (idx: number) => void; + handleMatchPatternChange: (value: string) => void; + handleTokenThresholdChange: (value: string) => void; + handleCancel: () => void; + buildNewTrigger: (generateId: () => string) => Omit; +} + +/** + * Hook for managing AddTriggerForm event handlers. + */ +export function useAddTriggerFormHandlers({ + formState, + validatePattern, + clearPreview, +}: UseAddTriggerFormHandlersOptions): AddTriggerFormHandlersReturn { + const { + name, + toolName, + mode, + contentType, + matchField, + matchPattern, + tokenThreshold, + tokenType, + ignorePatterns, + repositoryIds, + color, + setMode, + setContentType, + setToolName, + setMatchField, + setMatchPattern, + setTokenThreshold, + setIgnorePatterns, + setRepositoryIds, + setIsExpanded, + resetForm, + } = formState; + + // When mode changes, adjust content type defaults + const handleModeChange = useCallback( + (newMode: TriggerMode) => { + setMode(newMode); + if (newMode === 'error_status') { + setContentType('tool_result'); + } + }, + [setMode, setContentType] + ); + + // When content type changes, reset matchField to first available option + const handleContentTypeChange = useCallback( + (newContentType: TriggerContentType) => { + setContentType(newContentType); + const newMatchFields = getAvailableMatchFields(newContentType, toolName || undefined); + setMatchField(newMatchFields[0]?.value || ''); + // Reset tool name if not applicable + if (newContentType !== 'tool_use' && newContentType !== 'tool_result') { + setToolName(''); + } + }, + [toolName, setContentType, setMatchField, setToolName] + ); + + // When tool name changes, reset matchField to first available option + const handleToolNameChange = useCallback( + (newToolName: string) => { + setToolName(newToolName); + const newMatchFields = getAvailableMatchFields(contentType, newToolName || undefined); + setMatchField(newMatchFields[0]?.value || ''); + }, + [contentType, setToolName, setMatchField] + ); + + // Handler for adding repository + const handleAddRepository = useCallback( + (item: RepositoryDropdownItem) => { + if (!repositoryIds.includes(item.id)) { + setRepositoryIds([...repositoryIds, item.id]); + } + }, + [repositoryIds, setRepositoryIds] + ); + + // Handler for removing ignore pattern + const handleRemoveIgnorePattern = useCallback( + (idx: number) => { + const newPatterns = [...ignorePatterns]; + newPatterns.splice(idx, 1); + setIgnorePatterns(newPatterns); + }, + [ignorePatterns, setIgnorePatterns] + ); + + // Handler for adding ignore pattern + const handleAddIgnorePattern = useCallback( + (pattern: string) => { + setIgnorePatterns([...ignorePatterns, pattern]); + }, + [ignorePatterns, setIgnorePatterns] + ); + + // Handler for removing repository + const handleRemoveRepository = useCallback( + (idx: number) => { + const newIds = [...repositoryIds]; + newIds.splice(idx, 1); + setRepositoryIds(newIds); + }, + [repositoryIds, setRepositoryIds] + ); + + // Handler for match pattern change with validation + const handleMatchPatternChange = useCallback( + (value: string) => { + setMatchPattern(value); + validatePattern(value); + }, + [setMatchPattern, validatePattern] + ); + + // Handler for token threshold change + const handleTokenThresholdChange = useCallback( + (value: string) => { + const val = value.replace(/\D/g, ''); + setTokenThreshold(parseInt(val) || 0); + }, + [setTokenThreshold] + ); + + // Handler for cancel button + const handleCancel = useCallback(() => { + resetForm(); + clearPreview(); + setIsExpanded(false); + }, [resetForm, clearPreview, setIsExpanded]); + + // Build new trigger object from form state + const buildNewTrigger = useCallback( + (generateId: () => string): Omit => { + return { + id: `custom-${generateId()}`, + name: name.trim(), + enabled: true, + contentType, + mode, + ...(mode === 'error_status' && { requireError: true }), + ...(mode === 'content_match' && + matchField && { matchField: matchField as TriggerMatchField }), + ...(mode === 'content_match' && matchPattern && { matchPattern }), + ...(mode === 'token_threshold' && { tokenThreshold, tokenType }), + ...((contentType === 'tool_use' || contentType === 'tool_result') && + toolName && { toolName }), + ...(ignorePatterns.length > 0 && { ignorePatterns }), + ...(repositoryIds.length > 0 && { repositoryIds }), + color, + }; + }, + [ + name, + contentType, + mode, + matchField, + matchPattern, + tokenThreshold, + tokenType, + toolName, + ignorePatterns, + repositoryIds, + color, + ] + ); + + return { + handleModeChange, + handleContentTypeChange, + handleToolNameChange, + handleAddRepository, + handleRemoveIgnorePattern, + handleAddIgnorePattern, + handleRemoveRepository, + handleMatchPatternChange, + handleTokenThresholdChange, + handleCancel, + buildNewTrigger, + }; +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormState.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormState.ts new file mode 100644 index 00000000..a6042107 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useAddTriggerFormState.ts @@ -0,0 +1,135 @@ +/** + * Hook for AddTriggerForm state management. + * Extracts all useState calls and resetForm logic from AddTriggerForm. + */ + +import { useCallback, useState } from 'react'; + +import type { TriggerContentType, TriggerMode, TriggerTokenType } from '@renderer/types/data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface AddTriggerFormState { + // Section 1: General Info + name: string; + toolName: string; + + // Section 2: Trigger Condition + mode: TriggerMode; + + // Section 3: Dynamic Configuration + // Content match settings + contentType: TriggerContentType; + matchField: string; + matchPattern: string; + + // Token threshold settings + tokenThreshold: number; + tokenType: TriggerTokenType; + + // Section 4: Advanced + ignorePatterns: string[]; + + // Section 5: Repository Scope + repositoryIds: string[]; + + // Display + color: TriggerColor; + + // UI state + isExpanded: boolean; +} + +export interface AddTriggerFormStateReturn extends AddTriggerFormState { + setName: (name: string) => void; + setToolName: (toolName: string) => void; + setMode: (mode: TriggerMode) => void; + setContentType: (contentType: TriggerContentType) => void; + setMatchField: (matchField: string) => void; + setMatchPattern: (matchPattern: string) => void; + setTokenThreshold: (threshold: number) => void; + setTokenType: (tokenType: TriggerTokenType) => void; + setIgnorePatterns: (patterns: string[]) => void; + setRepositoryIds: (ids: string[]) => void; + setColor: (color: TriggerColor) => void; + setIsExpanded: (expanded: boolean) => void; + resetForm: () => void; +} + +/** + * Hook for managing AddTriggerForm state. + */ +export function useAddTriggerFormState(): AddTriggerFormStateReturn { + // Section 1: General Info + const [name, setName] = useState(''); + const [toolName, setToolName] = useState(''); + + // Section 2: Trigger Condition + const [mode, setMode] = useState('error_status'); + + // Section 3: Dynamic Configuration + // Content match settings + const [contentType, setContentType] = useState('tool_result'); + const [matchField, setMatchField] = useState('content'); + const [matchPattern, setMatchPattern] = useState(''); + + // Token threshold settings + const [tokenThreshold, setTokenThreshold] = useState(1000); + const [tokenType, setTokenType] = useState('total'); + + // Section 4: Advanced + const [ignorePatterns, setIgnorePatterns] = useState([]); + + // Section 5: Repository Scope + const [repositoryIds, setRepositoryIds] = useState([]); + + // Display + const [color, setColor] = useState('red'); + + // UI state + const [isExpanded, setIsExpanded] = useState(false); + + const resetForm = useCallback(() => { + setName(''); + setToolName(''); + setMode('error_status'); + setContentType('tool_result'); + setMatchField('content'); + setMatchPattern(''); + setTokenThreshold(1000); + setTokenType('total'); + setIgnorePatterns([]); + setRepositoryIds([]); + // Intentionally do NOT reset color — preserve last-used color across triggers + }, []); + + return { + // State values + name, + toolName, + mode, + contentType, + matchField, + matchPattern, + tokenThreshold, + tokenType, + ignorePatterns, + repositoryIds, + color, + isExpanded, + + // Setters + setName, + setToolName, + setMode, + setContentType, + setMatchField, + setMatchPattern, + setTokenThreshold, + setTokenType, + setIgnorePatterns, + setRepositoryIds, + setColor, + setIsExpanded, + resetForm, + }; +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useRepositoryLookup.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useRepositoryLookup.ts new file mode 100644 index 00000000..dbaec754 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useRepositoryLookup.ts @@ -0,0 +1,47 @@ +/** + * Hook to convert repository IDs to RepositoryDropdownItem[] for display. + * Used by TriggerCard and AddTriggerForm to show selected repositories. + */ + +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; + +import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; + +/** + * Converts an array of repository IDs to RepositoryDropdownItem[] for display. + * Searches repository groups to find matching repositories. + */ +export function useRepositoryLookup(repositoryIds: string[]): RepositoryDropdownItem[] { + const repositoryGroups = useStore((state) => state.repositoryGroups); + + return useMemo((): RepositoryDropdownItem[] => { + const items: RepositoryDropdownItem[] = []; + + for (const repositoryId of repositoryIds) { + // Find repository group by ID + const group = repositoryGroups.find((g) => g.id === repositoryId); + if (group) { + items.push({ + id: group.id, + name: group.name, + path: group.worktrees[0]?.path ?? '', + worktreeCount: group.worktrees.length, + totalSessions: group.totalSessions, + }); + } else { + // If not found, create a placeholder item + items.push({ + id: repositoryId, + name: repositoryId, + path: '', + worktreeCount: 0, + totalSessions: 0, + }); + } + } + + return items; + }, [repositoryIds, repositoryGroups]); +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerCardState.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerCardState.ts new file mode 100644 index 00000000..3779ec0f --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerCardState.ts @@ -0,0 +1,281 @@ +/** + * Hook for TriggerCard local state and callback handlers. + * Extracts state management logic from TriggerCard component. + */ + +import { useCallback, useState } from 'react'; + +import { deriveMode, getAvailableMatchFields } from '../utils/trigger'; + +import type { RepositoryDropdownItem } from '@renderer/components/settings/hooks/useSettingsConfig'; +import type { + NotificationTrigger, + TriggerContentType, + TriggerMatchField, + TriggerMode, + TriggerTokenType, +} from '@renderer/types/data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +interface UseTriggerCardStateOptions { + trigger: NotificationTrigger; + onUpdate: (updates: Partial) => Promise; + validatePattern: (pattern: string) => boolean; +} + +interface UseTriggerCardStateReturn { + // UI state + isExpanded: boolean; + setIsExpanded: (value: boolean) => void; + editingName: boolean; + setEditingName: (value: boolean) => void; + + // Local form values + localName: string; + setLocalName: (value: string) => void; + localPattern: string; + localMode: TriggerMode; + localTokenThreshold: number; + localTokenType: TriggerTokenType; + + // Handlers + handleToggleEnabled: () => void; + handleNameSave: () => void; + handlePatternBlur: () => void; + handlePatternChange: (value: string) => void; + handleContentTypeChange: (value: TriggerContentType) => void; + handleToolNameChange: (value: string) => void; + handleMatchFieldChange: (value: string) => void; + handleModeChange: (newMode: TriggerMode) => void; + handleTokenThresholdChange: (value: number) => void; + handleTokenThresholdBlur: () => void; + handleTokenTypeChange: (value: TriggerTokenType) => void; + handleAddIgnorePattern: (pattern: string) => void; + handleRemoveIgnorePattern: (index: number) => void; + handleAddRepository: (item: RepositoryDropdownItem) => void; + handleRemoveRepository: (index: number) => void; + handleColorChange: (color: TriggerColor) => void; +} + +/** + * Manages TriggerCard local state and provides memoized callback handlers. + */ +export function useTriggerCardState({ + trigger, + onUpdate, + validatePattern, +}: UseTriggerCardStateOptions): UseTriggerCardStateReturn { + // UI state + const [isExpanded, setIsExpanded] = useState(false); + const [editingName, setEditingName] = useState(false); + + // Local form values + const [localName, setLocalName] = useState(trigger.name); + const [localPattern, setLocalPattern] = useState(trigger.matchPattern ?? ''); + const [localMode, setLocalMode] = useState(deriveMode(trigger)); + const [localTokenThreshold, setLocalTokenThreshold] = useState( + trigger.tokenThreshold ?? 1000 + ); + const [localTokenType, setLocalTokenType] = useState( + trigger.tokenType ?? 'total' + ); + + // Toggle enabled/disabled + const handleToggleEnabled = useCallback(() => { + void onUpdate({ enabled: !trigger.enabled }); + }, [trigger.enabled, onUpdate]); + + // Save name on blur or Enter + const handleNameSave = useCallback(() => { + if (localName.trim() && localName !== trigger.name) { + void onUpdate({ name: localName.trim() }); + } + setEditingName(false); + }, [localName, trigger.name, onUpdate]); + + // Save pattern on blur + const handlePatternBlur = useCallback(() => { + if (validatePattern(localPattern) && localPattern !== trigger.matchPattern) { + void onUpdate({ matchPattern: localPattern }); + } + }, [localPattern, trigger.matchPattern, onUpdate, validatePattern]); + + // Update local pattern and validate + const handlePatternChange = useCallback( + (value: string) => { + setLocalPattern(value); + validatePattern(value); + }, + [validatePattern] + ); + + // Content type change - reset matchField to first available + const handleContentTypeChange = useCallback( + (value: TriggerContentType) => { + const newMatchFields = getAvailableMatchFields(value, trigger.toolName ?? undefined); + const newMatchField = newMatchFields[0]?.value ?? ''; + const updates: Partial = { + contentType: value, + matchField: (newMatchField as TriggerMatchField) || undefined, + }; + // Reset tool name if not applicable + if (value !== 'tool_use' && value !== 'tool_result') { + updates.toolName = undefined; + } + void onUpdate(updates); + }, + [onUpdate, trigger.toolName] + ); + + // Tool name change - reset matchField to first available + const handleToolNameChange = useCallback( + (value: string) => { + const newMatchFields = getAvailableMatchFields(trigger.contentType, value || undefined); + const newMatchField = newMatchFields[0]?.value ?? ''; + void onUpdate({ + toolName: value || undefined, + matchField: (newMatchField as TriggerMatchField) || undefined, + }); + }, + [onUpdate, trigger.contentType] + ); + + // Match field change + const handleMatchFieldChange = useCallback( + (value: string) => { + void onUpdate({ matchField: value as TriggerMatchField }); + }, + [onUpdate] + ); + + // Mode change with appropriate defaults + const handleModeChange = useCallback( + (newMode: TriggerMode) => { + setLocalMode(newMode); + const updates: Partial = { mode: newMode }; + + if (newMode === 'error_status') { + updates.requireError = true; + updates.contentType = 'tool_result'; + } else if (newMode === 'content_match') { + // Ensure matchField is set for validation + const contentType = trigger.contentType ?? 'tool_result'; + const matchFields = getAvailableMatchFields(contentType, trigger.toolName ?? undefined); + if (!trigger.matchField && matchFields.length > 0) { + updates.matchField = matchFields[0].value as TriggerMatchField; + } + } else if (newMode === 'token_threshold') { + updates.tokenThreshold = localTokenThreshold; + updates.tokenType = localTokenType; + } + + void onUpdate(updates); + }, + [ + onUpdate, + localTokenThreshold, + localTokenType, + trigger.contentType, + trigger.toolName, + trigger.matchField, + ] + ); + + // Token threshold change — local only, commit on blur + const handleTokenThresholdChange = useCallback((value: number) => { + setLocalTokenThreshold(value); + }, []); + + // Commit token threshold to config + const handleTokenThresholdBlur = useCallback(() => { + if (localTokenThreshold !== (trigger.tokenThreshold ?? 1000)) { + void onUpdate({ tokenThreshold: localTokenThreshold }); + } + }, [localTokenThreshold, trigger.tokenThreshold, onUpdate]); + + // Token type change + const handleTokenTypeChange = useCallback( + (value: TriggerTokenType) => { + setLocalTokenType(value); + void onUpdate({ tokenType: value }); + }, + [onUpdate] + ); + + // Add ignore pattern + const handleAddIgnorePattern = useCallback( + (pattern: string) => { + const newPatterns = [...(trigger.ignorePatterns ?? []), pattern]; + void onUpdate({ ignorePatterns: newPatterns }); + }, + [trigger.ignorePatterns, onUpdate] + ); + + // Remove ignore pattern + const handleRemoveIgnorePattern = useCallback( + (index: number) => { + const newPatterns = [...(trigger.ignorePatterns ?? [])]; + newPatterns.splice(index, 1); + void onUpdate({ ignorePatterns: newPatterns }); + }, + [trigger.ignorePatterns, onUpdate] + ); + + // Add repository + const handleAddRepository = useCallback( + (item: RepositoryDropdownItem) => { + const currentIds = trigger.repositoryIds ?? []; + if (!currentIds.includes(item.id)) { + void onUpdate({ repositoryIds: [...currentIds, item.id] }); + } + }, + [trigger.repositoryIds, onUpdate] + ); + + // Remove repository + const handleRemoveRepository = useCallback( + (index: number) => { + const newIds = [...(trigger.repositoryIds ?? [])]; + newIds.splice(index, 1); + void onUpdate({ repositoryIds: newIds }); + }, + [trigger.repositoryIds, onUpdate] + ); + + // Color change + const handleColorChange = useCallback( + (color: TriggerColor) => { + void onUpdate({ color }); + }, + [onUpdate] + ); + + return { + isExpanded, + setIsExpanded, + editingName, + setEditingName, + localName, + setLocalName, + localPattern, + localMode, + localTokenThreshold, + localTokenType, + handleToggleEnabled, + handleNameSave, + handlePatternBlur, + handlePatternChange, + handleContentTypeChange, + handleToolNameChange, + handleMatchFieldChange, + handleModeChange, + handleTokenThresholdChange, + handleTokenThresholdBlur, + handleTokenTypeChange, + handleAddIgnorePattern, + handleRemoveIgnorePattern, + handleAddRepository, + handleRemoveRepository, + handleColorChange, + }; +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts new file mode 100644 index 00000000..99c03d8f --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/hooks/useTriggerForm.ts @@ -0,0 +1,184 @@ +/** + * Hook for shared form state and validation logic used by TriggerCard and AddTriggerForm. + */ + +import { useCallback, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Component:TriggerForm'); + +import { generateId, validateRegexPattern } from '../utils/trigger'; + +import type { PreviewResult } from '../types'; +import type { + NotificationTrigger, + TriggerMatchField, + TriggerMode, + TriggerTestResult, + TriggerTokenType, +} from '@renderer/types/data'; + +interface UseTriggerFormOptions { + /** Initial trigger for editing mode, or undefined for new trigger creation */ + trigger?: NotificationTrigger; + /** Callback when trigger is updated (for edit mode) */ + onUpdate?: (updates: Partial) => Promise; +} + +interface UseTriggerFormReturn { + // Pattern validation + patternError: string | null; + validatePattern: (pattern: string) => boolean; + + // Preview/test functionality + previewResult: PreviewResult | null; + handleTestTrigger: (trigger: NotificationTrigger) => Promise; + handleViewSession: (error: TriggerTestResult['errors'][0]) => void; + clearPreview: () => void; + + // Build trigger for testing (used by AddTriggerForm) + buildTriggerForTest: (formState: { + name: string; + contentType: NotificationTrigger['contentType']; + mode: TriggerMode; + matchField?: string; + matchPattern?: string; + tokenThreshold?: number; + tokenType?: TriggerTokenType; + toolName?: string; + ignorePatterns?: string[]; + repositoryIds?: string[]; + }) => NotificationTrigger; +} + +/** + * Shared form state and validation logic for trigger forms. + */ +export function useTriggerForm(_options: UseTriggerFormOptions = {}): UseTriggerFormReturn { + const [patternError, setPatternError] = useState(null); + const [previewResult, setPreviewResult] = useState(null); + + // Get navigateToError from store for View Session functionality + const navigateToError = useStore((state) => state.navigateToError); + + /** + * Validate a regex pattern. + */ + const validatePattern = useCallback((pattern: string): boolean => { + const error = validateRegexPattern(pattern); + setPatternError(error); + return error === null; + }, []); + + /** + * Clear the preview result. + */ + const clearPreview = useCallback(() => { + setPreviewResult(null); + }, []); + + /** + * Test trigger against historical data. + * Results are automatically limited by the main process to prevent resource exhaustion: + * - Max 50 errors returned + * - Max 10,000 totalCount + * - Max 100 sessions scanned + * - 30 second timeout + */ + const handleTestTrigger = useCallback(async (trigger: NotificationTrigger) => { + setPreviewResult({ loading: true, totalCount: 0, errors: [] }); + + try { + const result = await window.electronAPI.config.testTrigger(trigger); + setPreviewResult({ + loading: false, + totalCount: result.totalCount, + errors: result.errors, + truncated: result.truncated, + }); + } catch (error) { + logger.error('Failed to test trigger:', error); + setPreviewResult(null); + } + }, []); + + /** + * Handle View Session click - navigate to the error location. + */ + const handleViewSession = useCallback( + (error: TriggerTestResult['errors'][0]) => { + navigateToError({ + id: error.id, + sessionId: error.sessionId, + projectId: error.projectId, + message: error.message, + timestamp: error.timestamp, + source: error.source, + filePath: '', + context: error.context, + isRead: true, + createdAt: error.timestamp, + // Deep linking data for exact error position + toolUseId: error.toolUseId, + subagentId: error.subagentId, + lineNumber: error.lineNumber, + }); + }, + [navigateToError] + ); + + /** + * Build a trigger object from form state for testing. + */ + const buildTriggerForTest = useCallback( + (formState: { + name: string; + contentType: NotificationTrigger['contentType']; + mode: TriggerMode; + matchField?: string; + matchPattern?: string; + tokenThreshold?: number; + tokenType?: TriggerTokenType; + toolName?: string; + ignorePatterns?: string[]; + repositoryIds?: string[]; + }): NotificationTrigger => { + return { + id: `test-${generateId()}`, + name: formState.name.trim() || 'Test Trigger', + enabled: true, + contentType: formState.contentType, + mode: formState.mode, + isBuiltin: false, + ...(formState.mode === 'error_status' && { requireError: true }), + ...(formState.mode === 'content_match' && + formState.matchField && { matchField: formState.matchField as TriggerMatchField }), + ...(formState.mode === 'content_match' && + formState.matchPattern && { matchPattern: formState.matchPattern }), + ...(formState.mode === 'token_threshold' && { + tokenThreshold: formState.tokenThreshold, + tokenType: formState.tokenType, + }), + ...((formState.contentType === 'tool_use' || formState.contentType === 'tool_result') && + formState.toolName && { toolName: formState.toolName }), + ...(formState.ignorePatterns && + formState.ignorePatterns.length > 0 && { ignorePatterns: formState.ignorePatterns }), + ...(formState.repositoryIds && + formState.repositoryIds.length > 0 && { repositoryIds: formState.repositoryIds }), + }; + }, + [] + ); + + return { + patternError, + validatePattern, + previewResult, + handleTestTrigger, + handleViewSession, + clearPreview, + buildTriggerForTest, + }; +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/index.tsx b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx new file mode 100644 index 00000000..c0ba60f3 --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/index.tsx @@ -0,0 +1,88 @@ +/** + * NotificationTriggerSettings - Component for managing notification triggers. + * Allows users to configure when notifications should be generated. + * + * Uses intent-first design pattern with 4 sections: + * 1. General Info (always visible) + * 2. Trigger Condition (mode selector) + * 3. Dynamic Configuration (based on mode) + * 4. Advanced (collapsible) + */ + +import { AddTriggerForm } from './components/AddTriggerForm'; +import { SectionHeader } from './components/SectionHeader'; +import { TriggerCard } from './components/TriggerCard'; + +import type { NotificationTriggerSettingsProps } from './types'; + +// Stable no-op function for builtin triggers that can't be removed +const noopRemove = (_triggerId: string): Promise => Promise.resolve(); + +/** + * Main component for managing notification triggers. + */ +export const NotificationTriggerSettings = ({ + triggers, + saving, + onUpdateTrigger, + onAddTrigger, + onRemoveTrigger, +}: Readonly): React.JSX.Element => { + // Separate builtin and custom triggers + const builtinTriggers = triggers.filter((t) => t.isBuiltin); + const customTriggers = triggers.filter((t) => !t.isBuiltin); + + return ( +
    + {/* Builtin Triggers */} + {builtinTriggers.length > 0 && ( +
    + +

    + Default triggers that come with the application. You can enable/disable them and + customize their patterns. +

    +
    + {builtinTriggers.map((trigger) => ( + + ))} +
    +
    + )} + + {/* Custom Triggers */} +
    + +

    + Create your own triggers to get notified for specific patterns or tool outputs. +

    + + {customTriggers.length > 0 && ( +
    + {customTriggers.map((trigger) => ( + + ))} +
    + )} + + {customTriggers.length === 0 && ( +

    No custom triggers configured yet.

    + )} + + +
    +
    + ); +}; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/types.ts b/src/renderer/components/settings/NotificationTriggerSettings/types.ts new file mode 100644 index 00000000..c927dbbe --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/types.ts @@ -0,0 +1,39 @@ +/** + * Local type definitions for NotificationTriggerSettings components. + */ + +import type { NotificationTrigger, TriggerMode, TriggerTestResult } from '@renderer/types/data'; + +/** + * Preview result state for a trigger test. + */ +export interface PreviewResult { + loading: boolean; + totalCount: number; + errors: TriggerTestResult['errors']; + /** + * True if results were truncated due to safety limits. + * When truncated, totalCount may be capped at 10,000. + */ + truncated?: boolean; +} + +/** + * Mode configuration for the segmented control. + */ +export interface ModeConfig { + value: TriggerMode; + label: string; + icon: React.ComponentType<{ className?: string }>; +} + +/** + * Props for the main NotificationTriggerSettings component. + */ +export interface NotificationTriggerSettingsProps { + triggers: NotificationTrigger[]; + saving: boolean; + onUpdateTrigger: (triggerId: string, updates: Partial) => Promise; + onAddTrigger: (trigger: Omit) => Promise; + onRemoveTrigger: (triggerId: string) => Promise; +} diff --git a/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts new file mode 100644 index 00000000..70e6975e --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/utils/constants.ts @@ -0,0 +1,50 @@ +/** + * Constants for NotificationTriggerSettings. + */ + +import { Activity, AlertCircle, Search } from 'lucide-react'; + +import type { ModeConfig } from '../types'; +import type { TriggerContentType, TriggerToolName } from '@renderer/types/data'; + +/** + * Content type options for dropdown. + */ +export const CONTENT_TYPE_OPTIONS: { value: TriggerContentType; label: string }[] = [ + { value: 'tool_result', label: 'Tool Result' }, + { value: 'tool_use', label: 'Tool Use' }, + { value: 'thinking', label: 'Thinking' }, + { value: 'text', label: 'Text Output' }, +]; + +/** + * Tool name options for dropdown. + */ +export const TOOL_NAME_OPTIONS: { value: TriggerToolName; label: string }[] = [ + { value: '', label: 'Any Tool' }, + { value: 'Bash', label: 'Bash' }, + { value: 'Task', label: 'Task' }, + { value: 'Read', label: 'Read' }, + { value: 'Write', label: 'Write' }, + { value: 'Edit', label: 'Edit' }, + { value: 'Grep', label: 'Grep' }, + { value: 'Glob', label: 'Glob' }, + { value: 'WebFetch', label: 'WebFetch' }, + { value: 'WebSearch', label: 'WebSearch' }, + { value: 'LSP', label: 'LSP' }, + { value: 'TodoWrite', label: 'TodoWrite' }, + { value: 'Skill', label: 'Skill' }, + { value: 'NotebookEdit', label: 'NotebookEdit' }, + { value: 'AskUserQuestion', label: 'AskUserQuestion' }, + { value: 'KillShell', label: 'KillShell' }, + { value: 'TaskOutput', label: 'TaskOutput' }, +]; + +/** + * Mode options for the trigger mode selector. + */ +export const MODE_OPTIONS: ModeConfig[] = [ + { value: 'error_status', label: 'Execution Error', icon: AlertCircle }, + { value: 'content_match', label: 'Content Pattern', icon: Search }, + { value: 'token_threshold', label: 'High Token Usage', icon: Activity }, +]; diff --git a/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts b/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts new file mode 100644 index 00000000..fb9803ae --- /dev/null +++ b/src/renderer/components/settings/NotificationTriggerSettings/utils/trigger.ts @@ -0,0 +1,112 @@ +/** + * Utility functions for notification triggers. + */ + +import type { NotificationTrigger, TriggerContentType, TriggerMode } from '@renderer/types/data'; + +/** + * Generates a UUID v4 for new triggers. + */ +export function generateId(): string { + return crypto.randomUUID(); +} + +/** + * Get available match fields based on content type and tool name. + */ +export function getAvailableMatchFields( + contentType: TriggerContentType, + toolName?: string +): { value: string; label: string }[] { + if (contentType === 'tool_result') { + return [{ value: 'content', label: 'Content' }]; + } + + if (contentType === 'thinking') { + return [{ value: 'thinking', label: 'Thinking Content' }]; + } + + if (contentType === 'text') { + return [{ value: 'text', label: 'Text Content' }]; + } + + if (contentType === 'tool_use') { + switch (toolName) { + case 'Bash': + return [ + { value: 'command', label: 'Command' }, + { value: 'description', label: 'Description' }, + ]; + case 'Task': + return [ + { value: 'description', label: 'Description' }, + { value: 'prompt', label: 'Prompt' }, + { value: 'subagent_type', label: 'Subagent Type' }, + ]; + case 'Read': + case 'Write': + return [{ value: 'file_path', label: 'File Path' }]; + case 'Edit': + return [ + { value: 'file_path', label: 'File Path' }, + { value: 'old_string', label: 'Old String' }, + { value: 'new_string', label: 'New String' }, + ]; + case 'Glob': + return [ + { value: 'pattern', label: 'Pattern' }, + { value: 'path', label: 'Path' }, + ]; + case 'Grep': + return [ + { value: 'pattern', label: 'Pattern' }, + { value: 'path', label: 'Path' }, + { value: 'glob', label: 'Glob Filter' }, + ]; + case 'WebFetch': + return [ + { value: 'url', label: 'URL' }, + { value: 'prompt', label: 'Prompt' }, + ]; + case 'WebSearch': + return [{ value: 'query', label: 'Query' }]; + case 'Skill': + return [ + { value: 'skill', label: 'Skill Name' }, + { value: 'args', label: 'Arguments' }, + ]; + default: + return []; + } + } + + return []; +} + +/** + * Derive the effective mode from trigger configuration for backward compatibility. + */ +export function deriveMode(trigger: NotificationTrigger): TriggerMode { + if (trigger.mode) return trigger.mode; + // Backward compatibility: if requireError is true and no mode, default to error_status + if (trigger.requireError && trigger.contentType === 'tool_result') { + return 'error_status'; + } + return 'content_match'; +} + +/** + * Validates a regex pattern. + * @returns null if valid, error message if invalid + */ +export function validateRegexPattern(pattern: string): string | null { + if (!pattern) { + return null; + } + try { + new RegExp(pattern); + return null; + } catch { + return 'Invalid regex pattern'; + } +} diff --git a/src/renderer/components/settings/SettingsTabs.tsx b/src/renderer/components/settings/SettingsTabs.tsx new file mode 100644 index 00000000..1efcd72f --- /dev/null +++ b/src/renderer/components/settings/SettingsTabs.tsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; + +import { Bell, Settings, Wrench } from 'lucide-react'; + +export type SettingsSection = 'general' | 'notifications' | 'advanced'; + +interface SettingsTabsProps { + activeSection: SettingsSection; + onSectionChange: (section: SettingsSection) => void; +} + +interface TabConfig { + id: SettingsSection; + label: string; + icon: React.ComponentType<{ className?: string }>; +} + +const tabs: TabConfig[] = [ + { id: 'general', label: 'General', icon: Settings }, + { id: 'notifications', label: 'Notifications', icon: Bell }, + { id: 'advanced', label: 'Advanced', icon: Wrench }, +]; + +export const SettingsTabs = ({ + activeSection, + onSectionChange, +}: Readonly): React.JSX.Element => { + const [hoveredTab, setHoveredTab] = useState(null); + + return ( +
    + {tabs.map((tab) => { + const Icon = tab.icon; + const isActive = activeSection === tab.id; + const isHovered = hoveredTab === tab.id; + + const getTextColor = (): string => { + if (isActive) return 'var(--color-text)'; + if (isHovered) return 'var(--color-text-secondary)'; + return 'var(--color-text-muted)'; + }; + + return ( + + ); + })} +
    + ); +}; diff --git a/src/renderer/components/settings/SettingsView.tsx b/src/renderer/components/settings/SettingsView.tsx new file mode 100644 index 00000000..3263500d --- /dev/null +++ b/src/renderer/components/settings/SettingsView.tsx @@ -0,0 +1,146 @@ +/** + * SettingsView - Main settings panel with all app configuration options. + * Provides UI for managing notifications, display settings, and advanced options. + */ + +import { useState } from 'react'; + +import { Loader2 } from 'lucide-react'; + +import { useSettingsConfig, useSettingsHandlers } from './hooks'; +import { AdvancedSection, GeneralSection, NotificationsSection } from './sections'; +import { type SettingsSection, SettingsTabs } from './SettingsTabs'; + +export const SettingsView = (): React.JSX.Element | null => { + const [activeSection, setActiveSection] = useState('general'); + + const { + config, + safeConfig, + loading, + saving, + error, + setError, + setSaving, + setConfig, + setOptimisticConfig, + updateConfig, + ignoredRepositoryItems, + excludedRepositoryIds, + isSnoozed, + } = useSettingsConfig(); + + const handlers = useSettingsHandlers({ + config, + setSaving, + setError, + setConfig, + setOptimisticConfig, + updateConfig, + }); + + // Loading state + if (loading) { + return ( +
    +
    + + Loading settings... +
    +
    + ); + } + + // Error state + if (error && !config) { + return ( +
    +
    +

    {error}

    + +
    +
    + ); + } + + if (!config) return null; + + return ( +
    +
    + {/* Header */} +
    +

    + Settings +

    +

    + Manage your app preferences +

    + {error && ( +
    +

    {error}

    +
    + )} +
    + + {/* Tabs */} + + + {/* Content */} +
    + {activeSection === 'general' && ( + + )} + + {activeSection === 'notifications' && ( + + )} + + {activeSection === 'advanced' && ( + + )} +
    +
    +
    + ); +}; diff --git a/src/renderer/components/settings/components/SettingRow.tsx b/src/renderer/components/settings/components/SettingRow.tsx new file mode 100644 index 00000000..d0353e34 --- /dev/null +++ b/src/renderer/components/settings/components/SettingRow.tsx @@ -0,0 +1,35 @@ +/** + * SettingRow - Setting row component for consistent layout. + * Linear-style clean row without icons. + */ + +interface SettingRowProps { + readonly label: string; + readonly description?: string; + readonly children: React.ReactNode; +} + +export const SettingRow = ({ + label, + description, + children, +}: SettingRowProps): React.JSX.Element => { + return ( +
    +
    +
    + {label} +
    + {description && ( +
    + {description} +
    + )} +
    +
    {children}
    +
    + ); +}; diff --git a/src/renderer/components/settings/components/SettingsSectionHeader.tsx b/src/renderer/components/settings/components/SettingsSectionHeader.tsx new file mode 100644 index 00000000..67887fd9 --- /dev/null +++ b/src/renderer/components/settings/components/SettingsSectionHeader.tsx @@ -0,0 +1,19 @@ +/** + * SettingsSectionHeader - Section header component. + * Linear-style subtle label. + */ + +interface SettingsSectionHeaderProps { + readonly title: string; +} + +export const SettingsSectionHeader = ({ title }: SettingsSectionHeaderProps): React.JSX.Element => { + return ( +

    + {title} +

    + ); +}; diff --git a/src/renderer/components/settings/components/SettingsSelect.tsx b/src/renderer/components/settings/components/SettingsSelect.tsx new file mode 100644 index 00000000..f8b53df0 --- /dev/null +++ b/src/renderer/components/settings/components/SettingsSelect.tsx @@ -0,0 +1,97 @@ +/** + * SettingsSelect - Custom dropdown select component with styled dropdown menu. + * Avoids browser default select styling for a consistent dark theme experience. + */ + +import { useEffect, useRef, useState } from 'react'; + +import { Check, ChevronDown } from 'lucide-react'; + +interface SettingsSelectProps { + readonly value: T; + readonly options: readonly { value: T; label: string }[]; + readonly onChange: (value: T) => void; + readonly disabled?: boolean; + readonly dropUp?: boolean; +} + +export const SettingsSelect = ({ + value, + options, + onChange, + disabled = false, + dropUp = false, +}: SettingsSelectProps): React.JSX.Element => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + // Find current label + const currentLabel = options.find((opt) => opt.value === value)?.label ?? 'Select...'; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + const handleSelect = (optionValue: T): void => { + onChange(optionValue); + setIsOpen(false); + }; + + return ( +
    + {/* Trigger Button */} + + + {/* Dropdown Menu */} + {isOpen && ( +
    + {options.map((option) => ( + + ))} +
    + )} +
    + ); +}; diff --git a/src/renderer/components/settings/components/SettingsToggle.tsx b/src/renderer/components/settings/components/SettingsToggle.tsx new file mode 100644 index 00000000..1af51179 --- /dev/null +++ b/src/renderer/components/settings/components/SettingsToggle.tsx @@ -0,0 +1,45 @@ +/** + * SettingsToggle - Toggle switch component for boolean settings. + * Linear-style design with white thumb and focus ring. + */ + +interface SettingsToggleProps { + readonly enabled: boolean; + readonly onChange: (value: boolean) => void; + readonly disabled?: boolean; +} + +export const SettingsToggle = ({ + enabled, + onChange, + disabled = false, +}: SettingsToggleProps): React.JSX.Element => { + const handleClick = (): void => { + if (!disabled) { + onChange(!enabled); + } + }; + + return ( + + ); +}; diff --git a/src/renderer/components/settings/components/index.ts b/src/renderer/components/settings/components/index.ts new file mode 100644 index 00000000..33dada83 --- /dev/null +++ b/src/renderer/components/settings/components/index.ts @@ -0,0 +1,8 @@ +/** + * Settings shared components barrel export. + */ + +export { SettingRow } from './SettingRow'; +export { SettingsSectionHeader } from './SettingsSectionHeader'; +export { SettingsSelect } from './SettingsSelect'; +export { SettingsToggle } from './SettingsToggle'; diff --git a/src/renderer/components/settings/hooks/index.ts b/src/renderer/components/settings/hooks/index.ts new file mode 100644 index 00000000..97ad3e63 --- /dev/null +++ b/src/renderer/components/settings/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * Settings hooks barrel export. + */ + +export { useSettingsConfig } from './useSettingsConfig'; +export { useSettingsHandlers } from './useSettingsHandlers'; diff --git a/src/renderer/components/settings/hooks/useSettingsConfig.ts b/src/renderer/components/settings/hooks/useSettingsConfig.ts new file mode 100644 index 00000000..064ca78a --- /dev/null +++ b/src/renderer/components/settings/hooks/useSettingsConfig.ts @@ -0,0 +1,228 @@ +/** + * useSettingsConfig - Hook for managing settings configuration state. + * Handles loading, saving, and providing safe defaults for config. + */ + +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +import type { AppConfig } from '@renderer/types/data'; + +// Get the setState function from the store to update appConfig globally +const setStoreState = useStore.setState; + +/** Repository item for ignored repositories list */ +export interface RepositoryDropdownItem { + id: string; + name: string; + path: string; + worktreeCount: number; + totalSessions: number; +} + +export interface SafeConfig { + general: { + launchAtLogin: boolean; + showDockIcon: boolean; + theme: 'dark' | 'light' | 'system'; + defaultTab: 'dashboard' | 'last-session'; + }; + notifications: { + enabled: boolean; + soundEnabled: boolean; + ignoredRegex: string[]; + ignoredRepositories: string[]; + snoozedUntil: number | null; + snoozeMinutes: number; + includeSubagentErrors: boolean; + triggers: AppConfig['notifications']['triggers']; + }; + display: { + showTimestamps: boolean; + compactMode: boolean; + syntaxHighlighting: boolean; + }; +} + +interface UseSettingsConfigReturn { + config: AppConfig | null; + safeConfig: SafeConfig; + loading: boolean; + saving: boolean; + error: string | null; + setError: (error: string | null) => void; + setSaving: (saving: boolean) => void; + setConfig: (config: AppConfig | null) => void; + setOptimisticConfig: React.Dispatch>; + updateConfig: ( + section: keyof AppConfig, + data: Partial + ) => Promise; + ignoredRepositoryItems: RepositoryDropdownItem[]; + excludedRepositoryIds: string[]; + isSnoozed: boolean; +} + +export function useSettingsConfig(): UseSettingsConfigReturn { + const { repositoryGroups, fetchRepositoryGroups } = useStore( + useShallow((s) => ({ + repositoryGroups: s.repositoryGroups, + fetchRepositoryGroups: s.fetchRepositoryGroups, + })) + ); + + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Local optimistic state for immediate visual feedback on toggles + const [optimisticConfig, setOptimisticConfig] = useState(null); + + // Fetch config on mount + useEffect(() => { + const loadConfig = async (): Promise => { + try { + setLoading(true); + setError(null); + const loadedConfig = await window.electronAPI.config.get(); + setConfig(loadedConfig); + setOptimisticConfig(loadedConfig); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load settings'); + } finally { + setLoading(false); + } + }; + + void loadConfig(); + }, []); + + // Fetch repository groups for ignored repositories dropdown + useEffect(() => { + if (repositoryGroups.length === 0) { + void fetchRepositoryGroups(); + } + }, [repositoryGroups.length, fetchRepositoryGroups]); + + // Update a config section with optimistic update for immediate UI feedback + const updateConfig = useCallback( + async (section: keyof AppConfig, data: Partial) => { + // Optimistic update - immediately reflect the change in UI + setOptimisticConfig((prev) => { + if (!prev) return prev; + return { + ...prev, + [section]: { + ...prev[section], + ...data, + }, + }; + }); + + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.update(section, data); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + // Update global store so other components (like useTheme) see the change + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + // Revert optimistic update on error + setOptimisticConfig(config); + setError(err instanceof Error ? err.message : 'Failed to save settings'); + } finally { + setSaving(false); + } + }, + [config] + ); + + // Use optimistic config for UI display (falls back to config if not set) + const displayConfig = optimisticConfig ?? config; + + // Create safe config with defaults to prevent null reference errors + const safeConfig = useMemo( + (): SafeConfig => ({ + general: { + launchAtLogin: displayConfig?.general?.launchAtLogin ?? false, + showDockIcon: displayConfig?.general?.showDockIcon ?? true, + theme: displayConfig?.general?.theme ?? 'dark', + defaultTab: displayConfig?.general?.defaultTab ?? 'dashboard', + }, + notifications: { + enabled: displayConfig?.notifications?.enabled ?? true, + soundEnabled: displayConfig?.notifications?.soundEnabled ?? true, + ignoredRegex: displayConfig?.notifications?.ignoredRegex ?? [], + ignoredRepositories: displayConfig?.notifications?.ignoredRepositories ?? [], + snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null, + snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30, + includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true, + triggers: displayConfig?.notifications?.triggers ?? [], + }, + display: { + showTimestamps: displayConfig?.display?.showTimestamps ?? true, + compactMode: displayConfig?.display?.compactMode ?? false, + syntaxHighlighting: displayConfig?.display?.syntaxHighlighting ?? true, + }, + }), + [displayConfig] + ); + + // Convert ignored repository IDs to RepositoryDropdownItem[] for display + const ignoredRepositoryItems = useMemo((): RepositoryDropdownItem[] => { + const items: RepositoryDropdownItem[] = []; + const ignoredRepositories = safeConfig.notifications.ignoredRepositories; + + for (const repositoryId of ignoredRepositories) { + // Find repository group by ID + const group = repositoryGroups.find((g) => g.id === repositoryId); + if (group) { + items.push({ + id: group.id, + name: group.name, + path: group.worktrees[0]?.path ?? '', + worktreeCount: group.worktrees.length, + totalSessions: group.totalSessions, + }); + } else { + // If not found, create a placeholder item + items.push({ + id: repositoryId, + name: repositoryId, + path: '', + worktreeCount: 0, + totalSessions: 0, + }); + } + } + + return items; + }, [safeConfig.notifications.ignoredRepositories, repositoryGroups]); + + // Get excluded repository IDs for dropdown + const excludedRepositoryIds = safeConfig.notifications.ignoredRepositories; + + // Check if snoozed + const isSnoozed = + safeConfig.notifications.snoozedUntil !== null && + safeConfig.notifications.snoozedUntil > Date.now(); + + return { + config, + safeConfig, + loading, + saving, + error, + setError, + setSaving, + setConfig, + setOptimisticConfig, + updateConfig, + ignoredRepositoryItems, + excludedRepositoryIds, + isSnoozed, + }; +} diff --git a/src/renderer/components/settings/hooks/useSettingsHandlers.ts b/src/renderer/components/settings/hooks/useSettingsHandlers.ts new file mode 100644 index 00000000..7638e683 --- /dev/null +++ b/src/renderer/components/settings/hooks/useSettingsHandlers.ts @@ -0,0 +1,391 @@ +/** + * useSettingsHandlers - Hook for all settings action handlers. + * Groups handlers by section for better organization. + */ + +import { useCallback, useRef } from 'react'; + +import { useStore } from '@renderer/store'; + +import type { RepositoryDropdownItem } from './useSettingsConfig'; +import type { AppConfig, NotificationTrigger } from '@renderer/types/data'; + +// Get the setState function from the store to update appConfig globally +const setStoreState = useStore.setState; + +interface UseSettingsHandlersProps { + config: AppConfig | null; + setSaving: (saving: boolean) => void; + setError: (error: string | null) => void; + setConfig: (config: AppConfig | null) => void; + setOptimisticConfig: React.Dispatch>; + updateConfig: ( + section: keyof AppConfig, + data: Partial + ) => Promise; +} + +interface SettingsHandlers { + // General handlers + handleGeneralToggle: (key: keyof AppConfig['general'], value: boolean) => void; + handleThemeChange: (value: 'dark' | 'light' | 'system') => void; + handleDefaultTabChange: (value: 'dashboard' | 'last-session') => void; + + // Notification handlers + handleNotificationToggle: (key: keyof AppConfig['notifications'], value: boolean) => void; + handleSnooze: (minutes: number) => Promise; + handleClearSnooze: () => Promise; + handleAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise; + handleRemoveIgnoredRepository: (repositoryId: string) => Promise; + + // Trigger handlers + handleAddTrigger: (trigger: Omit) => Promise; + handleUpdateTrigger: (triggerId: string, updates: Partial) => Promise; + handleRemoveTrigger: (triggerId: string) => Promise; + + // Display handlers + handleDisplayToggle: (key: keyof AppConfig['display'], value: boolean) => void; + + // Advanced handlers + handleResetToDefaults: () => Promise; + handleExportConfig: () => void; + handleImportConfig: () => void; + handleOpenInEditor: () => Promise; +} + +export function useSettingsHandlers({ + config, + setSaving, + setError, + setConfig, + setOptimisticConfig, + updateConfig, +}: UseSettingsHandlersProps): SettingsHandlers { + // Use ref for config to avoid recreating callbacks when config changes + const configRef = useRef(config); + configRef.current = config; + + // General handlers + const handleGeneralToggle = useCallback( + (key: keyof AppConfig['general'], value: boolean) => { + void updateConfig('general', { [key]: value }); + }, + [updateConfig] + ); + + const handleThemeChange = useCallback( + (value: 'dark' | 'light' | 'system') => { + void updateConfig('general', { theme: value }); + }, + [updateConfig] + ); + + const handleDefaultTabChange = useCallback( + (value: 'dashboard' | 'last-session') => { + void updateConfig('general', { defaultTab: value }); + }, + [updateConfig] + ); + + // Notification handlers + const handleNotificationToggle = useCallback( + (key: keyof AppConfig['notifications'], value: boolean) => { + void updateConfig('notifications', { [key]: value }); + }, + [updateConfig] + ); + + const handleSnooze = useCallback( + async (minutes: number) => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.snooze(minutes); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to snooze notifications'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + const handleClearSnooze = useCallback(async () => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.clearSnooze(); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to clear snooze'); + } finally { + setSaving(false); + } + }, [setSaving, setConfig, setOptimisticConfig, setError]); + + const handleAddIgnoredRepository = useCallback( + async (item: RepositoryDropdownItem) => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.addIgnoreRepository(item.id); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add repository'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + const handleRemoveIgnoredRepository = useCallback( + async (repositoryId: string) => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.removeIgnoreRepository(repositoryId); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove repository'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + // Trigger handlers + const handleAddTrigger = useCallback( + async (trigger: Omit) => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.addTrigger(trigger); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add trigger'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + const handleUpdateTrigger = useCallback( + async (triggerId: string, updates: Partial) => { + // Optimistic update - immediately reflect the change in UI + setOptimisticConfig((prev) => { + if (!prev) return prev; + const updatedTriggers = + prev.notifications.triggers?.map((t) => + t.id === triggerId ? { ...t, ...updates } : t + ) ?? []; + return { + ...prev, + notifications: { + ...prev.notifications, + triggers: updatedTriggers, + }, + }; + }); + + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.updateTrigger(triggerId, updates); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + // Revert optimistic update on error using ref to avoid stale closure + setOptimisticConfig(configRef.current); + setError(err instanceof Error ? err.message : 'Failed to update trigger'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + const handleRemoveTrigger = useCallback( + async (triggerId: string) => { + try { + setSaving(true); + const updatedConfig = await window.electronAPI.config.removeTrigger(triggerId); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to remove trigger'); + } finally { + setSaving(false); + } + }, + [setSaving, setConfig, setOptimisticConfig, setError] + ); + + // Display handlers + const handleDisplayToggle = useCallback( + (key: keyof AppConfig['display'], value: boolean) => { + void updateConfig('display', { [key]: value }); + }, + [updateConfig] + ); + + // Advanced handlers + const handleResetToDefaults = useCallback(async () => { + if (!confirm('Are you sure you want to reset all settings to defaults?')) { + return; + } + try { + setSaving(true); + const defaultIgnoredRegex = ["The user doesn't want to proceed with this tool use\\."]; + const defaultTriggers: NotificationTrigger[] = [ + { + id: 'builtin-tool-result-error', + name: 'Tool Result Error', + enabled: true, + contentType: 'tool_result', + mode: 'error_status', + requireError: true, + ignorePatterns: ["The user doesn't want to proceed with this tool use\\."], + isBuiltin: true, + }, + { + id: 'builtin-bash-command', + name: 'Bash Command Alert for .env files', + enabled: true, + contentType: 'tool_use', + toolName: 'Bash', + mode: 'content_match', + matchField: 'command', + matchPattern: '/.env', + isBuiltin: true, + }, + ]; + const defaultConfig: AppConfig = { + notifications: { + enabled: true, + soundEnabled: true, + ignoredRegex: defaultIgnoredRegex, + ignoredRepositories: [], + snoozedUntil: null, + snoozeMinutes: 30, + includeSubagentErrors: true, + triggers: defaultTriggers, + }, + general: { + launchAtLogin: false, + showDockIcon: true, + theme: 'dark', + defaultTab: 'dashboard', + }, + display: { + showTimestamps: true, + compactMode: false, + syntaxHighlighting: true, + }, + sessions: { + pinnedSessions: {}, + }, + }; + + await window.electronAPI.config.update('notifications', defaultConfig.notifications); + await window.electronAPI.config.update('general', defaultConfig.general); + const updatedConfig = await window.electronAPI.config.update( + 'display', + defaultConfig.display + ); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reset settings'); + } finally { + setSaving(false); + } + }, [setSaving, setConfig, setOptimisticConfig, setError]); + + const handleExportConfig = useCallback(() => { + if (!configRef.current) return; + const dataStr = JSON.stringify(configRef.current, null, 2); + const blob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'claude-code-context-config.json'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, []); + + const handleOpenInEditor = useCallback(async () => { + try { + await window.electronAPI.config.openInEditor(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to open config in editor'); + } + }, [setError]); + + const handleImportConfig = useCallback(() => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + setSaving(true); + const text = await file.text(); + const importedConfig = JSON.parse(text) as AppConfig; + + if (importedConfig.notifications) { + await window.electronAPI.config.update('notifications', importedConfig.notifications); + } + if (importedConfig.general) { + await window.electronAPI.config.update('general', importedConfig.general); + } + if (importedConfig.display) { + await window.electronAPI.config.update('display', importedConfig.display); + } + + const updatedConfig = await window.electronAPI.config.get(); + setConfig(updatedConfig); + setOptimisticConfig(updatedConfig); + setStoreState({ appConfig: updatedConfig }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to import config'); + } finally { + setSaving(false); + } + }; + input.click(); + }, [setSaving, setConfig, setOptimisticConfig, setError]); + + return { + handleGeneralToggle, + handleThemeChange, + handleDefaultTabChange, + handleNotificationToggle, + handleSnooze, + handleClearSnooze, + handleAddIgnoredRepository, + handleRemoveIgnoredRepository, + handleAddTrigger, + handleUpdateTrigger, + handleRemoveTrigger, + handleDisplayToggle, + handleResetToDefaults, + handleExportConfig, + handleImportConfig, + handleOpenInEditor, + }; +} diff --git a/src/renderer/components/settings/sections/AdvancedSection.tsx b/src/renderer/components/settings/sections/AdvancedSection.tsx new file mode 100644 index 00000000..975aff08 --- /dev/null +++ b/src/renderer/components/settings/sections/AdvancedSection.tsx @@ -0,0 +1,104 @@ +/** + * AdvancedSection - Advanced settings including config management and about info. + */ + +import { useEffect, useState } from 'react'; + +import appIcon from '@renderer/favicon.png'; +import { Code2, Download, RefreshCw, Upload } from 'lucide-react'; + +import { SettingsSectionHeader } from '../components'; + +interface AdvancedSectionProps { + readonly saving: boolean; + readonly onResetToDefaults: () => void; + readonly onExportConfig: () => void; + readonly onImportConfig: () => void; + readonly onOpenInEditor: () => void; +} + +export const AdvancedSection = ({ + saving, + onResetToDefaults, + onExportConfig, + onImportConfig, + onOpenInEditor, +}: AdvancedSectionProps): React.JSX.Element => { + const [version, setVersion] = useState(''); + + useEffect(() => { + window.electronAPI.getAppVersion().then(setVersion).catch(console.error); + }, []); + + return ( +
    + +
    + + + + +
    + + +
    + App Icon +
    +

    + Claude Code Context +

    +

    + Version {version || '...'} +

    +

    + Visualize and analyze Claude Code session executions with interactive waterfall charts + and detailed insights. +

    +
    +
    +
    + ); +}; diff --git a/src/renderer/components/settings/sections/GeneralSection.tsx b/src/renderer/components/settings/sections/GeneralSection.tsx new file mode 100644 index 00000000..87194319 --- /dev/null +++ b/src/renderer/components/settings/sections/GeneralSection.tsx @@ -0,0 +1,60 @@ +/** + * GeneralSection - General settings including startup and appearance. + */ + +import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; + +import type { SafeConfig } from '../hooks/useSettingsConfig'; + +// Theme options +const THEME_OPTIONS = [ + { value: 'dark', label: 'Dark' }, + { value: 'light', label: 'Light' }, + { value: 'system', label: 'System' }, +] as const; + +interface GeneralSectionProps { + readonly safeConfig: SafeConfig; + readonly saving: boolean; + readonly onGeneralToggle: (key: 'launchAtLogin' | 'showDockIcon', value: boolean) => void; + readonly onThemeChange: (value: 'dark' | 'light' | 'system') => void; +} + +export const GeneralSection = ({ + safeConfig, + saving, + onGeneralToggle, + onThemeChange, +}: GeneralSectionProps): React.JSX.Element => { + return ( +
    + + + onGeneralToggle('launchAtLogin', v)} + disabled={saving} + /> + + {window.navigator.userAgent.includes('Macintosh') && ( + + onGeneralToggle('showDockIcon', v)} + disabled={saving} + /> + + )} + + + + + +
    + ); +}; diff --git a/src/renderer/components/settings/sections/NotificationsSection.tsx b/src/renderer/components/settings/sections/NotificationsSection.tsx new file mode 100644 index 00000000..5a3829b5 --- /dev/null +++ b/src/renderer/components/settings/sections/NotificationsSection.tsx @@ -0,0 +1,166 @@ +/** + * NotificationsSection - Notification settings including triggers and ignored repositories. + */ + +import { + RepositoryDropdown, + SelectedRepositoryItem, +} from '@renderer/components/common/RepositoryDropdown'; + +import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components'; +import { NotificationTriggerSettings } from '../NotificationTriggerSettings'; + +import type { RepositoryDropdownItem, SafeConfig } from '../hooks/useSettingsConfig'; +import type { NotificationTrigger } from '@renderer/types/data'; + +// Snooze duration options +const SNOOZE_OPTIONS = [ + { value: 15, label: '15 minutes' }, + { value: 30, label: '30 minutes' }, + { value: 60, label: '1 hour' }, + { value: 120, label: '2 hours' }, + { value: 240, label: '4 hours' }, + { value: -1, label: 'Until tomorrow' }, +] as const; + +interface NotificationsSectionProps { + readonly safeConfig: SafeConfig; + readonly saving: boolean; + readonly isSnoozed: boolean; + readonly ignoredRepositoryItems: RepositoryDropdownItem[]; + readonly excludedRepositoryIds: string[]; + readonly onNotificationToggle: ( + key: 'enabled' | 'soundEnabled' | 'includeSubagentErrors', + value: boolean + ) => void; + readonly onSnooze: (minutes: number) => Promise; + readonly onClearSnooze: () => Promise; + readonly onAddIgnoredRepository: (item: RepositoryDropdownItem) => Promise; + readonly onRemoveIgnoredRepository: (repositoryId: string) => Promise; + readonly onAddTrigger: (trigger: Omit) => Promise; + readonly onUpdateTrigger: ( + triggerId: string, + updates: Partial + ) => Promise; + readonly onRemoveTrigger: (triggerId: string) => Promise; +} + +export const NotificationsSection = ({ + safeConfig, + saving, + isSnoozed, + ignoredRepositoryItems, + excludedRepositoryIds, + onNotificationToggle, + onSnooze, + onClearSnooze, + onAddIgnoredRepository, + onRemoveIgnoredRepository, + onAddTrigger, + onUpdateTrigger, + onRemoveTrigger, +}: NotificationsSectionProps): React.JSX.Element => { + return ( +
    + {/* Notification Triggers */} + + + {/* Notification Settings */} + + + onNotificationToggle('enabled', v)} + disabled={saving} + /> + + + onNotificationToggle('soundEnabled', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + + onNotificationToggle('includeSubagentErrors', v)} + disabled={saving || !safeConfig.notifications.enabled} + /> + + +
    + {isSnoozed ? ( + + ) : ( + v !== 0 && onSnooze(v)} + disabled={saving || !safeConfig.notifications.enabled} + dropUp + /> + )} +
    +
    + + +

    + Notifications from these repositories will be ignored +

    + {ignoredRepositoryItems.length > 0 ? ( +
    + {ignoredRepositoryItems.map((item) => ( + onRemoveIgnoredRepository(item.id)} + disabled={saving} + /> + ))} +
    + ) : ( +
    +

    + No repositories ignored +

    +
    + )} + +
    + ); +}; diff --git a/src/renderer/components/settings/sections/index.ts b/src/renderer/components/settings/sections/index.ts new file mode 100644 index 00000000..d5579c28 --- /dev/null +++ b/src/renderer/components/settings/sections/index.ts @@ -0,0 +1,7 @@ +/** + * Settings section components barrel export. + */ + +export { AdvancedSection } from './AdvancedSection'; +export { GeneralSection } from './GeneralSection'; +export { NotificationsSection } from './NotificationsSection'; diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx new file mode 100644 index 00000000..e46af28c --- /dev/null +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -0,0 +1,355 @@ +/** + * DateGroupedSessions - Sessions organized by date categories with virtual scrolling. + * Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll. + */ + +import { useCallback, useEffect, useMemo, useRef } from 'react'; + +import { useStore } from '@renderer/store'; +import { + getNonEmptyCategories, + groupSessionsByDate, + separatePinnedSessions, +} from '@renderer/utils/dateGrouping'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { SessionItem } from './SessionItem'; + +import type { Session } from '@renderer/types/data'; +import type { DateCategory } from '@renderer/types/tabs'; + +// Virtual list item types +type VirtualItem = + | { type: 'header'; category: DateCategory; id: string } + | { type: 'pinned-header'; id: string } + | { type: 'session'; session: Session; isPinned: boolean; id: string } + | { type: 'loader'; id: string }; + +/** + * Item height constants for virtual scroll positioning. + * CRITICAL: These values MUST match the actual rendered heights of components. + * If SessionItem height changes, update SESSION_HEIGHT here AND add h-[Xpx] to SessionItem. + * Mismatch causes items to overlap! + */ +const HEADER_HEIGHT = 28; +const SESSION_HEIGHT = 48; // Must match h-[48px] in SessionItem.tsx +const LOADER_HEIGHT = 36; +const OVERSCAN = 5; + +export const DateGroupedSessions = (): React.JSX.Element => { + const { + sessions, + selectedSessionId, + selectedProjectId, + sessionsLoading, + sessionsError, + sessionsHasMore, + sessionsLoadingMore, + sessionsTotalCount, + fetchSessionsMore, + pinnedSessionIds, + } = useStore( + useShallow((s) => ({ + sessions: s.sessions, + selectedSessionId: s.selectedSessionId, + selectedProjectId: s.selectedProjectId, + sessionsLoading: s.sessionsLoading, + sessionsError: s.sessionsError, + sessionsHasMore: s.sessionsHasMore, + sessionsLoadingMore: s.sessionsLoadingMore, + sessionsTotalCount: s.sessionsTotalCount, + fetchSessionsMore: s.fetchSessionsMore, + pinnedSessionIds: s.pinnedSessionIds, + })) + ); + + const parentRef = useRef(null); + + // Separate pinned sessions from unpinned + const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo( + () => separatePinnedSessions(sessions, pinnedSessionIds), + [sessions, pinnedSessionIds] + ); + + // Group only unpinned sessions by date + const groupedSessions = useMemo(() => groupSessionsByDate(unpinnedSessions), [unpinnedSessions]); + + // Get non-empty categories in display order + const nonEmptyCategories = useMemo( + () => getNonEmptyCategories(groupedSessions), + [groupedSessions] + ); + + // Flatten sessions with date headers into virtual list items + const virtualItems = useMemo((): VirtualItem[] => { + const items: VirtualItem[] = []; + + // Add pinned section first + if (pinnedSessions.length > 0) { + items.push({ + type: 'pinned-header', + id: 'header-pinned', + }); + + for (const session of pinnedSessions) { + items.push({ + type: 'session', + session, + isPinned: true, + id: `session-${session.id}`, + }); + } + } + + for (const category of nonEmptyCategories) { + // Add header item + items.push({ + type: 'header', + category, + id: `header-${category}`, + }); + + // Add session items + for (const session of groupedSessions[category]) { + items.push({ + type: 'session', + session, + isPinned: false, + id: `session-${session.id}`, + }); + } + } + + // Add loader item if there are more sessions to load + if (sessionsHasMore) { + items.push({ + type: 'loader', + id: 'loader', + }); + } + + return items; + }, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]); + + // Estimate item size based on type + const estimateSize = useCallback( + (index: number) => { + const item = virtualItems[index]; + if (!item) return SESSION_HEIGHT; + + switch (item.type) { + case 'header': + case 'pinned-header': + return HEADER_HEIGHT; + case 'loader': + return LOADER_HEIGHT; + case 'session': + default: + return SESSION_HEIGHT; + } + }, + [virtualItems] + ); + + // Set up virtualizer + // eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual API limitation, not fixable in user code + const rowVirtualizer = useVirtualizer({ + count: virtualItems.length, + getScrollElement: () => parentRef.current, + estimateSize, + overscan: OVERSCAN, + }); + + // Get virtual items for dependency tracking + const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualRowsLength = virtualRows.length; + + // Load more when scrolling near end + useEffect(() => { + if (virtualRowsLength === 0) return; + + const lastItem = virtualRows[virtualRowsLength - 1]; + if (!lastItem) return; + + // If we're within 3 items of the end and there's more to load, fetch more + if ( + lastItem.index >= virtualItems.length - 3 && + sessionsHasMore && + !sessionsLoadingMore && + !sessionsLoading + ) { + void fetchSessionsMore(); + } + }, [ + virtualRows, + virtualRowsLength, + virtualItems.length, + sessionsHasMore, + sessionsLoadingMore, + sessionsLoading, + fetchSessionsMore, + ]); + + if (!selectedProjectId) { + return ( +
    +
    +

    Select a project to view sessions

    +
    +
    + ); + } + + if (sessionsLoading && sessions.length === 0) { + return ( +
    +
    + {[...Array(3)].map((_, i) => ( +
    +
    +
    +
    +
    + ))} +
    +
    + ); + } + + if (sessionsError) { + return ( +
    +
    +

    + Error loading sessions +

    +

    {sessionsError}

    +
    +
    + ); + } + + if (sessions.length === 0) { + return ( +
    +
    + +

    No sessions found

    +

    This project has no sessions yet

    +
    +
    + ); + } + + return ( +
    +
    + +

    + Sessions +

    + + ({sessions.length} + {sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''}) + +
    + +
    +
    + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const item = virtualItems[virtualRow.index]; + if (!item) return null; + + return ( +
    + {item.type === 'pinned-header' ? ( +
    + + Pinned +
    + ) : item.type === 'header' ? ( +
    + {item.category} +
    + ) : item.type === 'loader' ? ( +
    + {sessionsLoadingMore ? ( + <> + + Loading more sessions... + + ) : ( + Scroll to load more + )} +
    + ) : ( + + )} +
    + ); + })} +
    +
    +
    + ); +}; diff --git a/src/renderer/components/sidebar/SessionContextMenu.tsx b/src/renderer/components/sidebar/SessionContextMenu.tsx new file mode 100644 index 00000000..37acea03 --- /dev/null +++ b/src/renderer/components/sidebar/SessionContextMenu.tsx @@ -0,0 +1,130 @@ +/** + * SessionContextMenu - Right-click context menu for sidebar session items. + * Supports opening in current pane, new tab, and split right. + * Shows keyboard shortcut hints for actions that have them. + */ + +import { useEffect, useRef } from 'react'; + +import { MAX_PANES } from '@renderer/types/panes'; +import { Pin, PinOff } from 'lucide-react'; + +interface SessionContextMenuProps { + x: number; + y: number; + sessionId: string; + projectId: string; + sessionLabel: string; + paneCount: number; + isPinned: boolean; + onClose: () => void; + onOpenInCurrentPane: () => void; + onOpenInNewTab: () => void; + onSplitRightAndOpen: () => void; + onTogglePin: () => void; +} + +export const SessionContextMenu = ({ + x, + y, + paneCount, + isPinned, + onClose, + onOpenInCurrentPane, + onOpenInNewTab, + onSplitRightAndOpen, + onTogglePin, +}: SessionContextMenuProps): React.JSX.Element => { + const menuRef = useRef(null); + + useEffect(() => { + const handleMouseDown = (e: MouseEvent): void => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('mousedown', handleMouseDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleMouseDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [onClose]); + + const menuWidth = 240; + const menuHeight = 180; + const clampedX = Math.min(x, window.innerWidth - menuWidth - 8); + const clampedY = Math.min(y, window.innerHeight - menuHeight - 8); + + const handleClick = (action: () => void) => () => { + action(); + onClose(); + }; + + const atMaxPanes = paneCount >= MAX_PANES; + + return ( +
    + + +
    + +
    + : } + onClick={handleClick(onTogglePin)} + /> +
    + ); +}; + +const MenuItem = ({ + label, + shortcut, + icon, + onClick, + disabled, +}: { + label: string; + shortcut?: string; + icon?: React.ReactNode; + onClick: () => void; + disabled?: boolean; +}): React.JSX.Element => { + return ( + + ); +}; diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx new file mode 100644 index 00000000..1dbe4cb1 --- /dev/null +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -0,0 +1,200 @@ +/** + * SessionItem - Compact session row in the session list. + * Shows title, message count, and time ago. + * Supports right-click context menu for pane management. + */ + +import { useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { useStore } from '@renderer/store'; +import { formatDistanceToNowStrict } from 'date-fns'; +import { MessageSquare, Pin } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { OngoingIndicator } from '../common/OngoingIndicator'; + +import { SessionContextMenu } from './SessionContextMenu'; + +import type { Session } from '@renderer/types/data'; + +interface SessionItemProps { + session: Session; + isActive?: boolean; + isPinned?: boolean; +} + +/** + * Format time distance in short form (e.g., "4m", "2h", "1d") + */ +function formatShortTime(date: Date): string { + const distance = formatDistanceToNowStrict(date, { addSuffix: false }); + return distance + .replace(' seconds', 's') + .replace(' second', 's') + .replace(' minutes', 'm') + .replace(' minute', 'm') + .replace(' hours', 'h') + .replace(' hour', 'h') + .replace(' days', 'd') + .replace(' day', 'd') + .replace(' weeks', 'w') + .replace(' week', 'w') + .replace(' months', 'mo') + .replace(' month', 'mo') + .replace(' years', 'y') + .replace(' year', 'y'); +} + +export const SessionItem = ({ + session, + isActive, + isPinned, +}: Readonly): React.JSX.Element => { + const { openTab, activeProjectId, selectSession, paneCount, splitPane, togglePinSession } = + useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + })) + ); + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; + + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: session.firstMessage?.slice(0, 50) ?? 'Session', + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } + ); + + selectSession(session.id); + }; + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const sessionLabel = session.firstMessage?.slice(0, 50) ?? 'Session'; + + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { replaceActiveTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); + + // Height must match SESSION_HEIGHT (48px) in DateGroupedSessions.tsx for virtual scroll + return ( + <> + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + />, + document.body + )} + + ); +}; diff --git a/src/renderer/constants/cssVariables.ts b/src/renderer/constants/cssVariables.ts new file mode 100644 index 00000000..eec8bf61 --- /dev/null +++ b/src/renderer/constants/cssVariables.ts @@ -0,0 +1,223 @@ +/** + * CSS Variable Constants + * + * Centralized CSS variable strings to avoid duplication across components. + * These are used with inline styles for theme-aware styling. + */ + +// ============================================================================= +// Text Colors +// ============================================================================= + +/** Muted text color for less important content */ +export const COLOR_TEXT_MUTED = 'var(--color-text-muted)'; + +/** Secondary text color for supporting content */ +export const COLOR_TEXT_SECONDARY = 'var(--color-text-secondary)'; + +/** Primary text color */ +export const COLOR_TEXT = 'var(--color-text)'; + +// ============================================================================= +// Prose/Typography Colors (for markdown rendering) +// ============================================================================= + +/** Prose body text color */ +export const PROSE_BODY = 'var(--prose-body)'; + +/** Prose heading color */ +export const PROSE_HEADING = 'var(--prose-heading)'; + +/** Prose muted text color */ +export const PROSE_MUTED = 'var(--prose-muted)'; + +/** Prose link color */ +export const PROSE_LINK = 'var(--prose-link)'; + +/** Prose inline code background */ +export const PROSE_CODE_BG = 'var(--prose-code-bg)'; + +/** Prose inline code text color */ +export const PROSE_CODE_TEXT = 'var(--prose-code-text)'; + +/** Prose code block background */ +export const PROSE_PRE_BG = 'var(--prose-pre-bg)'; + +/** Prose code block border */ +export const PROSE_PRE_BORDER = 'var(--prose-pre-border)'; + +/** Prose blockquote border color */ +export const PROSE_BLOCKQUOTE_BORDER = 'var(--prose-blockquote-border)'; + +/** Prose table border color */ +export const PROSE_TABLE_BORDER = 'var(--prose-table-border)'; + +/** Prose table header background */ +export const PROSE_TABLE_HEADER_BG = 'var(--prose-table-header-bg)'; + +// ============================================================================= +// Surface Colors +// ============================================================================= + +/** Raised surface background */ +export const COLOR_SURFACE_RAISED = 'var(--color-surface-raised)'; + +/** Overlay surface background */ +export const COLOR_SURFACE_OVERLAY = 'var(--color-surface-overlay)'; + +/** Base surface background */ +export const COLOR_SURFACE = 'var(--color-surface)'; + +// ============================================================================= +// Border Colors +// ============================================================================= + +/** Standard border color */ +export const COLOR_BORDER = 'var(--color-border)'; + +/** Subtle border color */ +export const COLOR_BORDER_SUBTLE = 'var(--color-border-subtle)'; + +// ============================================================================= +// Tool Item Colors (for expandable items in chat) +// ============================================================================= + +/** Tool item muted color */ +export const TOOL_ITEM_MUTED = 'var(--tool-item-muted)'; + +// ============================================================================= +// Code Block Colors +// ============================================================================= + +/** Code block background */ +export const CODE_BG = 'var(--code-bg)'; + +/** Code block border */ +export const CODE_BORDER = 'var(--code-border)'; + +/** Code block header background */ +export const CODE_HEADER_BG = 'var(--code-header-bg)'; + +/** Code filename color */ +export const CODE_FILENAME = 'var(--code-filename)'; + +/** Code line number color */ +export const CODE_LINE_NUMBER = 'var(--code-line-number)'; + +// ============================================================================= +// Diff Colors +// ============================================================================= + +/** Diff removed line background */ +export const DIFF_REMOVED_BG = 'var(--diff-removed-bg)'; + +/** Diff removed line text color */ +export const DIFF_REMOVED_TEXT = 'var(--diff-removed-text)'; + +/** Diff removed line border */ +export const DIFF_REMOVED_BORDER = 'var(--diff-removed-border)'; + +/** Diff added line background */ +export const DIFF_ADDED_BG = 'var(--diff-added-bg)'; + +/** Diff added line text color */ +export const DIFF_ADDED_TEXT = 'var(--diff-added-text)'; + +/** Diff added line border */ +export const DIFF_ADDED_BORDER = 'var(--diff-added-border)'; + +// ============================================================================= +// Tool Call/Result Colors +// ============================================================================= + +/** Tool call background */ +export const TOOL_CALL_BG = 'var(--tool-call-bg)'; + +/** Tool call border */ +export const TOOL_CALL_BORDER = 'var(--tool-call-border)'; + +/** Tool call text color */ +export const TOOL_CALL_TEXT = 'var(--tool-call-text)'; + +// ============================================================================= +// Tag/Badge Colors +// ============================================================================= + +/** Tag background */ +export const TAG_BG = 'var(--tag-bg)'; + +/** Tag text color */ +export const TAG_TEXT = 'var(--tag-text)'; + +/** Tag border */ +export const TAG_BORDER = 'var(--tag-border)'; + +// ============================================================================= +// Worktree Badge Colors (hardcoded hex values) +// ============================================================================= + +/** Muted zinc badge background */ +export const WORKTREE_BADGE_BG = 'rgba(161, 161, 170, 0.15)'; + +/** Muted zinc badge text color */ +export const WORKTREE_BADGE_TEXT = '#a1a1aa'; + +// ============================================================================= +// Card/Subagent Styling (theme-aware) +// ============================================================================= + +/** Card background */ +export const CARD_BG = 'var(--card-bg)'; + +/** Card border color */ +const CARD_BORDER = 'var(--card-border)'; + +/** Card border style */ +export const CARD_BORDER_STYLE = `1px solid ${CARD_BORDER}`; + +/** Card header background */ +export const CARD_HEADER_BG = 'var(--card-header-bg)'; + +/** Card header hover background */ +export const CARD_HEADER_HOVER = 'var(--card-header-hover)'; + +/** Card muted icon color */ +export const CARD_ICON_MUTED = 'var(--card-icon-muted)'; + +/** Card light text */ +export const CARD_TEXT_LIGHT = 'var(--card-text-light)'; + +/** Card lighter text */ +export const CARD_TEXT_LIGHTER = 'var(--card-text-lighter)'; + +/** Card separator color */ +export const CARD_SEPARATOR = 'var(--card-separator)'; + +// ============================================================================= +// Form/Input Colors (Tailwind classes for select/input options) +// ============================================================================= + +/** Background for select options (theme-aware) */ +export const SELECT_OPTION_BG = 'bg-surface'; + +// ============================================================================= +// Form State Classes (Tailwind classes for form states) +// ============================================================================= + +/** Cursor pointer class */ +const CURSOR_POINTER = 'cursor-pointer'; + +/** Cursor not allowed with opacity for disabled state */ +const CURSOR_DISABLED = 'cursor-not-allowed opacity-50'; + +/** + * Helper to get cursor class based on disabled state + */ +export const getCursorClass = (disabled: boolean): string => + disabled ? CURSOR_DISABLED : CURSOR_POINTER; + +/** + * Base className for select inputs in settings forms (theme-aware) + */ +export const SELECT_INPUT_BASE = + 'rounded border border-border bg-transparent px-2 py-1 text-sm text-text focus:border-transparent focus:outline-none focus:ring-1 focus:ring-indigo-500'; diff --git a/src/renderer/constants/layout.ts b/src/renderer/constants/layout.ts new file mode 100644 index 00000000..367ffe5f --- /dev/null +++ b/src/renderer/constants/layout.ts @@ -0,0 +1,9 @@ +/** + * Shared layout constants for consistent header heights. + * Used by both SidebarHeader and TabBar to ensure alignment. + */ + +export { getTrafficLightPaddingForZoom, HEADER_ROW1_HEIGHT } from '@shared/constants'; + +/** Height of the secondary header row (worktree selector) */ +export const HEADER_ROW2_HEIGHT = 30; // px diff --git a/src/renderer/constants/teamColors.ts b/src/renderer/constants/teamColors.ts new file mode 100644 index 00000000..96c4177c --- /dev/null +++ b/src/renderer/constants/teamColors.ts @@ -0,0 +1,51 @@ +/** + * Team Color Constants + * + * Shared color definitions for team member visualization. + * Used by TeammateMessageItem and SubagentItem when displaying team members. + */ + +export interface TeamColorSet { + /** Border accent color */ + border: string; + /** Badge background (semi-transparent) */ + badge: string; + /** Text color for labels */ + text: string; +} + +const TEAMMATE_COLORS: Record = { + blue: { border: '#3b82f6', badge: 'rgba(59, 130, 246, 0.15)', text: '#60a5fa' }, + green: { border: '#22c55e', badge: 'rgba(34, 197, 94, 0.15)', text: '#4ade80' }, + red: { border: '#ef4444', badge: 'rgba(239, 68, 68, 0.15)', text: '#f87171' }, + yellow: { border: '#eab308', badge: 'rgba(234, 179, 8, 0.15)', text: '#facc15' }, + purple: { border: '#a855f7', badge: 'rgba(168, 85, 247, 0.15)', text: '#c084fc' }, + cyan: { border: '#06b6d4', badge: 'rgba(6, 182, 212, 0.15)', text: '#22d3ee' }, + orange: { border: '#f97316', badge: 'rgba(249, 115, 22, 0.15)', text: '#fb923c' }, + pink: { border: '#ec4899', badge: 'rgba(236, 72, 153, 0.15)', text: '#f472b6' }, +}; + +const DEFAULT_COLOR: TeamColorSet = TEAMMATE_COLORS.blue; + +/** + * Get a TeamColorSet from a color name or hex string. + * Falls back to blue if unrecognized. + */ +export function getTeamColorSet(colorName: string): TeamColorSet { + if (!colorName) return DEFAULT_COLOR; + + // Check named colors + const named = TEAMMATE_COLORS[colorName.toLowerCase()]; + if (named) return named; + + // If it's a hex color, generate a set from it + if (colorName.startsWith('#')) { + return { + border: colorName, + badge: `${colorName}26`, + text: colorName, + }; + } + + return DEFAULT_COLOR; +} diff --git a/src/renderer/contexts/TabUIContext.tsx b/src/renderer/contexts/TabUIContext.tsx new file mode 100644 index 00000000..b99aa4ea --- /dev/null +++ b/src/renderer/contexts/TabUIContext.tsx @@ -0,0 +1,51 @@ +/** + * TabUIContext - Provides the current tab's ID to all descendant components. + * + * This context enables per-tab UI state isolation. Components use the tabId + * from this context to access their tab-specific state from the store. + * + * Usage: + * ```tsx + * // In TabbedLayout (provider): + * + * + * + * + * // In any descendant component (consumer): + * const tabId = useTabId(); + * const { expandedIds, toggleExpansion } = useTabUI(); + * ``` + */ + +import { createContext, type ReactNode } from 'react'; + +// ============================================================================= +// Context Definition +// ============================================================================= + +interface TabUIContextValue { + /** The unique ID of the current tab */ + tabId: string; +} + +const TabUIContext = createContext(null); + +export { TabUIContext }; + +// ============================================================================= +// Provider Component +// ============================================================================= + +interface TabUIProviderProps { + /** The tab ID to provide to descendants */ + tabId: string; + children: ReactNode; +} + +/** + * Provides the tab ID to all descendant components. + * Wrap each tab's content with this provider. + */ +export const TabUIProvider = ({ tabId, children }: TabUIProviderProps): JSX.Element => { + return {children}; +}; diff --git a/src/renderer/contexts/useTabUIContext.ts b/src/renderer/contexts/useTabUIContext.ts new file mode 100644 index 00000000..102adba2 --- /dev/null +++ b/src/renderer/contexts/useTabUIContext.ts @@ -0,0 +1,18 @@ +/** + * Hooks for accessing the TabUIContext. + * + * These hooks are in a separate file from the provider to support React Fast Refresh. + */ + +import { useContext } from 'react'; + +import { TabUIContext } from './TabUIContext'; + +/** + * Returns the current tab's ID, or null if not within a TabUIProvider. + * Use this for components that may be rendered outside of a tab context. + */ +export function useTabIdOptional(): string | null { + const context = useContext(TabUIContext); + return context?.tabId ?? null; +} diff --git a/src/renderer/favicon.png b/src/renderer/favicon.png new file mode 100644 index 00000000..8fc2bf50 Binary files /dev/null and b/src/renderer/favicon.png differ diff --git a/src/renderer/hooks/navigation/utils.ts b/src/renderer/hooks/navigation/utils.ts new file mode 100644 index 00000000..c5ce035f --- /dev/null +++ b/src/renderer/hooks/navigation/utils.ts @@ -0,0 +1,263 @@ +/** + * Shared navigation utilities for scroll/highlight orchestration. + * + * These helpers are used by useTabNavigationController and can be + * reused by other navigation-related hooks. + */ + +import type { ChatItem } from '@renderer/types/groups'; + +// ============================================================================= +// Target Resolution +// ============================================================================= + +/** + * Find the AI group that contains or is closest to the given error timestamp. + */ +export function findAIGroupByTimestamp(items: ChatItem[], errorTimestamp: number): string | null { + if (items.length === 0) return null; + + let bestGroupId: string | null = null; + let bestTimeDiff = Infinity; + + for (const item of items) { + if (item.type !== 'ai') continue; + + const group = item.group; + const startMs = group.startTime.getTime(); + const endMs = group.endTime.getTime(); + + // Check if error timestamp is within this group's time range + if (errorTimestamp >= startMs && errorTimestamp <= endMs) { + return group.id; // Exact match + } + + // Track closest group for fallback + const startDiff = Math.abs(errorTimestamp - startMs); + const endDiff = Math.abs(errorTimestamp - endMs); + const minDiff = Math.min(startDiff, endDiff); + + if (minDiff < bestTimeDiff) { + bestTimeDiff = minDiff; + bestGroupId = group.id; + } + } + + return bestGroupId; +} + +/** + * Find the chat item (any type) that contains or is closest to the given timestamp. + * Returns the item's group ID and type. + */ +export function findChatItemByTimestamp( + items: ChatItem[], + targetTimestamp: number +): { groupId: string; type: 'user' | 'system' | 'ai' | 'compact' } | null { + if (items.length === 0) return null; + + let bestMatch: { groupId: string; type: 'user' | 'system' | 'ai' | 'compact' } | null = null; + let bestTimeDiff = Infinity; + + for (const item of items) { + let itemTimestamp: number; + + if (item.type === 'user') { + itemTimestamp = item.group.timestamp.getTime(); + } else if (item.type === 'system') { + itemTimestamp = item.group.timestamp.getTime(); + } else if (item.type === 'ai') { + const startMs = item.group.startTime.getTime(); + const endMs = item.group.endTime.getTime(); + if (targetTimestamp >= startMs && targetTimestamp <= endMs) { + return { groupId: item.group.id, type: 'ai' }; + } + itemTimestamp = startMs; + } else if (item.type === 'compact') { + itemTimestamp = item.group.timestamp.getTime(); + } else { + continue; + } + + const timeDiff = Math.abs(targetTimestamp - itemTimestamp); + if (timeDiff < bestTimeDiff) { + bestTimeDiff = timeDiff; + bestMatch = { groupId: item.group.id, type: item.type }; + } + } + + return bestMatch; +} + +// ============================================================================= +// Subagent Group Resolution +// ============================================================================= + +/** + * Find the AI group that contains a subagent with the given ID. + * Looks through each AI group's processes array for a matching process ID. + */ +export function findAIGroupBySubagentId(items: ChatItem[], subagentId: string): string | null { + for (const item of items) { + if (item.type !== 'ai') continue; + if (item.group.processes.some((p) => p.id === subagentId)) { + return item.group.id; + } + } + return null; +} + +// ============================================================================= +// DOM Readiness Helpers +// ============================================================================= + +/** + * Wait for element size to stabilize using ResizeObserver. + * More reliable than timer-based approaches because it detects actual DOM changes. + */ +export function waitForElementStability( + element: HTMLElement, + timeoutMs = 250, + stableFrames = 2 +): Promise { + return new Promise((resolve) => { + let lastSize = { width: 0, height: 0 }; + let stableCount = 0; + let resolved = false; + + const observer = new ResizeObserver((entries) => { + if (resolved) return; + const entry = entries[0]; + if (!entry) return; + + const currentSize = { + width: Math.round(entry.contentRect.width), + height: Math.round(entry.contentRect.height), + }; + + if (currentSize.width === lastSize.width && currentSize.height === lastSize.height) { + stableCount++; + if (stableCount >= stableFrames) { + resolved = true; + observer.disconnect(); + resolve(); + } + } else { + stableCount = 0; + lastSize = currentSize; + } + }); + + observer.observe(element); + + // Initial size reading to bootstrap comparison + const rect = element.getBoundingClientRect(); + lastSize = { width: Math.round(rect.width), height: Math.round(rect.height) }; + + // Timeout fallback to prevent infinite waiting + setTimeout(() => { + if (!resolved) { + resolved = true; + observer.disconnect(); + resolve(); + } + }, timeoutMs); + }); +} + +/** + * Wait for scroll animation to complete. + * Detects completion by monitoring when scrollTop stops changing. + */ +export function waitForScrollEnd(container: HTMLElement, timeoutMs = 400): Promise { + return new Promise((resolve) => { + let lastScrollTop = container.scrollTop; + let stableCount = 0; + let rafId: number | undefined; + let resolved = false; + + const checkScroll = (): void => { + if (resolved) return; + + const currentScrollTop = container.scrollTop; + + if (Math.abs(currentScrollTop - lastScrollTop) < 1) { + stableCount++; + if (stableCount >= 3) { + resolved = true; + if (rafId !== undefined) cancelAnimationFrame(rafId); + resolve(); + return; + } + } else { + stableCount = 0; + lastScrollTop = currentScrollTop; + } + + rafId = requestAnimationFrame(checkScroll); + }; + + rafId = requestAnimationFrame(checkScroll); + + setTimeout(() => { + if (!resolved) { + resolved = true; + if (rafId !== undefined) cancelAnimationFrame(rafId); + resolve(); + } + }, timeoutMs); + }); +} + +// ============================================================================= +// Visibility and Scroll Calculation +// ============================================================================= + +/** + * Calculate the scrollTop value to center an element in the visible area + * of a scroll container, accounting for sticky offset. + */ +export function calculateCenteredScrollTop( + element: HTMLElement, + container: HTMLElement, + stickyOffset: number +): number { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + const visibleHeight = containerRect.height - stickyOffset; + const elementCenterRelativeToContainer = + elementRect.top - containerRect.top + container.scrollTop + elementRect.height / 2; + const targetScrollTop = elementCenterRelativeToContainer - visibleHeight / 2 - stickyOffset; + + return Math.max(0, targetScrollTop); +} + +/** + * Find the current search result element within a container. + * When item identity is provided, resolves the exact current match for that item/index. + */ +export function findCurrentSearchResultInContainer( + container: HTMLElement | null | undefined, + itemId?: string, + matchIndexInItem?: number +): Element | null { + if (!container) return null; + + const currentResults = container.querySelectorAll('[data-search-result="current"]'); + if (itemId !== undefined && matchIndexInItem !== undefined) { + for (const candidate of currentResults) { + if (!(candidate instanceof HTMLElement)) { + continue; + } + if ( + candidate.dataset.searchItemId === itemId && + candidate.dataset.searchMatchIndex === String(matchIndexInItem) + ) { + return candidate; + } + } + } + + return currentResults[0] ?? null; +} diff --git a/src/renderer/hooks/useAutoScrollBottom.ts b/src/renderer/hooks/useAutoScrollBottom.ts new file mode 100644 index 00000000..6d303931 --- /dev/null +++ b/src/renderer/hooks/useAutoScrollBottom.ts @@ -0,0 +1,263 @@ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Options for the auto-scroll hook. + */ +interface UseAutoScrollBottomOptions { + /** + * Threshold in pixels from bottom to consider "at bottom". + * Default: 100px (generous threshold for better UX) + */ + threshold?: number; + + /** + * Smooth scroll duration in milliseconds. + * Default: 300ms + */ + smoothDuration?: number; + + /** + * Whether auto-scroll is enabled. + * Default: true + */ + enabled?: boolean; + + /** + * Whether auto-scroll is temporarily disabled (e.g., during navigation). + * Unlike enabled, this is for transient disabling during specific operations. + * Default: false + */ + disabled?: boolean; + + /** + * Optional external scroll container ref. If provided, the hook will use this + * ref instead of creating its own. Useful when the ref needs to be shared + * with other hooks (e.g., navigation coordinator). + */ + externalRef?: React.RefObject; + + /** + * When this value changes, reset isAtBottom state to true. + * Use for tab/session changes to ensure new content scrolls to bottom. + */ + resetKey?: string | null; +} + +/** + * Return type for the auto-scroll hook. + */ +interface UseAutoScrollBottomReturn { + /** + * Ref to attach to the scroll container element. + */ + scrollContainerRef: React.RefObject; + + /** + * Get whether the user is currently at the bottom of the scroll container. + * Returns a function to avoid accessing ref.current during render. + */ + getIsAtBottom: () => boolean; + + /** + * Manually scroll to bottom with smooth animation. + */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + + /** + * Check and update the isAtBottom state. + * Call this after content changes if needed. + */ + checkIsAtBottom: () => boolean; +} + +export function isNearBottom( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + threshold: number +): boolean { + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + return distanceFromBottom <= threshold; +} + +/** + * Custom hook for managing auto-scroll-to-bottom behavior in chat-like interfaces. + * + * Features: + * - Tracks whether user is at the bottom of the scroll container + * - Automatically scrolls to bottom when content changes (if user was at bottom) + * - Smooth scrolling animation + * - Respects user's scroll position (doesn't force scroll if user scrolled up) + * + * @param dependencies - Array of dependencies that trigger scroll check (e.g., conversation items) + * @param options - Configuration options + * @returns Scroll management utilities + * + * @example + * ```tsx + * const { scrollContainerRef, isAtBottom, scrollToBottom } = useAutoScrollBottom( + * [conversation?.items.length], + * { threshold: 100 } + * ); + * + * return ( + *
    + * {items.map(renderItem)} + *
    + * ); + * ``` + */ +export function useAutoScrollBottom( + dependencies: unknown[], + options: UseAutoScrollBottomOptions = {} +): UseAutoScrollBottomReturn { + const { + threshold = 100, + smoothDuration = 300, + enabled = true, + disabled = false, + externalRef, + resetKey, + } = options; + + // Use external ref if provided, otherwise create our own + const internalRef = useRef(null); + const scrollContainerRef = externalRef ?? internalRef; + + const isAtBottomRef = useRef(true); // Start assuming at bottom + const wasAtBottomBeforeUpdateRef = useRef(true); + const isScrollingRef = useRef(false); + // Track disabled state in ref for checking inside RAF callbacks + const disabledRef = useRef(disabled); + // Track resetKey to detect changes + const prevResetKeyRef = useRef(resetKey); + + /** + * Check if the scroll container is at the bottom. + */ + const checkIsAtBottom = useCallback((): boolean => { + const container = scrollContainerRef.current; + if (!container) return true; + + const { scrollTop, scrollHeight, clientHeight } = container; + return isNearBottom(scrollTop, scrollHeight, clientHeight, threshold); + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders + }, [threshold]); + + /** + * Scroll to bottom with smooth animation. + */ + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + const container = scrollContainerRef.current; + if (!container) return; + + // Prevent scroll event handler from updating isAtBottom during programmatic scroll + isScrollingRef.current = true; + + const targetScrollTop = container.scrollHeight - container.clientHeight; + + if (behavior === 'smooth') { + // Use native smooth scrolling + container.scrollTo({ + top: targetScrollTop, + behavior: 'smooth', + }); + + // Reset flag after animation completes + setTimeout(() => { + isScrollingRef.current = false; + isAtBottomRef.current = true; + }, smoothDuration); + } else { + container.scrollTop = targetScrollTop; + isScrollingRef.current = false; + isAtBottomRef.current = true; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders + [smoothDuration] + ); + + /** + * Handle scroll events to track isAtBottom state. + */ + const handleScroll = useCallback(() => { + // Ignore scroll events during programmatic scrolling + if (isScrollingRef.current) return; + + isAtBottomRef.current = checkIsAtBottom(); + }, [checkIsAtBottom]); + + /** + * Set up scroll event listener. + */ + useEffect(() => { + const container = scrollContainerRef.current; + if (!container) return; + + container.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + container.removeEventListener('scroll', handleScroll); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- scrollContainerRef is a ref, stable across renders + }, [handleScroll]); + + /** + * Before content updates, remember if we were at bottom. + */ + useEffect(() => { + wasAtBottomBeforeUpdateRef.current = isAtBottomRef.current; + }); + + // Keep disabledRef in sync with disabled prop + useEffect(() => { + disabledRef.current = disabled; + }, [disabled]); + + // Reset isAtBottom state when resetKey changes (e.g., tab/session switch) + // This ensures new content will auto-scroll to bottom + useEffect(() => { + if (resetKey !== prevResetKeyRef.current) { + isAtBottomRef.current = true; + wasAtBottomBeforeUpdateRef.current = true; + prevResetKeyRef.current = resetKey; + } + }, [resetKey]); + + /** + * After content updates (dependencies change), scroll to bottom if we were at bottom. + */ + useEffect(() => { + // Skip if disabled (e.g., during navigation) or not enabled + if (!enabled || disabled) return; + + // Use requestAnimationFrame to ensure DOM has updated + requestAnimationFrame(() => { + // Re-check disabled state inside RAF - it might have changed between effect and callback + // This prevents auto-scroll from firing if navigation started after the effect ran + if (disabledRef.current) return; + + // Only auto-scroll if user was at bottom before the update + if (wasAtBottomBeforeUpdateRef.current) { + scrollToBottom('smooth'); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- Dynamic dependencies array is intentional design + }, [...dependencies, enabled, disabled, scrollToBottom]); + + /** + * Getter function for isAtBottom to avoid accessing ref.current during render. + */ + const getIsAtBottom = useCallback((): boolean => { + return isAtBottomRef.current; + }, []); + + return { + scrollContainerRef, + getIsAtBottom, + scrollToBottom, + checkIsAtBottom, + }; +} diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..a095aebe --- /dev/null +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,284 @@ +/** + * useKeyboardShortcuts - Global keyboard shortcut handler + * Handles app-wide keyboard shortcuts for tab management, navigation, and pane management. + * + * Pane-scoped: Tab cycling (Ctrl+Tab, Cmd+1-9, Cmd+Shift+[/]) operates within the focused pane. + * Pane shortcuts: Cmd+Option+1-4 (focus pane), Cmd+\ (split right), Cmd+Option+W (close pane). + */ + +import { useEffect } from 'react'; + +import { createLogger } from '@shared/utils/logger'; +import { useShallow } from 'zustand/react/shallow'; + +import { useStore } from '../store'; + +const logger = createLogger('Hook:KeyboardShortcuts'); + +export function useKeyboardShortcuts(): void { + const { + openTabs, + activeTabId, + selectedTabIds, + openDashboard, + closeTab, + closeAllTabs, + closeTabs, + setActiveTab, + showSearch, + getActiveTab, + selectedProjectId, + selectedSessionId, + fetchSessionDetail, + fetchSessions, + openCommandPalette, + openSettingsTab, + toggleSidebar, + paneLayout, + focusPane, + splitPane, + closePane, + } = useStore( + useShallow((s) => ({ + openTabs: s.openTabs, + activeTabId: s.activeTabId, + selectedTabIds: s.selectedTabIds, + openDashboard: s.openDashboard, + closeTab: s.closeTab, + closeAllTabs: s.closeAllTabs, + closeTabs: s.closeTabs, + setActiveTab: s.setActiveTab, + showSearch: s.showSearch, + getActiveTab: s.getActiveTab, + selectedProjectId: s.selectedProjectId, + selectedSessionId: s.selectedSessionId, + fetchSessionDetail: s.fetchSessionDetail, + fetchSessions: s.fetchSessions, + openCommandPalette: s.openCommandPalette, + openSettingsTab: s.openSettingsTab, + toggleSidebar: s.toggleSidebar, + paneLayout: s.paneLayout, + focusPane: s.focusPane, + splitPane: s.splitPane, + closePane: s.closePane, + })) + ); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent): void { + // Check if Cmd (macOS) or Ctrl (Windows/Linux) is pressed + const isMod = event.metaKey || event.ctrlKey; + + // Ctrl+Tab / Ctrl+Shift+Tab: Switch tabs within focused pane (universal shortcut) + if (event.ctrlKey && event.key === 'Tab') { + event.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); + + if (event.shiftKey) { + // Ctrl+Shift+Tab: Previous tab (with wrap-around) + if (currentIndex > 0) { + setActiveTab(openTabs[currentIndex - 1].id); + } else if (openTabs.length > 0) { + // Wrap to last tab + setActiveTab(openTabs[openTabs.length - 1].id); + } + } else { + // Ctrl+Tab: Next tab (with wrap-around) + if (currentIndex !== -1 && currentIndex < openTabs.length - 1) { + setActiveTab(openTabs[currentIndex + 1].id); + } else if (openTabs.length > 0) { + // Wrap to first tab + setActiveTab(openTabs[0].id); + } + } + return; + } + + if (!isMod) return; + + // --- Pane management shortcuts (Cmd+Option) --- + + // Cmd+Option+1-4: Focus pane by index + if (event.altKey && !event.shiftKey) { + const numKey = parseInt(event.key); + if (numKey >= 1 && numKey <= 4) { + event.preventDefault(); + const targetPane = paneLayout.panes[numKey - 1]; + if (targetPane) { + focusPane(targetPane.id); + } + return; + } + + // Cmd+Option+W: Close current pane + if (event.key === 'w') { + event.preventDefault(); + if (paneLayout.panes.length > 1) { + closePane(paneLayout.focusedPaneId); + } + return; + } + } + + // Cmd+\: Split right with current tab + if (event.key === '\\' && !event.altKey && !event.shiftKey) { + event.preventDefault(); + if (activeTabId) { + splitPane(paneLayout.focusedPaneId, activeTabId, 'right'); + } + return; + } + + // Cmd+T: New tab (Dashboard) + if (event.key === 't') { + event.preventDefault(); + openDashboard(); + return; + } + + // Cmd+Shift+W: Close all tabs + if (event.key === 'w' && event.shiftKey && !event.altKey) { + event.preventDefault(); + closeAllTabs(); + return; + } + + // Cmd+W: Close selected tabs (if multi-selected) or active tab + if (event.key === 'w' && !event.altKey) { + event.preventDefault(); + if (selectedTabIds.length > 0) { + closeTabs(selectedTabIds); + } else if (activeTabId) { + closeTab(activeTabId); + } + return; + } + + // Cmd+[1-9]: Switch to tab by index within focused pane + const numKey = parseInt(event.key); + if (numKey >= 1 && numKey <= 9 && !event.altKey) { + event.preventDefault(); + const targetTab = openTabs[numKey - 1]; + if (targetTab) { + setActiveTab(targetTab.id); + } + return; + } + + // Cmd+Shift+]: Next tab within focused pane + if (event.key === ']' && event.shiftKey) { + event.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); + if (currentIndex !== -1 && currentIndex < openTabs.length - 1) { + setActiveTab(openTabs[currentIndex + 1].id); + } + return; + } + + // Cmd+Shift+[: Previous tab within focused pane + if (event.key === '[' && event.shiftKey) { + event.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); + if (currentIndex > 0) { + setActiveTab(openTabs[currentIndex - 1].id); + } + return; + } + + // Cmd+Option+Right: Next tab (browser-style) within focused pane + if (event.key === 'ArrowRight' && event.altKey) { + event.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); + if (currentIndex !== -1 && currentIndex < openTabs.length - 1) { + setActiveTab(openTabs[currentIndex + 1].id); + } + return; + } + + // Cmd+Option+Left: Previous tab (browser-style) within focused pane + if (event.key === 'ArrowLeft' && event.altKey) { + event.preventDefault(); + const currentIndex = openTabs.findIndex((t) => t.id === activeTabId); + if (currentIndex > 0) { + setActiveTab(openTabs[currentIndex - 1].id); + } + return; + } + + // Cmd+K: Open command palette for global search + if (event.key === 'k') { + event.preventDefault(); + openCommandPalette(); + return; + } + + // Cmd+,: Open settings (standard macOS shortcut) + if (event.key === ',') { + event.preventDefault(); + openSettingsTab(); + return; + } + + // Cmd+F: Find in session + if (event.key === 'f') { + event.preventDefault(); + const activeTab = getActiveTab(); + // Only enable search in session views, not dashboard + if (activeTab?.type === 'session') { + showSearch(); + } + return; + } + + // Cmd+O: Open project (placeholder for future implementation) + if (event.key === 'o') { + event.preventDefault(); + logger.debug('Open project shortcut triggered (not yet implemented)'); + return; + } + + // Cmd+R: Refresh current session and sidebar session list + if (event.key === 'r') { + event.preventDefault(); + if (selectedProjectId && selectedSessionId) { + void Promise.all([ + fetchSessionDetail(selectedProjectId, selectedSessionId), + fetchSessions(selectedProjectId), + ]); + } + return; + } + + // Cmd+B: Toggle sidebar + if (event.key === 'b') { + event.preventDefault(); + toggleSidebar(); + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [ + openTabs, + activeTabId, + selectedTabIds, + openDashboard, + closeTab, + closeAllTabs, + closeTabs, + setActiveTab, + showSearch, + getActiveTab, + selectedProjectId, + selectedSessionId, + fetchSessionDetail, + fetchSessions, + openCommandPalette, + openSettingsTab, + toggleSidebar, + paneLayout, + focusPane, + splitPane, + closePane, + ]); +} diff --git a/src/renderer/hooks/useTabNavigationController.ts b/src/renderer/hooks/useTabNavigationController.ts new file mode 100644 index 00000000..65274fea5 --- /dev/null +++ b/src/renderer/hooks/useTabNavigationController.ts @@ -0,0 +1,524 @@ +/** + * Unified Tab Navigation Controller + * + * Single active-tab controller that replaces useNavigationCoordinator + useSearchContextNavigation. + * Manages the complete lifecycle of navigation requests with proper sequencing: + * + * 1. Receive pending navigation request from tab state + * 2. Ignore if tab is not active (prevents cross-tab races) + * 3. Wait for content to load + * 4. Expand target group and item + * 5. Wait for DOM to stabilize + * 6. Scroll to target + * 7. Set highlight (red for error, yellow for search) + * 8. Clear highlight after timeout + * 9. Consume the navigation request (mark as processed) + * + * The nonce-based request model ensures: + * - Repeated clicks create new navigations + * - Tab switches don't re-trigger stale requests + * - Auto-scroll is suppressed during navigation + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { isErrorPayload, isSearchPayload } from '@renderer/types/tabs'; + +import { + calculateCenteredScrollTop, + findAIGroupBySubagentId, + findAIGroupByTimestamp, + findChatItemByTimestamp, + findCurrentSearchResultInContainer, + waitForElementStability, + waitForScrollEnd, +} from './navigation/utils'; + +import type { SessionConversation } from '@renderer/types/groups'; +import type { TabNavigationRequest } from '@renderer/types/tabs'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +// ============================================================================= +// Types +// ============================================================================= + +export type NavigationPhase = + | 'idle' // No navigation in progress + | 'pending' // Navigation requested, waiting for content + | 'expanding' // Expanding target group/item + | 'scrolling' // Scrolling to target + | 'highlighting' // Showing highlight ring + | 'complete'; // Navigation done, waiting to clear highlight + +interface UseTabNavigationControllerOptions { + /** Whether this tab instance is currently the active tab */ + isActiveTab: boolean; + /** Pending navigation request from tab state (undefined = no request) */ + pendingNavigation?: TabNavigationRequest; + /** Conversation data (null while loading) */ + conversation: SessionConversation | null; + /** Whether conversation is currently loading */ + conversationLoading: boolean; + /** Function to consume (mark as processed) a navigation request */ + consumeTabNavigation: (tabId: string, requestId: string) => void; + /** Tab ID for consuming navigation */ + tabId: string; + /** Refs to AI group DOM elements */ + aiGroupRefs: React.MutableRefObject>; + /** Refs to individual chat item DOM elements */ + chatItemRefs: React.MutableRefObject>; + /** Refs to individual tool item DOM elements */ + toolItemRefs: React.MutableRefObject>; + /** Function to expand an AI group (per-tab state) */ + expandAIGroup: (groupId: string) => void; + /** Ref to scroll container */ + scrollContainerRef: React.RefObject; + /** Height of sticky elements at top of scroll container */ + stickyOffset?: number; + /** Optional helper to ensure a target group is mounted (e.g., virtualized lists) */ + ensureGroupVisible?: (groupId: string) => Promise | void; + /** Function to expand a subagent trace (persists in per-tab state) */ + expandSubagentTrace: (subagentId: string) => void; + /** Function to set search query in the search bar */ + setSearchQuery: (query: string) => void; + /** Function to select an exact search match by item identity */ + selectSearchMatch: (itemId: string, matchIndexInItem: number) => boolean; + /** Highlight duration in ms (default: 3000) */ + highlightDuration?: number; +} + +interface UseTabNavigationControllerReturn { + /** Current navigation phase */ + phase: NavigationPhase; + /** Currently highlighted group ID */ + highlightedGroupId: string | null; + /** Tool use ID to highlight */ + highlightToolUseId: string | null; + /** Whether this is a search-based highlight (yellow) */ + isSearchHighlight: boolean; + /** Custom highlight color from trigger (undefined = default red) */ + highlightColor: TriggerColor | undefined; + /** Whether auto-scroll should be disabled */ + shouldDisableAutoScroll: boolean; + /** Set highlighted group (for external control, e.g., turn navigation) */ + setHighlightedGroupId: (id: string | null) => void; + /** Handle highlight end (clear highlight) */ + handleHighlightEnd: () => void; +} + +// ============================================================================= +// Hook Implementation +// ============================================================================= + +export function useTabNavigationController( + options: UseTabNavigationControllerOptions +): UseTabNavigationControllerReturn { + const { + isActiveTab, + pendingNavigation, + conversation, + conversationLoading, + consumeTabNavigation, + tabId, + aiGroupRefs, + chatItemRefs, + toolItemRefs, + expandAIGroup, + scrollContainerRef, + stickyOffset = 0, + ensureGroupVisible, + expandSubagentTrace, + setSearchQuery, + selectSearchMatch, + highlightDuration = 3000, + } = options; + + // State + const [phase, setPhase] = useState('idle'); + const [highlightedGroupId, setHighlightedGroupId] = useState(null); + const [currentToolUseId, setCurrentToolUseId] = useState(null); + const [isSearchHighlight, setIsSearchHighlight] = useState(false); + const [highlightColor, setHighlightColor] = useState(undefined); + + // Refs for tracking + const activeRequestIdRef = useRef(null); + const highlightTimerRef = useRef | null>(null); + const abortControllerRef = useRef(null); + const lastFailureAtRef = useRef(0); + + // Clear highlight and reset state + const handleHighlightEnd = useCallback(() => { + setHighlightedGroupId(null); + setCurrentToolUseId(null); + setIsSearchHighlight(false); + setHighlightColor(undefined); + setPhase('idle'); + activeRequestIdRef.current = null; + + if (highlightTimerRef.current) { + clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = null; + } + }, []); + + // Abort any in-progress navigation + const abortNavigation = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + if (highlightTimerRef.current) { + clearTimeout(highlightTimerRef.current); + highlightTimerRef.current = null; + } + }, []); + + // Execute error navigation sequence + const executeErrorNavigation = useCallback( + async (request: TabNavigationRequest, abortSignal: AbortSignal): Promise => { + if (!isErrorPayload(request) || !conversation) return false; + const { errorTimestamp, toolUseId, subagentId } = request.payload; + + const checkAborted = (): boolean => abortSignal.aborted; + + // Find target AI group (subagent-aware lookup first, then timestamp fallback) + let targetGroupId: string | null = null; + if (subagentId) { + targetGroupId = findAIGroupBySubagentId(conversation.items, subagentId); + } + if (!targetGroupId && errorTimestamp > 0) { + targetGroupId = findAIGroupByTimestamp(conversation.items, errorTimestamp); + } + if (!targetGroupId) { + // Fallback: last AI group + const aiItems = conversation.items.filter((item) => item.type === 'ai'); + if (aiItems.length > 0) { + targetGroupId = aiItems[aiItems.length - 1].group.id; + } + } + if (!targetGroupId) return false; + + // Phase 1: Expanding + setPhase('expanding'); + expandAIGroup(targetGroupId); + // Persist subagent trace expansion so it survives highlight clearing + if (subagentId) { + expandSubagentTrace(subagentId); + } + await ensureGroupVisible?.(targetGroupId); + if (checkAborted()) return false; + + // Set highlight early so it's visible even if scroll is imperfect + setHighlightedGroupId(targetGroupId); + setIsSearchHighlight(false); + // Error navigation uses a TriggerColor (preset key or custom hex, defaulting to 'red') + setHighlightColor(request.highlight === 'none' ? undefined : request.highlight); + if (toolUseId) setCurrentToolUseId(toolUseId); + + // Wait for element to exist and stabilize + let element: HTMLElement | undefined; + const elementLookupStart = Date.now(); + while (Date.now() - elementLookupStart < 600) { + element = aiGroupRefs.current.get(targetGroupId); + if (element) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + if (checkAborted()) return false; + await ensureGroupVisible?.(targetGroupId); + } + // If element not found, highlight is already set — return success + if (!element) return true; + await waitForElementStability(element, 250, 2); + if (checkAborted()) return false; + + // Phase 2: Scrolling (best-effort — highlight already set) + setPhase('scrolling'); + + // Wait for tool item ref if needed (longer timeout for subagent cascading expansion) + let toolElement: HTMLElement | undefined; + if (toolUseId) { + // Subagents need more time: AI group expand → display item expand → trace expand → tool render + const toolLookupTimeout = subagentId ? 1200 : 300; + const startTime = Date.now(); + while (Date.now() - startTime < toolLookupTimeout) { + toolElement = toolItemRefs.current.get(toolUseId); + if (toolElement) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + if (checkAborted()) return true; // Highlight already set + } + if (toolElement) { + await waitForElementStability(toolElement, 300, 2); + if (checkAborted()) return true; // Highlight already set + } + } + + // Scroll to target (best-effort) + const targetElement = toolElement ?? element; + const container = scrollContainerRef.current; + if (targetElement && container) { + const targetScrollTop = calculateCenteredScrollTop(targetElement, container, stickyOffset); + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); + await waitForScrollEnd(container, 400); + } + if (checkAborted()) return false; + + // Phase 3: Highlight was set early, just update phase + setPhase('highlighting'); + return true; + }, + [ + conversation, + expandAIGroup, + expandSubagentTrace, + aiGroupRefs, + toolItemRefs, + scrollContainerRef, + stickyOffset, + ensureGroupVisible, + ] + ); + + // Execute search navigation sequence + const executeSearchNavigation = useCallback( + async (request: TabNavigationRequest, abortSignal: AbortSignal): Promise => { + if (!isSearchPayload(request) || !conversation) return false; + const { query, messageTimestamp, targetGroupId, targetMatchIndexInItem } = request.payload; + + const checkAborted = (): boolean => abortSignal.aborted; + + // Find target chat item (prefer exact group ID when provided) + const exactTargetItem = + targetGroupId !== undefined + ? conversation.items.find((item) => item.group.id === targetGroupId) + : undefined; + const targetItem = + exactTargetItem && + (exactTargetItem.type === 'user' || + exactTargetItem.type === 'system' || + exactTargetItem.type === 'ai' || + exactTargetItem.type === 'compact') + ? { groupId: exactTargetItem.group.id, type: exactTargetItem.type } + : findChatItemByTimestamp(conversation.items, messageTimestamp); + if (!targetItem) return false; + + // Phase 1: Expanding + setPhase('expanding'); + setSearchQuery(query); + if (targetGroupId !== undefined && targetMatchIndexInItem !== undefined) { + selectSearchMatch(targetGroupId, targetMatchIndexInItem); + } + setHighlightedGroupId(targetItem.groupId); + setIsSearchHighlight(true); + await ensureGroupVisible?.(targetItem.groupId); + if (checkAborted()) return false; + + // Wait for element to appear + const startedAt = Date.now(); + let targetEl: Element | null = null; + + while (!checkAborted() && Date.now() - startedAt < 600) { + targetEl = findCurrentSearchResultInContainer( + scrollContainerRef.current, + targetGroupId, + targetMatchIndexInItem + ); + if (!targetEl) { + targetEl = + chatItemRefs.current.get(targetItem.groupId) ?? + aiGroupRefs.current.get(targetItem.groupId) ?? + null; + } + if (targetEl) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + await ensureGroupVisible?.(targetItem.groupId); + } + + if (checkAborted()) return false; + // If element not found, highlight is already set — return success + if (!targetEl) return true; + + // Phase 2: Scrolling (best-effort — highlight already set) + setPhase('scrolling'); + const container = scrollContainerRef.current; + if (container && targetEl instanceof HTMLElement) { + const targetScrollTop = calculateCenteredScrollTop(targetEl, container, stickyOffset); + container.scrollTo({ top: targetScrollTop, behavior: 'smooth' }); + await waitForScrollEnd(container, 400); + } else if (targetEl instanceof HTMLElement) { + targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); + await new Promise((resolve) => setTimeout(resolve, 350)); + } + + if (checkAborted()) return false; + + // Phase 3: Highlighting (yellow for search) + setPhase('highlighting'); + // highlightedGroupId and isSearchHighlight already set above + + return true; + }, + [ + conversation, + scrollContainerRef, + chatItemRefs, + aiGroupRefs, + stickyOffset, + ensureGroupVisible, + setSearchQuery, + selectSearchMatch, + ] + ); + + // Main navigation executor + const executeNavigation = useCallback( + async (request: TabNavigationRequest): Promise => { + abortNavigation(); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + let success = false; + + if (request.kind === 'error') { + success = await executeErrorNavigation(request, abortController.signal); + } else if (request.kind === 'search') { + success = await executeSearchNavigation(request, abortController.signal); + } else if (request.kind === 'autoBottom') { + // autoBottom is handled by useAutoScrollBottom naturally + // Just consume the request and stay idle + consumeTabNavigation(tabId, request.id); + return; + } + + if (abortController.signal.aborted) return; + + if (success) { + // Schedule highlight end + highlightTimerRef.current = setTimeout(() => { + if (!abortController.signal.aborted) { + // Clear search state if it was a search navigation + if (request.kind === 'search') { + setSearchQuery(''); + } + handleHighlightEnd(); + } + }, highlightDuration); + + setPhase('complete'); + } else { + // Navigation failed - reset + setPhase('idle'); + setHighlightedGroupId(null); + setCurrentToolUseId(null); + setIsSearchHighlight(false); + setHighlightColor(undefined); + activeRequestIdRef.current = null; + lastFailureAtRef.current = Date.now(); + } + + // Consume the request regardless of success/failure to prevent re-processing + consumeTabNavigation(tabId, request.id); + } catch { + if (!abortController.signal.aborted) { + setPhase('idle'); + activeRequestIdRef.current = null; + lastFailureAtRef.current = Date.now(); + consumeTabNavigation(tabId, request.id); + } + } + }, + [ + abortNavigation, + executeErrorNavigation, + executeSearchNavigation, + consumeTabNavigation, + tabId, + highlightDuration, + handleHighlightEnd, + setSearchQuery, + ] + ); + + // Effect: Detect and process new navigation requests + useEffect(() => { + // Ignore if not active tab (prevents cross-tab races) + if (!isActiveTab) return; + + // No pending request + if (!pendingNavigation) return; + + // Already processing this request + if (activeRequestIdRef.current === pendingNavigation.id) return; + + // Recently failed - debounce + if (Date.now() - lastFailureAtRef.current < 500) return; + + // Record this request + activeRequestIdRef.current = pendingNavigation.id; + + // If content is loading, wait in pending state + if (conversationLoading || !conversation) { + queueMicrotask(() => setPhase('pending')); + return; + } + + // Execute navigation (deferred to avoid synchronous setState in effect) + queueMicrotask(() => { + void executeNavigation(pendingNavigation); + }); + }, [isActiveTab, pendingNavigation, conversationLoading, conversation, executeNavigation]); + + // Effect: When content finishes loading and we're pending, start navigation + useEffect(() => { + if (phase !== 'pending') return; + if (!isActiveTab) return; + if (conversationLoading || !conversation) return; + if (!pendingNavigation) return; + + queueMicrotask(() => { + void executeNavigation(pendingNavigation); + }); + }, [phase, isActiveTab, conversationLoading, conversation, pendingNavigation, executeNavigation]); + + // Effect: Reset when tab becomes inactive + useEffect(() => { + if (!isActiveTab && phase !== 'idle') { + abortNavigation(); + queueMicrotask(() => { + setPhase('idle'); + setHighlightedGroupId(null); + setCurrentToolUseId(null); + setIsSearchHighlight(false); + setHighlightColor(undefined); + }); + activeRequestIdRef.current = null; + } + }, [isActiveTab, phase, abortNavigation]); + + // Cleanup on unmount + useEffect(() => { + return () => { + abortNavigation(); + }; + }, [abortNavigation]); + + // Computed: should disable auto-scroll + const shouldDisableAutoScroll = + phase === 'pending' || + phase === 'expanding' || + phase === 'scrolling' || + phase === 'highlighting' || + phase === 'complete' || + // Also disable while any pendingNavigation exists (even before processing starts) + (isActiveTab && pendingNavigation !== undefined); + + return { + phase, + highlightedGroupId, + highlightToolUseId: currentToolUseId, + isSearchHighlight, + highlightColor, + shouldDisableAutoScroll, + setHighlightedGroupId, + handleHighlightEnd, + }; +} diff --git a/src/renderer/hooks/useTabUI.ts b/src/renderer/hooks/useTabUI.ts new file mode 100644 index 00000000..91a06a1c --- /dev/null +++ b/src/renderer/hooks/useTabUI.ts @@ -0,0 +1,252 @@ +/** + * useTabUI - Hook for accessing per-tab UI state. + * + * This hook combines the TabUIContext (for tabId) with the tabUISlice (for state/actions). + * It provides a simple interface for components to access their tab-specific UI state. + * + * IMPORTANT: This hook subscribes to `tabUIStates` directly to ensure proper reactivity. + * Using getter functions (like isContextPanelVisibleForTab) in useMemo doesn't work + * because the function reference doesn't change when the underlying state changes. + * + * Usage: + * ```tsx + * const { isAIGroupExpanded, toggleAIGroupExpansion } = useTabUI(); + * + * // Check if a group is expanded in THIS tab + * if (isAIGroupExpanded(groupId)) { ... } + * + * // Toggle expansion in THIS tab only + * toggleAIGroupExpansion(groupId); + * ``` + */ + +import { useCallback, useMemo } from 'react'; + +import { useTabIdOptional } from '@renderer/contexts/useTabUIContext'; +import { useStore } from '@renderer/store'; +import { useShallow } from 'zustand/react/shallow'; + +// ============================================================================= +// Types +// ============================================================================= + +interface UseTabUIReturn { + tabId: string | null; + isAIGroupExpanded: (aiGroupId: string) => boolean; + toggleAIGroupExpansion: (aiGroupId: string) => void; + expandAIGroup: (aiGroupId: string) => void; + getExpandedDisplayItemIds: (aiGroupId: string) => Set; + toggleDisplayItemExpansion: (aiGroupId: string, itemId: string) => void; + expandDisplayItem: (aiGroupId: string, itemId: string) => void; + isSubagentTraceExpanded: (subagentId: string) => boolean; + toggleSubagentTraceExpansion: (subagentId: string) => void; + expandSubagentTrace: (subagentId: string) => void; + isContextPanelVisible: boolean; + setContextPanelVisible: (visible: boolean) => void; + selectedContextPhase: number | null; + setSelectedContextPhase: (phase: number | null) => void; + savedScrollTop: number | undefined; + saveScrollPosition: (scrollTop: number) => void; + initializeTabUI: () => void; +} + +// ============================================================================= +// Main Hook +// ============================================================================= + +/** + * Hook for accessing per-tab UI state and actions. + * + * @returns Object containing per-tab state getters and actions + */ +export function useTabUI(): UseTabUIReturn { + // Get tabId from context (null if not in a tab) + const tabId = useTabIdOptional(); + + // Subscribe to tabUIStates MAP directly for reactivity + // This ensures re-renders when any tab state changes + const tabUIStates = useStore((s) => s.tabUIStates); + + // Get the current tab's state (derived from subscribed state) + const tabState = useMemo(() => { + if (!tabId) return null; + return tabUIStates.get(tabId) ?? null; + }, [tabId, tabUIStates]); + + // Get all tab UI actions from store (these are stable function references) + const { + toggleAIGroupExpansionForTab, + expandAIGroupForTab, + toggleDisplayItemExpansionForTab, + expandDisplayItemForTab, + toggleSubagentTraceExpansionForTab, + expandSubagentTraceForTab, + setContextPanelVisibleForTab, + setSelectedContextPhaseForTab, + saveScrollPositionForTab, + initTabUIState, + } = useStore( + useShallow((s) => ({ + toggleAIGroupExpansionForTab: s.toggleAIGroupExpansionForTab, + expandAIGroupForTab: s.expandAIGroupForTab, + toggleDisplayItemExpansionForTab: s.toggleDisplayItemExpansionForTab, + expandDisplayItemForTab: s.expandDisplayItemForTab, + toggleSubagentTraceExpansionForTab: s.toggleSubagentTraceExpansionForTab, + expandSubagentTraceForTab: s.expandSubagentTraceForTab, + setContextPanelVisibleForTab: s.setContextPanelVisibleForTab, + setSelectedContextPhaseForTab: s.setSelectedContextPhaseForTab, + saveScrollPositionForTab: s.saveScrollPositionForTab, + initTabUIState: s.initTabUIState, + })) + ); + + // ========================================================================== + // Derived state from tabState (reactive!) + // ========================================================================== + + // AI Group expansion - check directly from tabState + const isAIGroupExpanded = useCallback( + (aiGroupId: string): boolean => { + return tabState?.expandedAIGroupIds.has(aiGroupId) ?? false; + }, + [tabState] + ); + + const toggleAIGroupExpansion = useCallback( + (aiGroupId: string): void => { + if (!tabId) return; + toggleAIGroupExpansionForTab(tabId, aiGroupId); + }, + [tabId, toggleAIGroupExpansionForTab] + ); + + const expandAIGroup = useCallback( + (aiGroupId: string): void => { + if (!tabId) return; + expandAIGroupForTab(tabId, aiGroupId); + }, + [tabId, expandAIGroupForTab] + ); + + // Display item expansion - derive from tabState + const getExpandedDisplayItemIds = useCallback( + (aiGroupId: string): Set => { + return tabState?.expandedDisplayItemIds.get(aiGroupId) ?? new Set(); + }, + [tabState] + ); + + const toggleDisplayItemExpansion = useCallback( + (aiGroupId: string, itemId: string): void => { + if (!tabId) return; + toggleDisplayItemExpansionForTab(tabId, aiGroupId, itemId); + }, + [tabId, toggleDisplayItemExpansionForTab] + ); + + const expandDisplayItem = useCallback( + (aiGroupId: string, itemId: string): void => { + if (!tabId) return; + expandDisplayItemForTab(tabId, aiGroupId, itemId); + }, + [tabId, expandDisplayItemForTab] + ); + + // Subagent trace expansion - derive from tabState + const isSubagentTraceExpanded = useCallback( + (subagentId: string): boolean => { + return tabState?.expandedSubagentTraceIds.has(subagentId) ?? false; + }, + [tabState] + ); + + const toggleSubagentTraceExpansion = useCallback( + (subagentId: string): void => { + if (!tabId) return; + toggleSubagentTraceExpansionForTab(tabId, subagentId); + }, + [tabId, toggleSubagentTraceExpansionForTab] + ); + + const expandSubagentTrace = useCallback( + (subagentId: string): void => { + if (!tabId) return; + expandSubagentTraceForTab(tabId, subagentId); + }, + [tabId, expandSubagentTraceForTab] + ); + + // Context panel - derive directly from tabState (reactive!) + const isContextPanelVisible = tabState?.showContextPanel ?? false; + + const setContextPanelVisible = useCallback( + (visible: boolean): void => { + if (!tabId) return; + setContextPanelVisibleForTab(tabId, visible); + }, + [tabId, setContextPanelVisibleForTab] + ); + + // Context phase selection - derive from tabState + const selectedContextPhase = tabState?.selectedContextPhase ?? null; + + const setSelectedContextPhase = useCallback( + (phase: number | null): void => { + if (!tabId) return; + setSelectedContextPhaseForTab(tabId, phase); + }, + [tabId, setSelectedContextPhaseForTab] + ); + + // Scroll position - derive from tabState + const savedScrollTop = tabState?.savedScrollTop; + + const saveScrollPosition = useCallback( + (scrollTop: number): void => { + if (!tabId) return; + saveScrollPositionForTab(tabId, scrollTop); + }, + [tabId, saveScrollPositionForTab] + ); + + // Initialize tab UI state (call once when tab is mounted) + const initializeTabUI = useCallback((): void => { + if (!tabId) return; + initTabUIState(tabId); + }, [tabId, initTabUIState]); + + return { + // Current tab ID + tabId, + + // AI Group expansion + isAIGroupExpanded, + toggleAIGroupExpansion, + expandAIGroup, + + // Display item expansion + getExpandedDisplayItemIds, + toggleDisplayItemExpansion, + expandDisplayItem, + + // Subagent trace expansion + isSubagentTraceExpanded, + toggleSubagentTraceExpansion, + expandSubagentTrace, + + // Context panel + isContextPanelVisible, + setContextPanelVisible, + + // Context phase selection + selectedContextPhase, + setSelectedContextPhase, + + // Scroll position + savedScrollTop, + saveScrollPosition, + + // Initialization + initializeTabUI, + }; +} diff --git a/src/renderer/hooks/useTheme.ts b/src/renderer/hooks/useTheme.ts new file mode 100644 index 00000000..1ee91c42 --- /dev/null +++ b/src/renderer/hooks/useTheme.ts @@ -0,0 +1,102 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { useShallow } from 'zustand/react/shallow'; + +import { useStore } from '../store'; + +type Theme = 'dark' | 'light' | 'system'; +type ResolvedTheme = 'dark' | 'light'; + +const THEME_CACHE_KEY = 'claude-code-context-theme-cache'; + +/** + * Hook to manage theme state and application. + * - Fetches theme preference from config on mount + * - Listens to system theme changes when set to 'system' + * - Applies theme class to document root + * - Caches theme in localStorage for flash prevention + */ +export function useTheme(): { + theme: Theme; + resolvedTheme: ResolvedTheme; + isDark: boolean; + isLight: boolean; +} { + const { appConfig, fetchConfig } = useStore( + useShallow((s) => ({ + appConfig: s.appConfig, + fetchConfig: s.fetchConfig, + })) + ); + const [resolvedTheme, setResolvedTheme] = useState(() => { + // Initialize from cache to prevent flash + try { + const cached = localStorage.getItem(THEME_CACHE_KEY); + if (cached === 'light') return 'light'; + } catch { + // localStorage may not be available + } + return 'dark'; + }); + + // Fetch config on mount if not loaded + useEffect(() => { + if (!appConfig) { + void fetchConfig(); + } + }, [appConfig, fetchConfig]); + + // Get configured theme + const configuredTheme: Theme = appConfig?.general?.theme ?? 'dark'; + + // Get system theme preference + const getSystemTheme = useCallback((): ResolvedTheme => { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }, []); + + // Resolve 'system' theme and listen for changes + useEffect(() => { + const updateTheme = (): void => { + const resolved = configuredTheme === 'system' ? getSystemTheme() : configuredTheme; + setResolvedTheme(resolved); + + // Cache for flash prevention + try { + localStorage.setItem(THEME_CACHE_KEY, resolved); + } catch { + // localStorage may not be available + } + }; + + updateTheme(); + + // Listen to system theme changes when in 'system' mode + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (): void => { + if (configuredTheme === 'system') { + updateTheme(); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [configuredTheme, getSystemTheme]); + + // Apply theme class to document root + useEffect(() => { + const root = document.documentElement; + + // Remove existing theme classes + root.classList.remove('dark', 'light'); + + // Add new theme class + root.classList.add(resolvedTheme); + }, [resolvedTheme]); + + return { + theme: configuredTheme, + resolvedTheme, + isDark: resolvedTheme === 'dark', + isLight: resolvedTheme === 'light', + }; +} diff --git a/src/renderer/hooks/useVisibleAIGroup.ts b/src/renderer/hooks/useVisibleAIGroup.ts new file mode 100644 index 00000000..74a0187b --- /dev/null +++ b/src/renderer/hooks/useVisibleAIGroup.ts @@ -0,0 +1,122 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; + +interface UseVisibleAIGroupOptions { + onVisibleChange: (aiGroupId: string) => void; + threshold?: number; // Default 0.5 + /** Optional scroll container to observe against (important for nested scroll areas). */ + rootRef?: RefObject; +} + +interface UseVisibleAIGroupReturn { + registerAIGroupRef: (aiGroupId: string) => (element: HTMLElement | null) => void; +} + +export function useVisibleAIGroup(options: UseVisibleAIGroupOptions): UseVisibleAIGroupReturn { + const { onVisibleChange, threshold = 0.5, rootRef } = options; + + // Track which AI Groups are currently visible (above threshold) + const visibleAIGroupIds = useRef>(new Set()); + + // Track element references by AI Group ID + const elementRefs = useRef>(new Map()); + + // IntersectionObserver instance + const observerRef = useRef(null); + + // Calculate and report the topmost visible AI Group + const updateTopmostVisible = useCallback(() => { + if (visibleAIGroupIds.current.size === 0) { + return; + } + + let topmostId: string | null = null; + let minTop = Infinity; + + // Find the AI Group with the smallest top position (closest to top of viewport) + visibleAIGroupIds.current.forEach((id) => { + const element = elementRefs.current.get(id); + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.top < minTop) { + minTop = rect.top; + topmostId = id; + } + } + }); + + if (topmostId) { + onVisibleChange(topmostId); + } + }, [onVisibleChange]); + + // Set up IntersectionObserver + useEffect(() => { + observerRef.current = new IntersectionObserver( + (entries) => { + let changed = false; + + entries.forEach((entry) => { + const aiGroupId = entry.target.getAttribute('data-aigroup-id'); + if (!aiGroupId) return; + + if (entry.isIntersecting && entry.intersectionRatio >= threshold) { + // Element is visible above threshold + if (!visibleAIGroupIds.current.has(aiGroupId)) { + visibleAIGroupIds.current.add(aiGroupId); + changed = true; + } + } else { + // Element is not visible or below threshold + if (visibleAIGroupIds.current.has(aiGroupId)) { + visibleAIGroupIds.current.delete(aiGroupId); + changed = true; + } + } + }); + + // Recalculate topmost visible AI Group if visibility changed + if (changed) { + updateTopmostVisible(); + } + }, + { + root: rootRef?.current ?? null, + threshold, + // Use root margin to start detection slightly before element enters viewport + rootMargin: '0px', + } + ); + + return () => { + observerRef.current?.disconnect(); + observerRef.current = null; + }; + }, [threshold, updateTopmostVisible, rootRef]); + + // Register an AI Group element for observation + const registerAIGroupRef = useCallback((aiGroupId: string) => { + return (element: HTMLElement | null) => { + const observer = observerRef.current; + if (!observer) return; + + // Clean up previous element if it exists + const prevElement = elementRefs.current.get(aiGroupId); + if (prevElement) { + observer.unobserve(prevElement); + elementRefs.current.delete(aiGroupId); + visibleAIGroupIds.current.delete(aiGroupId); + } + + // Register new element + if (element) { + element.setAttribute('data-aigroup-id', aiGroupId); + elementRefs.current.set(aiGroupId, element); + observer.observe(element); + } + }; + }, []); + + return { + registerAIGroupRef, + }; +} diff --git a/src/renderer/hooks/useZoomFactor.ts b/src/renderer/hooks/useZoomFactor.ts new file mode 100644 index 00000000..0602ae9c --- /dev/null +++ b/src/renderer/hooks/useZoomFactor.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +/** + * Reads current zoom factor and stays subscribed to zoom updates from main. + */ +export function useZoomFactor(): number { + const [zoomFactor, setZoomFactor] = useState(1); + + useEffect(() => { + let isMounted = true; + + void window.electronAPI + .getZoomFactor() + .then((value) => { + if (isMounted) { + setZoomFactor(value); + } + }) + .catch(() => { + // Keep default 1 if zoom factor cannot be read. + }); + + const unsubscribe = window.electronAPI.onZoomFactorChanged((value) => { + setZoomFactor(value); + }); + + return () => { + isMounted = false; + unsubscribe(); + }; + }, []); + + return zoomFactor; +} diff --git a/src/renderer/index.css b/src/renderer/index.css new file mode 100644 index 00000000..a9125363 --- /dev/null +++ b/src/renderer/index.css @@ -0,0 +1,505 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Theme CSS Custom Properties */ +:root { + /* Dark theme (default) - Soft Charcoal palette */ + --color-surface: #141416; /* Main Chat Area Background (Soft Matte Charcoal) */ + --color-surface-raised: #27272a; /* Active/Selected items (Zinc-800) */ + --color-surface-overlay: #27272a; /* Overlay surfaces */ + --color-surface-sidebar: #0f0f11; /* Sidebar Background (slightly darker) */ + --color-border: rgba(255, 255, 255, 0.05); /* Borders/Dividers (5% white) */ + --color-border-subtle: rgba(255, 255, 255, 0.05); /* Subtle borders (5% white) */ + --color-border-emphasis: rgba(255, 255, 255, 0.1); /* Emphasis borders (10% white) */ + --color-text: #fafafa; + --color-text-secondary: #a1a1aa; + --color-text-muted: #71717a; + + /* Scrollbar colors */ + --scrollbar-thumb: rgba(255, 255, 255, 0.15); + --scrollbar-thumb-hover: rgba(255, 255, 255, 0.25); + --scrollbar-thumb-active: rgba(255, 255, 255, 0.35); + + /* Search highlights */ + --highlight-bg: rgba(202, 138, 4, 0.7); + --highlight-bg-inactive: rgba(113, 63, 18, 0.5); + --highlight-text: #fef9c3; + --highlight-text-inactive: #fef08a; + --highlight-ring: #facc15; + + /* User chat bubble - Soft Charcoal theme (softer text for visual hierarchy) */ + --chat-user-bg: #27272a; + --chat-user-text: #a1a1aa; + --chat-user-border: rgba(255, 255, 255, 0.08); + --chat-user-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.03); + + /* User bubble inline tags - Linear-style neutral */ + --chat-user-tag-bg: rgba(255, 255, 255, 0.08); + --chat-user-tag-text: #e4e4e7; + --chat-user-tag-border: rgba(255, 255, 255, 0.12); + + /* Tool items */ + --tool-item-name: #e4e4e7; + --tool-item-summary: #a1a1aa; + --tool-item-muted: #71717a; + --tool-item-hover-bg: rgba(39, 39, 42, 0.5); + + /* System chat bubble */ + --chat-system-bg: rgba(39, 39, 42, 0.5); + --chat-system-text: #d4d4d8; + + /* AI message styling */ + --chat-ai-border: rgba(255, 255, 255, 0.05); + --chat-ai-icon: #71717a; + + /* Code blocks - Soft Charcoal theme */ + --code-bg: #1c1c1e; + --code-header-bg: #1c1c1e; + --code-border: rgba(255, 255, 255, 0.1); + --code-line-number: #52525b; + --code-filename: #60a5fa; + + /* Syntax highlighting - Dark theme */ + --syntax-string: #4ade80; + --syntax-comment: #71717a; + --syntax-number: #fb923c; + --syntax-keyword: #c084fc; + --syntax-type: #facc15; + --syntax-operator: #a1a1aa; + --syntax-function: #60a5fa; + + /* Inline code - Linear-style neutral */ + --inline-code-bg: rgba(255, 255, 255, 0.08); + --inline-code-text: #e4e4e7; + + /* Diff viewer */ + --diff-added-bg: rgba(34, 197, 94, 0.15); + --diff-added-text: #4ade80; + --diff-added-border: #22c55e; + --diff-removed-bg: rgba(239, 68, 68, 0.15); + --diff-removed-text: #f87171; + --diff-removed-border: #ef4444; + + /* Markdown prose - Brighter body text for AI responses (visual hierarchy) */ + --prose-heading: #ffffff; + --prose-body: #f4f4f5; + --prose-muted: #a1a1aa; + --prose-link: #60a5fa; + --prose-code-bg: rgba(255, 255, 255, 0.08); + --prose-code-text: #e4e4e7; + --prose-pre-bg: #1c1c1e; + --prose-pre-border: rgba(255, 255, 255, 0.1); + --prose-blockquote-border: rgba(255, 255, 255, 0.1); + --prose-table-border: rgba(255, 255, 255, 0.05); + --prose-table-header-bg: #27272a; + + /* Thinking blocks */ + --thinking-bg: rgba(88, 28, 135, 0.2); + --thinking-border: rgba(107, 33, 168, 0.4); + --thinking-text: #d8b4fe; + --thinking-text-muted: #e9d5ff; + --thinking-content-border: rgba(107, 33, 168, 0.3); + --thinking-content-text: #f3e8ff; + + /* Tool call blocks */ + --tool-call-bg: rgba(120, 53, 15, 0.2); + --tool-call-border: rgba(146, 64, 14, 0.4); + --tool-call-text: #fcd34d; + --tool-call-code-bg: rgba(69, 26, 3, 0.5); + --tool-call-content-border: rgba(146, 64, 14, 0.3); + + /* Tool result blocks */ + --tool-result-success-bg: rgba(20, 83, 45, 0.2); + --tool-result-success-border: rgba(22, 101, 52, 0.4); + --tool-result-success-text: #86efac; + --tool-result-error-bg: rgba(127, 29, 29, 0.2); + --tool-result-error-border: rgba(153, 27, 27, 0.4); + --tool-result-error-text: #fca5a5; + + /* Output blocks */ + --output-bg: rgba(31, 41, 55, 0.3); + --output-border: #374151; + --output-text: #e5e7eb; + --output-content-border: rgba(55, 65, 81, 0.5); + + /* Badges */ + --badge-error-bg: #dc2626; + --badge-error-text: #ffffff; + --badge-warning-bg: #ca8a04; + --badge-warning-text: #ffffff; + --badge-success-bg: #16a34a; + --badge-success-text: #ffffff; + --badge-info-bg: #2563eb; + --badge-info-text: #ffffff; + --badge-neutral-bg: #3f3f46; + --badge-neutral-text: #e4e4e7; + + /* Language/tag badges */ + --tag-bg: #27272a; + --tag-text: #a1a1aa; + --tag-border: rgba(255, 255, 255, 0.05); + + /* Highlighted text (skills, paths) - Linear-style neutral (same as inline code) */ + --skill-highlight-bg: rgba(255, 255, 255, 0.08); + --skill-highlight-text: #e4e4e7; + --path-highlight-bg: rgba(255, 255, 255, 0.08); + --path-highlight-text: #e4e4e7; + + /* Interruption badge */ + --interruption-bg: rgba(127, 29, 29, 0.3); + --interruption-border: #b91c1c; + --interruption-text: #fca5a5; + + /* Warning/Amber colors (for interruption banner) */ + --warning-bg: rgba(245, 158, 11, 0.15); + --warning-border: rgba(245, 158, 11, 0.4); + --warning-text: #fbbf24; + + /* Plan exit/Green colors (for ExitPlanMode) */ + --plan-exit-bg: rgba(34, 197, 94, 0.05); + --plan-exit-header-bg: rgba(34, 197, 94, 0.1); + --plan-exit-border: rgba(34, 197, 94, 0.25); + --plan-exit-text: #4ade80; + + /* Error highlight (pulsing) */ + --error-highlight-ring: #ef4444; + --error-highlight-bg: rgba(239, 68, 68, 0.2); + + /* Keyboard hints */ + --kbd-bg: #27272a; + --kbd-border: rgba(255, 255, 255, 0.1); + --kbd-text: #d4d4d8; + + /* Subagent/Card styling */ + --card-bg: #121212; + --card-border: #27272a; + --card-header-bg: #18181b; + --card-header-hover: #1f1f23; + --card-icon-muted: #52525b; + --card-text-light: #d4d4d8; + --card-text-lighter: #e4e4e7; + --card-separator: #3f3f46; + + /* Sticky Context button - transparent glass */ + --context-btn-bg: rgba(255, 255, 255, 0.08); + --context-btn-bg-hover: rgba(255, 255, 255, 0.14); + --context-btn-active-bg: rgba(99, 102, 241, 0.45); + --context-btn-active-text: #e0e7ff; +} + +/* Light theme overrides - Warm neutral palette for eye comfort */ +:root.light { + --color-surface: #f9f9f7; /* Warm off-white (not pure white) */ + --color-surface-raised: #f0efed; /* Warm raised surface, clearly distinct */ + --color-surface-overlay: #e8e7e4; /* Warm overlay */ + --color-surface-sidebar: #f1f0ee; /* Warm sidebar, distinct from main */ + --color-border: #d5d3cf; /* Warm neutral border */ + --color-border-subtle: #e3e1dd; /* Warm subtle border */ + --color-border-emphasis: #a8a5a0; /* Warm emphasis border */ + --color-text: #1c1b19; /* Warm near-black text */ + --color-text-secondary: #4d4b46; /* Warm secondary text */ + --color-text-muted: #6d6b65; /* Warm muted text */ + + /* Scrollbar colors for light mode */ + --scrollbar-thumb: rgba(0, 0, 0, 0.15); + --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); + --scrollbar-thumb-active: rgba(0, 0, 0, 0.4); + + /* Search highlights - High saturation yellow */ + --highlight-bg: #facc15; + --highlight-bg-inactive: #fef08a; + --highlight-text: #1c1917; + --highlight-text-inactive: #422006; + --highlight-ring: #ca8a04; + + /* User chat bubble - Warm neutral, clearly visible */ + --chat-user-bg: #eae9e6; + --chat-user-text: #5a5955; + --chat-user-border: #d5d3cf; + --chat-user-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04); + + /* User bubble inline tags - Warm neutral */ + --chat-user-tag-bg: rgba(0, 0, 0, 0.05); + --chat-user-tag-text: #3a3935; + --chat-user-tag-border: rgba(0, 0, 0, 0.08); + + /* Tool items - Warm high contrast */ + --tool-item-name: #1c1b19; + --tool-item-summary: #4d4b46; + --tool-item-muted: #6d6b65; + --tool-item-hover-bg: #eae9e6; + + /* System chat bubble */ + --chat-system-bg: #eae9e6; + --chat-system-text: #3a3935; + + /* AI message styling */ + --chat-ai-border: #d5d3cf; + --chat-ai-icon: #6d6b65; + + /* Code blocks - Warm light theme */ + --code-bg: #f0efed; + --code-header-bg: #eae9e6; + --code-border: #d5d3cf; + --code-line-number: #a8a5a0; + --code-filename: #2563eb; + + /* Syntax highlighting - GitHub Light inspired */ + --syntax-string: #0a3069; + --syntax-comment: #6e7781; + --syntax-number: #0550ae; + --syntax-keyword: #cf222e; + --syntax-type: #8250df; + --syntax-operator: #24292f; + --syntax-function: #8250df; + + /* Inline code - Warm neutral */ + --inline-code-bg: rgba(0, 0, 0, 0.05); + --inline-code-text: #3a3935; + + /* Diff viewer - Light mode */ + --diff-added-bg: rgba(34, 197, 94, 0.1); + --diff-added-text: #166534; + --diff-added-border: #22c55e; + --diff-removed-bg: rgba(239, 68, 68, 0.1); + --diff-removed-text: #991b1b; + --diff-removed-border: #ef4444; + + /* Markdown prose - Warm tones */ + --prose-heading: #1c1b19; + --prose-body: #2a2925; + --prose-muted: #6d6b65; + --prose-link: #2563eb; + --prose-code-bg: rgba(0, 0, 0, 0.05); + --prose-code-text: #3a3935; + --prose-pre-bg: #f0efed; + --prose-pre-border: #d5d3cf; + --prose-blockquote-border: #d5d3cf; + --prose-table-border: #e3e1dd; + --prose-table-header-bg: #eae9e6; + + /* Thinking blocks - Warm purple */ + --thinking-bg: #f9f5fe; + --thinking-border: #d8b4fe; + --thinking-text: #6b21a8; + --thinking-text-muted: #7c3aed; + --thinking-content-border: #e9d5ff; + --thinking-content-text: #581c87; + + /* Tool call blocks - Warm amber */ + --tool-call-bg: #fefbf0; + --tool-call-border: #f0d070; + --tool-call-text: #92400e; + --tool-call-code-bg: #fdf3d0; + --tool-call-content-border: #f0d888; + + /* Tool result blocks */ + --tool-result-success-bg: #f0fdf4; + --tool-result-success-border: #86efac; + --tool-result-success-text: #166534; + --tool-result-error-bg: #fef2f2; + --tool-result-error-border: #fca5a5; + --tool-result-error-text: #991b1b; + + /* Output blocks */ + --output-bg: #f0efed; + --output-border: #d5d3cf; + --output-text: #2a2925; + --output-content-border: #d5d3cf; + + /* Badges - High contrast */ + --badge-error-bg: #dc2626; + --badge-error-text: #ffffff; + --badge-warning-bg: #d97706; + --badge-warning-text: #ffffff; + --badge-success-bg: #16a34a; + --badge-success-text: #ffffff; + --badge-info-bg: #2563eb; + --badge-info-text: #ffffff; + --badge-neutral-bg: #e3e1dd; + --badge-neutral-text: #3a3935; + + /* Language/tag badges - Warm neutral */ + --tag-bg: #eae9e6; + --tag-text: #4d4b46; + --tag-border: #d5d3cf; + + /* Highlighted text (skills, paths) - Warm neutral */ + --skill-highlight-bg: rgba(0, 0, 0, 0.05); + --skill-highlight-text: #3a3935; + --path-highlight-bg: rgba(0, 0, 0, 0.05); + --path-highlight-text: #3a3935; + + /* Interruption badge */ + --interruption-bg: #fef2f2; + --interruption-border: #f87171; + --interruption-text: #b91c1c; + + /* Warning/Amber colors (for interruption banner) */ + --warning-bg: #fef3c7; + --warning-border: #f59e0b; + --warning-text: #b45309; + + /* Plan exit/Green colors (for ExitPlanMode) */ + --plan-exit-bg: #f0fdf4; + --plan-exit-header-bg: #dcfce7; + --plan-exit-border: #86efac; + --plan-exit-text: #15803d; + + /* Error highlight (pulsing) */ + --error-highlight-ring: #dc2626; + --error-highlight-bg: rgba(220, 38, 38, 0.15); + + /* Keyboard hints */ + --kbd-bg: #eae9e6; + --kbd-border: #a8a5a0; + --kbd-text: #3a3935; + + /* Subagent/Card styling - Warm light mode */ + --card-bg: #f9f9f7; + --card-border: #d5d3cf; + --card-header-bg: #f0efed; + --card-header-hover: #eae9e6; + --card-icon-muted: #a8a5a0; + --card-text-light: #3a3935; + --card-text-lighter: #2a2925; + --card-separator: #d5d3cf; + + /* Sticky Context button - transparent glass */ + --context-btn-bg: rgba(0, 0, 0, 0.06); + --context-btn-bg-hover: rgba(0, 0, 0, 0.1); + --context-btn-active-bg: rgba(99, 102, 241, 0.35); + --context-btn-active-text: #3730a3; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', + 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--color-surface); + color: var(--color-text); + transition: + background-color 0.2s ease, + color 0.2s ease; +} + +#root { + width: 100vw; + height: 100vh; + overflow: hidden; +} + +/* macOS window integration */ +.sidebar-header { + -webkit-app-region: drag; + -webkit-user-select: none; +} + +.sidebar-header button, +.sidebar-header input, +.sidebar-header .no-drag { + -webkit-app-region: no-drag; +} + +/* Hide horizontal scrollbar in tab bar */ +.scrollbar-none { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollbar-none::-webkit-scrollbar { + display: none; +} + +/* Custom scrollbar styling for dark theme */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; + transition: background 0.2s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); +} + +/* Firefox scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) transparent; +} + +/* Dashboard skeleton shimmer animation */ +@keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } +} + +@keyframes skeleton-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.skeleton-card { + animation: skeleton-fade-in 0.4s ease-out both; + position: relative; + overflow: hidden; +} + +.skeleton-card::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.04) 40%, + rgba(255, 255, 255, 0.06) 50%, + rgba(255, 255, 255, 0.04) 60%, + transparent 100% + ); + animation: shimmer 1.8s ease-in-out infinite; +} + +:root.light .skeleton-card::after { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(0, 0, 0, 0.03) 40%, + rgba(0, 0, 0, 0.05) 50%, + rgba(0, 0, 0, 0.03) 60%, + transparent 100% + ); +} diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 00000000..d32d2531 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,63 @@ + + + + + + + Claude Code Context + + + + +
    + +
    Claude Code Context
    +
    +
    +
    +
    +
    + + + diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx new file mode 100644 index 00000000..b0a79fac --- /dev/null +++ b/src/renderer/main.tsx @@ -0,0 +1,12 @@ +import './index.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import { App } from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/renderer/store/CLAUDE.md b/src/renderer/store/CLAUDE.md new file mode 100644 index 00000000..640f470f --- /dev/null +++ b/src/renderer/store/CLAUDE.md @@ -0,0 +1,52 @@ +# Store (Zustand) + +State management with slices pattern for domain organization. + +## Structure +- `index.ts` - Store creation, combines all slices +- `types.ts` - AppState type definition +- `slices/` - Individual domain slices +- `utils/` - Store utilities (`paneHelpers.ts`, `pathResolution.ts`) + +## Slices (12 total) +| Slice | Purpose | +|-------|---------| +| `projectSlice` | Projects list, selectedProjectId | +| `repositorySlice` | Repository grouping, worktrees | +| `sessionSlice` | Sessions list, pagination, selectedSessionId | +| `sessionDetailSlice` | Session detail, chunks, metrics | +| `subagentSlice` | Subagent data, selectedSubagentId | +| `conversationSlice` | Messages, conversation metadata | +| `tabSlice` | Tabs list, activeTabId, tab ordering | +| `tabUISlice` | Per-tab UI state (expansions, scroll) | +| `paneSlice` | Pane layout, split views | +| `uiSlice` | UI flags (sidebar visible, etc.) | +| `notificationSlice` | Notifications, unreadCount | +| `configSlice` | App config, triggers | + +## Slice Pattern +Each slice follows: +```typescript +data: T[] +selectedId: string | null +loading: boolean +error: string | null +``` + +## Key Pattern: Per-Tab UI Isolation +`tabUISlice` maintains independent UI state per tab using tabId: +- `expandedAIGroupIds`, `expandedDisplayItemIds`, `expandedSubagentTraceIds` +- Ensures expanding a group in tab A doesn't affect tab B + +## Store Initialization +Call `initializeNotificationListeners()` once in App.tsx useEffect: +- Subscribes to file change events +- Auto-refreshes sessions on new files +- Updates session detail on content change +- Uses `refreshSessionInPlace` to prevent flickering + +## Adding a Slice +1. Create `slices/{domain}Slice.ts` +2. Export `create{Domain}Slice` function +3. Add to store composition in `index.ts` +4. Update `AppState` type in `types.ts` diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts new file mode 100644 index 00000000..42762c39 --- /dev/null +++ b/src/renderer/store/index.ts @@ -0,0 +1,241 @@ +/** + * Store index - combines all slices and exports the unified store. + */ + +import { create } from 'zustand'; + +import { createConfigSlice } from './slices/configSlice'; +import { createConversationSlice } from './slices/conversationSlice'; +import { createNotificationSlice } from './slices/notificationSlice'; +import { createPaneSlice } from './slices/paneSlice'; +import { createProjectSlice } from './slices/projectSlice'; +import { createRepositorySlice } from './slices/repositorySlice'; +import { createSessionDetailSlice } from './slices/sessionDetailSlice'; +import { createSessionSlice } from './slices/sessionSlice'; +import { createSubagentSlice } from './slices/subagentSlice'; +import { createTabSlice } from './slices/tabSlice'; +import { createTabUISlice } from './slices/tabUISlice'; +import { createUISlice } from './slices/uiSlice'; + +import type { DetectedError } from '../types/data'; +import type { AppState } from './types'; + +// ============================================================================= +// Store Creation +// ============================================================================= + +export const useStore = create()((...args) => ({ + ...createProjectSlice(...args), + ...createRepositorySlice(...args), + ...createSessionSlice(...args), + ...createSessionDetailSlice(...args), + ...createSubagentSlice(...args), + ...createConversationSlice(...args), + ...createTabSlice(...args), + ...createTabUISlice(...args), + ...createPaneSlice(...args), + ...createUISlice(...args), + ...createNotificationSlice(...args), + ...createConfigSlice(...args), +})); + +// ============================================================================= +// Re-exports +// ============================================================================= + +// ============================================================================= +// Store Initialization - Subscribe to IPC Events +// ============================================================================= + +/** + * Initialize notification event listeners and fetch initial notification count. + * Call this once when the app starts (e.g., in App.tsx useEffect). + */ +export function initializeNotificationListeners(): () => void { + const cleanupFns: (() => void)[] = []; + const pendingSessionRefreshTimers = new Map>(); + const pendingProjectRefreshTimers = new Map>(); + const SESSION_REFRESH_DEBOUNCE_MS = 150; + const PROJECT_REFRESH_DEBOUNCE_MS = 300; + + const scheduleSessionRefresh = (projectId: string, sessionId: string): void => { + const key = `${projectId}/${sessionId}`; + const existingTimer = pendingSessionRefreshTimers.get(key); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + pendingSessionRefreshTimers.delete(key); + const state = useStore.getState(); + void state.refreshSessionInPlace(projectId, sessionId); + }, SESSION_REFRESH_DEBOUNCE_MS); + pendingSessionRefreshTimers.set(key, timer); + }; + + const scheduleProjectRefresh = (projectId: string): void => { + const existingTimer = pendingProjectRefreshTimers.get(projectId); + if (existingTimer) { + clearTimeout(existingTimer); + } + const timer = setTimeout(() => { + pendingProjectRefreshTimers.delete(projectId); + const state = useStore.getState(); + void state.refreshSessionsInPlace(projectId); + }, PROJECT_REFRESH_DEBOUNCE_MS); + pendingProjectRefreshTimers.set(projectId, timer); + }; + + // Listen for new notifications from main process + if (window.electronAPI.notifications?.onNew) { + const cleanup = window.electronAPI.notifications.onNew((_event: unknown, error: unknown) => { + // Cast the error to DetectedError type + const notification = error as DetectedError; + if (notification?.id) { + // Keep list in sync immediately; unread count is synced via notification:updated/fetch. + useStore.setState((state) => { + if (state.notifications.some((n) => n.id === notification.id)) { + return {}; + } + return { notifications: [notification, ...state.notifications].slice(0, 200) }; + }); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + + // Listen for notification updates from main process + if (window.electronAPI.notifications?.onUpdated) { + const cleanup = window.electronAPI.notifications.onUpdated( + (_event: unknown, payload: { total: number; unreadCount: number }) => { + const unreadCount = + typeof payload.unreadCount === 'number' && Number.isFinite(payload.unreadCount) + ? Math.max(0, Math.floor(payload.unreadCount)) + : 0; + useStore.setState({ unreadCount }); + } + ); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + + // Navigate to error when user clicks a native OS notification + if (window.electronAPI.notifications?.onClicked) { + const cleanup = window.electronAPI.notifications.onClicked((_event: unknown, data: unknown) => { + const error = data as DetectedError; + if (error?.id && error?.sessionId && error?.projectId) { + useStore.getState().navigateToError(error); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + + // Fetch after listeners are attached so startup events do not get overwritten by a stale response. + void useStore.getState().fetchNotifications(); + + /** + * Check if a session is visible in any pane (not just the focused pane's active tab). + * This ensures file change and task-list listeners refresh sessions shown in any split pane. + */ + const isSessionVisibleInAnyPane = (sessionId: string): boolean => { + const { paneLayout } = useStore.getState(); + return paneLayout.panes.some( + (pane) => + pane.activeTabId != null && + pane.tabs.some( + (tab) => + tab.id === pane.activeTabId && tab.type === 'session' && tab.sessionId === sessionId + ) + ); + }; + + // Listen for task-list file changes to refresh currently viewed session metadata + if (window.electronAPI.onTodoChange) { + const cleanup = window.electronAPI.onTodoChange((event) => { + if (!event.sessionId || event.type === 'unlink') { + return; + } + + const state = useStore.getState(); + const isViewingSession = + state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(event.sessionId); + + if (isViewingSession) { + // Find the project ID from any pane's tab that shows this session + const allTabs = state.getAllPaneTabs(); + const sessionTab = allTabs.find( + (t) => t.type === 'session' && t.sessionId === event.sessionId + ); + if (sessionTab?.projectId) { + scheduleSessionRefresh(sessionTab.projectId, event.sessionId); + } + } + + // Refresh project sessions list if applicable + const activeTab = state.getActiveTab(); + const activeProjectId = + activeTab?.type === 'session' && typeof activeTab.projectId === 'string' + ? activeTab.projectId + : null; + if (activeProjectId && activeProjectId === state.selectedProjectId) { + scheduleProjectRefresh(activeProjectId); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + + // Listen for file changes to auto-refresh current session and detect new sessions + if (window.electronAPI.onFileChange) { + const cleanup = window.electronAPI.onFileChange((event) => { + // Skip unlink events + if (event.type === 'unlink') { + return; + } + + const state = useStore.getState(); + + // Handle new session added to a project (main session files only) + if (event.type === 'add' && !event.isSubagent && event.projectId) { + // Refresh sessions list if viewing this project (without loading state) + if (state.selectedProjectId === event.projectId) { + scheduleProjectRefresh(event.projectId); + } + return; + } + + // Handle session or subagent content change + if (event.type === 'change' && event.projectId && event.sessionId) { + // Check if the changed session is visible in ANY pane (not just focused) + const isViewingSession = + state.selectedSessionId === event.sessionId || isSessionVisibleInAnyPane(event.sessionId); + + if (isViewingSession) { + // Use refreshSessionInPlace to avoid flickering and preserve UI state + scheduleSessionRefresh(event.projectId, event.sessionId); + } + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + + // Return cleanup function + return () => { + for (const timer of pendingSessionRefreshTimers.values()) { + clearTimeout(timer); + } + pendingSessionRefreshTimers.clear(); + for (const timer of pendingProjectRefreshTimers.values()) { + clearTimeout(timer); + } + pendingProjectRefreshTimers.clear(); + cleanupFns.forEach((fn) => fn()); + }; +} diff --git a/src/renderer/store/slices/configSlice.ts b/src/renderer/store/slices/configSlice.ts new file mode 100644 index 00000000..17e1a91d --- /dev/null +++ b/src/renderer/store/slices/configSlice.ts @@ -0,0 +1,89 @@ +/** + * Config slice - manages app configuration state and actions. + */ + +import { createLogger } from '@shared/utils/logger'; + +import type { AppState } from '../types'; +import type { AppConfig } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:config'); + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface ConfigSlice { + // State + appConfig: AppConfig | null; + configLoading: boolean; + configError: string | null; + + // Actions + fetchConfig: () => Promise; + updateConfig: (section: string, data: Record) => Promise; + openSettingsTab: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createConfigSlice: StateCreator = (set, get) => ({ + // Initial state + appConfig: null, + configLoading: false, + configError: null, + + // Fetch app configuration from main process + fetchConfig: async () => { + set({ configLoading: true, configError: null }); + try { + const config = await window.electronAPI.config.get(); + set({ + appConfig: config, + configLoading: false, + }); + } catch (error) { + set({ + configError: error instanceof Error ? error.message : 'Failed to fetch config', + configLoading: false, + }); + } + }, + + // Update a section of the app configuration + updateConfig: async (section: string, data: Record) => { + try { + await window.electronAPI.config.update(section, data); + // Refresh config after update + const config = await window.electronAPI.config.get(); + set({ appConfig: config }); + } catch (error) { + logger.error('Failed to update config:', error); + set({ + configError: error instanceof Error ? error.message : 'Failed to update config', + }); + } + }, + + // Open or focus the settings tab (per-pane singleton) + openSettingsTab: () => { + const state = get(); + + // Check if settings tab exists in focused pane + const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); + const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings'); + if (settingsTab) { + state.setActiveTab(settingsTab.id); + return; + } + + // Create new settings tab via openTab (which adds to focused pane) + state.openTab({ + type: 'settings', + label: 'Settings', + }); + }, +}); diff --git a/src/renderer/store/slices/conversationSlice.ts b/src/renderer/store/slices/conversationSlice.ts new file mode 100644 index 00000000..10374aae --- /dev/null +++ b/src/renderer/store/slices/conversationSlice.ts @@ -0,0 +1,476 @@ +/** + * Conversation slice - manages expansion states, chart mode, search, and detail popover. + */ + +import { findLastOutput } from '@renderer/utils/aiGroupEnhancer'; +import { findMarkdownSearchMatches } from '@shared/utils/markdownTextSearch'; + +import type { AppState, SearchMatch } from '../types'; +import type { AIGroupExpansionLevel } from '@renderer/types/groups'; +import type { SessionConversation } from '@renderer/types/groups'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Types +// ============================================================================= + +type DetailItemType = 'thinking' | 'text' | 'linked-tool' | 'subagent'; + +const isSearchDebugEnabled = (): boolean => { + if (typeof window === 'undefined') return false; + try { + return ( + window.localStorage.getItem('search-debug') === '1' || + (window as { __searchDebug?: boolean }).__searchDebug === true + ); + } catch { + return false; + } +}; + +export interface ActiveDetailItem { + aiGroupId: string; + itemId: string; + type: DetailItemType; +} + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface ConversationSlice { + // Expansion states + aiGroupExpansionLevels: Map; + expandedStepIds: Set; + /** Display item expansion state per AI group - persists across refreshes */ + expandedDisplayItemIds: Map>; + /** AI group expanded/collapsed state - persists across refreshes */ + expandedAIGroupIds: Set; + + // Detail popover state + activeDetailItem: ActiveDetailItem | null; + + // Search state + searchQuery: string; + searchVisible: boolean; + searchResultCount: number; + currentSearchIndex: number; + searchMatches: SearchMatch[]; + + // Auto-expand state for search results + /** AI group IDs that should be expanded to show search results */ + searchExpandedAIGroupIds: Set; + /** Subagent IDs within AI groups that should show their execution trace */ + searchExpandedSubagentIds: Set; + /** Current search result's display item ID for precise expansion (e.g., "thinking-0") */ + searchCurrentDisplayItemId: string | null; + /** Current search result's item ID within subagent trace (e.g., "subagent-thinking-0") */ + searchCurrentSubagentItemId: string | null; + + // Actions + setAIGroupExpansion: (aiGroupId: string, level: AIGroupExpansionLevel) => void; + toggleStepExpansion: (stepId: string) => void; + /** Toggle expansion of a display item within an AI group */ + toggleDisplayItemExpansion: (aiGroupId: string, itemId: string) => void; + /** Get expanded display item IDs for an AI group */ + getExpandedDisplayItemIds: (aiGroupId: string) => Set; + /** Toggle AI group expanded/collapsed state */ + toggleAIGroupExpansion: (aiGroupId: string) => void; + + // Detail popover actions + showDetailPopover: ( + aiGroupId: string, + itemId: string, + type: 'thinking' | 'text' | 'linked-tool' | 'subagent' + ) => void; + hideDetailPopover: () => void; + + // Search actions + setSearchQuery: (query: string, conversationOverride?: SessionConversation | null) => void; + /** Canonicalize search matches from currently rendered mark elements (DOM order) */ + syncSearchMatchesWithRendered: ( + renderedMatches: { itemId: string; matchIndexInItem: number }[] + ) => void; + /** Select a specific search match by item ID and in-item match index */ + selectSearchMatch: (itemId: string, matchIndexInItem: number) => boolean; + showSearch: () => void; + hideSearch: () => void; + nextSearchResult: () => void; + previousSearchResult: () => void; + /** Expand AI groups and subagents needed to show the current search result */ + expandForCurrentSearchResult: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createConversationSlice: StateCreator = ( + set, + get +) => ({ + // Initial state + aiGroupExpansionLevels: new Map(), + expandedStepIds: new Set(), + expandedDisplayItemIds: new Map(), + expandedAIGroupIds: new Set(), + + ganttChartMode: 'timeline', + + activeDetailItem: null, + + // Search state (initial values) + searchQuery: '', + searchVisible: false, + searchResultCount: 0, + currentSearchIndex: -1, + searchMatches: [], + + // Auto-expand state for search results (initial values) + searchExpandedAIGroupIds: new Set(), + searchExpandedSubagentIds: new Set(), + searchCurrentDisplayItemId: null, + searchCurrentSubagentItemId: null, + + // Set expansion level for a specific AI Group + setAIGroupExpansion: (aiGroupId: string, level: AIGroupExpansionLevel) => { + const state = get(); + const newLevels = new Map(state.aiGroupExpansionLevels); + newLevels.set(aiGroupId, level); + set({ aiGroupExpansionLevels: newLevels }); + }, + + // Toggle expansion state for a semantic step + toggleStepExpansion: (stepId: string) => { + const state = get(); + const newExpandedStepIds = new Set(state.expandedStepIds); + if (newExpandedStepIds.has(stepId)) { + newExpandedStepIds.delete(stepId); + } else { + newExpandedStepIds.add(stepId); + } + set({ expandedStepIds: newExpandedStepIds }); + }, + + // Toggle expansion of a display item within an AI group + toggleDisplayItemExpansion: (aiGroupId: string, itemId: string) => { + const state = get(); + const newMap = new Map(state.expandedDisplayItemIds); + const currentSet = newMap.get(aiGroupId) ?? new Set(); + const newSet = new Set(currentSet); + + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + + newMap.set(aiGroupId, newSet); + set({ expandedDisplayItemIds: newMap }); + }, + + // Get expanded display item IDs for an AI group + getExpandedDisplayItemIds: (aiGroupId: string) => { + const state = get(); + return state.expandedDisplayItemIds.get(aiGroupId) ?? new Set(); + }, + + // Toggle AI group expanded/collapsed state + toggleAIGroupExpansion: (aiGroupId: string) => { + const state = get(); + const newSet = new Set(state.expandedAIGroupIds); + if (newSet.has(aiGroupId)) { + newSet.delete(aiGroupId); + } else { + newSet.add(aiGroupId); + } + set({ expandedAIGroupIds: newSet }); + }, + + // Show detail popover + showDetailPopover: ( + aiGroupId: string, + itemId: string, + type: 'thinking' | 'text' | 'linked-tool' | 'subagent' + ) => { + set({ + activeDetailItem: { + aiGroupId, + itemId, + type, + }, + }); + }, + + // Hide detail popover + hideDetailPopover: () => { + set({ activeDetailItem: null }); + }, + + // Search actions + + setSearchQuery: (query: string, conversationOverride?: SessionConversation | null) => { + const conversation = conversationOverride ?? get().conversation; + + if (!query.trim() || !conversation) { + if (isSearchDebugEnabled()) { + console.info('[search] clear', { query }); + } + set({ + searchQuery: query, + searchResultCount: 0, + currentSearchIndex: -1, + searchMatches: [], + searchCurrentDisplayItemId: null, + searchCurrentSubagentItemId: null, + }); + return; + } + + // Build search matches by scanning conversation + // ONLY searches: user message text and AI lastOutput text (not tool results) + // Uses remark-based markdown parsing to extract visible text segments, + // ensuring match counts align with what ReactMarkdown renders. + const matches: SearchMatch[] = []; + const lowerQuery = query.toLowerCase(); + let globalIndex = 0; + + // Helper to find markdown-aware matches and add to matches array + const addMarkdownMatches = ( + text: string, + itemId: string, + itemType: 'user' | 'ai', + displayItemId?: string + ): void => { + const mdMatches = findMarkdownSearchMatches(text, lowerQuery); + for (const mdMatch of mdMatches) { + matches.push({ + itemId, + itemType, + matchIndexInItem: mdMatch.matchIndexInItem, + globalIndex, + displayItemId, + }); + globalIndex++; + } + }; + + for (const item of conversation.items) { + if (item.type === 'user') { + // Search user message text + const text = item.group.content.rawText ?? item.group.content.text ?? ''; + addMarkdownMatches(text, item.group.id, 'user'); + } else if (item.type === 'ai') { + // For AI items: ONLY search lastOutput text (not tool results, thinking, or subagents) + const aiGroup = item.group; + const itemId = aiGroup.id; + const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); + + if (lastOutput?.type === 'text' && lastOutput.text) { + // Last output text - displayItemId indicates this is lastOutput content + addMarkdownMatches(lastOutput.text, itemId, 'ai', 'lastOutput'); + } + // Skip tool_result type - only searching text output + } + // Skip system items entirely + } + + if (isSearchDebugEnabled()) { + const sample = matches.slice(0, 10).map((match) => ({ + itemId: match.itemId, + itemType: match.itemType, + matchIndexInItem: match.matchIndexInItem, + globalIndex: match.globalIndex, + })); + const counts = matches.reduce>((acc, match) => { + acc[`${match.itemType}:${match.itemId}`] = + (acc[`${match.itemType}:${match.itemId}`] ?? 0) + 1; + return acc; + }, {}); + console.info('[search] query', query, 'matches', matches.length); + console.info('[search] counts', counts); + console.info('[search] sample', sample); + } + + set({ + searchQuery: query, + searchResultCount: matches.length, + currentSearchIndex: matches.length > 0 ? 0 : -1, + searchMatches: matches, + }); + }, + + syncSearchMatchesWithRendered: (renderedMatches) => { + const state = get(); + if (!state.searchQuery.trim()) return; + + const dedupedRendered: { itemId: string; matchIndexInItem: number }[] = []; + const seen = new Set(); + for (const rendered of renderedMatches) { + const key = `${rendered.itemId}:${rendered.matchIndexInItem}`; + if (seen.has(key)) continue; + seen.add(key); + dedupedRendered.push(rendered); + } + + const oldMatches = state.searchMatches; + const sameLength = oldMatches.length === dedupedRendered.length; + const sameContent = + sameLength && + oldMatches.every( + (match, index) => + match.itemId === dedupedRendered[index]?.itemId && + match.matchIndexInItem === dedupedRendered[index]?.matchIndexInItem + ); + if (sameContent) return; + + const oldMatchMap = new Map(); + for (const match of oldMatches) { + oldMatchMap.set(`${match.itemId}:${match.matchIndexInItem}`, match); + } + + const nextMatches: SearchMatch[] = dedupedRendered.map((rendered, index) => { + const key = `${rendered.itemId}:${rendered.matchIndexInItem}`; + const previous = oldMatchMap.get(key); + const inferredItemType = rendered.itemId.startsWith('user-') ? 'user' : 'ai'; + return { + itemId: rendered.itemId, + itemType: previous?.itemType ?? inferredItemType, + matchIndexInItem: rendered.matchIndexInItem, + globalIndex: index, + displayItemId: previous?.displayItemId, + }; + }); + + const oldCurrentMatch = + state.currentSearchIndex >= 0 ? oldMatches[state.currentSearchIndex] : undefined; + let newCurrentIndex = -1; + if (oldCurrentMatch) { + newCurrentIndex = nextMatches.findIndex( + (match) => + match.itemId === oldCurrentMatch.itemId && + match.matchIndexInItem === oldCurrentMatch.matchIndexInItem + ); + } + + if (newCurrentIndex < 0) { + if (nextMatches.length === 0) { + newCurrentIndex = -1; + } else if (state.currentSearchIndex < 0) { + newCurrentIndex = 0; + } else { + newCurrentIndex = Math.min(state.currentSearchIndex, nextMatches.length - 1); + } + } + + if (isSearchDebugEnabled()) { + console.info('[search] sync-rendered', { + parsedCount: oldMatches.length, + renderedCount: nextMatches.length, + currentBefore: state.currentSearchIndex, + currentAfter: newCurrentIndex, + }); + } + + set({ + searchMatches: nextMatches, + searchResultCount: nextMatches.length, + currentSearchIndex: newCurrentIndex, + }); + }, + + showSearch: () => { + set({ searchVisible: true }); + }, + + selectSearchMatch: (itemId: string, matchIndexInItem: number) => { + const state = get(); + const targetIndex = state.searchMatches.findIndex( + (match) => match.itemId === itemId && match.matchIndexInItem === matchIndexInItem + ); + + if (targetIndex < 0) { + return false; + } + + set({ currentSearchIndex: targetIndex }); + get().expandForCurrentSearchResult(); + return true; + }, + + hideSearch: () => { + set({ + searchVisible: false, + searchQuery: '', + searchResultCount: 0, + currentSearchIndex: -1, + searchMatches: [], + searchExpandedAIGroupIds: new Set(), + searchExpandedSubagentIds: new Set(), + searchCurrentDisplayItemId: null, + searchCurrentSubagentItemId: null, + }); + }, + + nextSearchResult: () => { + const state = get(); + if (state.searchResultCount > 0) { + const nextIndex = (state.currentSearchIndex + 1) % state.searchResultCount; + set({ currentSearchIndex: nextIndex }); + // Auto-expand any collapsed sections containing the result + get().expandForCurrentSearchResult(); + if (isSearchDebugEnabled()) { + const match = get().searchMatches[nextIndex]; + console.info('[search] next', { + index: nextIndex, + itemId: match?.itemId, + matchIndexInItem: match?.matchIndexInItem, + }); + } + } + }, + + previousSearchResult: () => { + const state = get(); + if (state.searchResultCount > 0) { + const prevIndex = state.currentSearchIndex - 1; + const newIndex = prevIndex < 0 ? state.searchResultCount - 1 : prevIndex; + set({ currentSearchIndex: newIndex }); + // Auto-expand any collapsed sections containing the result + get().expandForCurrentSearchResult(); + if (isSearchDebugEnabled()) { + const match = get().searchMatches[newIndex]; + console.info('[search] prev', { + index: newIndex, + itemId: match?.itemId, + matchIndexInItem: match?.matchIndexInItem, + }); + } + } + }, + + expandForCurrentSearchResult: () => { + const state = get(); + const { currentSearchIndex, searchMatches } = state; + + if (currentSearchIndex < 0 || searchMatches.length === 0) return; + + const currentMatch = searchMatches[currentSearchIndex]; + if (!currentMatch) return; + + // For AI group matches, track the display item ID for highlighting + // Since we only search lastOutput text (always visible), no expansion needed + if (currentMatch.itemType === 'ai') { + set({ + searchCurrentDisplayItemId: currentMatch.displayItemId ?? null, + searchCurrentSubagentItemId: null, + }); + } else { + // For user matches, clear display item IDs + set({ + searchCurrentDisplayItemId: null, + searchCurrentSubagentItemId: null, + }); + } + }, +}); diff --git a/src/renderer/store/slices/notificationSlice.ts b/src/renderer/store/slices/notificationSlice.ts new file mode 100644 index 00000000..58d095b8 --- /dev/null +++ b/src/renderer/store/slices/notificationSlice.ts @@ -0,0 +1,223 @@ +/** + * Notification slice - manages notifications state and actions. + */ + +import { createErrorNavigationRequest, findTabBySessionAndProject } from '@renderer/types/tabs'; +import { createLogger } from '@shared/utils/logger'; + +import { getAllTabs } from '../utils/paneHelpers'; + +import type { AppState } from '../types'; +import type { DetectedError } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:notification'); +const NOTIFICATIONS_FETCH_LIMIT = 200; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface NotificationSlice { + // State + notifications: DetectedError[]; + unreadCount: number; + notificationsLoading: boolean; + notificationsError: string | null; + + // Actions + fetchNotifications: () => Promise; + markNotificationRead: (id: string) => Promise; + markAllNotificationsRead: () => Promise; + deleteNotification: (id: string) => Promise; + clearNotifications: () => Promise; + navigateToError: (error: DetectedError) => void; + openNotificationsTab: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createNotificationSlice: StateCreator = ( + set, + get +) => ({ + // Initial state + notifications: [], + unreadCount: 0, + notificationsLoading: false, + notificationsError: null, + + // Fetch all notifications from main process + fetchNotifications: async () => { + set({ notificationsLoading: true, notificationsError: null }); + try { + // Fetch the full stored history (manager currently caps storage at 100). + const result = await window.electronAPI.notifications.get({ + limit: NOTIFICATIONS_FETCH_LIMIT, + offset: 0, + }); + const notifications = result.notifications || []; + const unreadCount = + typeof result.unreadCount === 'number' && Number.isFinite(result.unreadCount) + ? Math.max(0, Math.floor(result.unreadCount)) + : notifications.filter((n: { isRead: boolean }) => !n.isRead).length; + set({ + notifications, + unreadCount, + notificationsLoading: false, + }); + } catch (error) { + set({ + notificationsError: + error instanceof Error ? error.message : 'Failed to fetch notifications', + notificationsLoading: false, + }); + } + }, + + // Mark a single notification as read + markNotificationRead: async (id: string) => { + try { + const success = await window.electronAPI.notifications.markRead(id); + if (!success) { + await get().fetchNotifications(); + return; + } + // Optimistically update local state + set((state) => { + const notifications = state.notifications.map((n) => + n.id === id ? { ...n, isRead: true } : n + ); + const unreadCount = notifications.filter((n) => !n.isRead).length; + return { notifications, unreadCount }; + }); + } catch (error) { + logger.error('Failed to mark notification as read:', error); + } + }, + + // Mark all notifications as read + markAllNotificationsRead: async () => { + try { + const success = await window.electronAPI.notifications.markAllRead(); + if (!success) { + await get().fetchNotifications(); + return; + } + // Optimistically update local state + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, isRead: true })), + unreadCount: 0, + })); + } catch (error) { + logger.error('Failed to mark all notifications as read:', error); + } + }, + + // Delete a single notification + deleteNotification: async (id: string) => { + try { + const success = await window.electronAPI.notifications.delete(id); + if (!success) { + await get().fetchNotifications(); + return; + } + // Optimistically update local state + set((state) => { + const notifications = state.notifications.filter((n) => n.id !== id); + const unreadCount = notifications.filter((n) => !n.isRead).length; + return { notifications, unreadCount }; + }); + } catch (error) { + logger.error('Failed to delete notification:', error); + } + }, + + // Clear all notifications + clearNotifications: async () => { + try { + const success = await window.electronAPI.notifications.clear(); + if (!success) { + await get().fetchNotifications(); + return; + } + set({ + notifications: [], + unreadCount: 0, + }); + } catch (error) { + logger.error('Failed to clear notifications:', error); + } + }, + + // Navigate to error location in session (deep linking) + navigateToError: (error: DetectedError) => { + const state = get(); + + // Mark the notification as read + void state.markNotificationRead(error.id); + + // Create the navigation request with a fresh nonce + const navRequest = createErrorNavigationRequest( + { + errorId: error.id, + errorTimestamp: error.timestamp, + toolUseId: error.toolUseId, + subagentId: error.subagentId, + lineNumber: error.lineNumber, + }, + 'notification', + error.triggerColor + ); + + // Check if session tab is already open across all panes + const allTabs = getAllTabs(state.paneLayout); + const existingTab = findTabBySessionAndProject(allTabs, error.sessionId, error.projectId); + + if (existingTab) { + // Focus existing tab via setActiveTab for proper sidebar sync + state.setActiveTab(existingTab.id); + // Enqueue navigation request with fresh nonce + state.enqueueTabNavigation(existingTab.id, navRequest); + } else { + // Open new session tab via openTab (properly adds to focused pane) + state.openTab({ + type: 'session', + label: 'Loading...', + projectId: error.projectId, + sessionId: error.sessionId, + }); + + // Enqueue navigation on the newly created tab, then trigger sidebar + // sync + session data fetch via setActiveTab + const newTabId = get().activeTabId; + if (newTabId) { + state.enqueueTabNavigation(newTabId, navRequest); + get().setActiveTab(newTabId); + } + } + }, + + // Open or focus the notifications tab (per-pane singleton) + openNotificationsTab: () => { + const state = get(); + + // Check if notifications tab exists in focused pane + const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId); + const notificationsTab = focusedPane?.tabs.find((t) => t.type === 'notifications'); + if (notificationsTab) { + state.setActiveTab(notificationsTab.id); + // Re-sync in case updates happened while tab was inactive. + void state.fetchNotifications(); + return; + } + + // Create new notifications tab via openTab (which adds to focused pane) + state.openTab({ + type: 'notifications', + label: 'Notifications', + }); + }, +}); diff --git a/src/renderer/store/slices/paneSlice.ts b/src/renderer/store/slices/paneSlice.ts new file mode 100644 index 00000000..85d6628e --- /dev/null +++ b/src/renderer/store/slices/paneSlice.ts @@ -0,0 +1,356 @@ +/** + * Pane slice - manages multi-pane split layout state and actions. + * Each pane has its own tab bar, active tab, and selected tabs. + */ + +import { MAX_PANES } from '@renderer/types/panes'; + +import { + createEmptyPane, + findPane, + findPaneByTabId, + getAllTabs, + insertPane, + removePane, + syncFocusedPaneState, + updatePane, +} from '../utils/paneHelpers'; + +import type { AppState } from '../types'; +import type { PaneLayout } from '@renderer/types/panes'; +import type { Tab } from '@renderer/types/tabs'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface PaneSlice { + // State + paneLayout: PaneLayout; + + // Pane lifecycle + focusPane: (paneId: string) => void; + splitPane: (sourcePaneId: string, tabId: string, direction: 'left' | 'right') => void; + closePane: (paneId: string) => void; + + // Tab movement + moveTabToPane: ( + tabId: string, + sourcePaneId: string, + targetPaneId: string, + insertIndex?: number + ) => void; + moveTabToNewPane: ( + tabId: string, + sourcePaneId: string, + adjacentPaneId: string, + direction: 'left' | 'right' + ) => void; + reorderTabInPane: (paneId: string, fromIndex: number, toIndex: number) => void; + + // Resize + resizePanes: (paneId: string, newWidthFraction: number) => void; + + // Queries + getPaneForTab: (tabId: string) => string | null; + getAllPaneTabs: () => Tab[]; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Sync root-level openTabs/activeTabId/selectedTabIds from the focused pane. + * This maintains backward compatibility for consumers that read root-level state. + */ +function syncRootState(layout: PaneLayout): Record { + const synced = syncFocusedPaneState(layout); + return { + paneLayout: layout, + openTabs: synced.openTabs, + activeTabId: synced.activeTabId, + selectedTabIds: synced.selectedTabIds, + }; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createPaneSlice: StateCreator = (set, get) => ({ + // Initial state: single pane (populated by tabSlice init or first openTab) + paneLayout: { + panes: [ + { + id: 'pane-default', + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 1, + }, + ], + focusedPaneId: 'pane-default', + }, + + focusPane: (paneId: string) => { + const state = get(); + const { paneLayout } = state; + if (paneLayout.focusedPaneId === paneId) return; + + const pane = findPane(paneLayout, paneId); + if (!pane) return; + + const newLayout: PaneLayout = { ...paneLayout, focusedPaneId: paneId }; + set(syncRootState(newLayout)); + + // Trigger sidebar sync for the focused pane's active tab + if (pane.activeTabId) { + get().setActiveTab(pane.activeTabId); + } + }, + + splitPane: (sourcePaneId: string, tabId: string, direction: 'left' | 'right') => { + const state = get(); + const { paneLayout } = state; + + if (paneLayout.panes.length >= MAX_PANES) return; + + const sourcePane = findPane(paneLayout, sourcePaneId); + if (!sourcePane) return; + + const tab = sourcePane.tabs.find((t) => t.id === tabId); + if (!tab) return; + + // Remove tab from source pane + const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); + let newSourceActiveTabId = sourcePane.activeTabId; + if (sourcePane.activeTabId === tabId) { + // Focus adjacent tab in source + const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); + newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; + } + + const updatedSource = { + ...sourcePane, + tabs: newSourceTabs, + activeTabId: newSourceActiveTabId, + selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), + }; + + // Create new pane with the tab + const newPaneId = crypto.randomUUID(); + const newPane = { + ...createEmptyPane(newPaneId), + tabs: [tab], + activeTabId: tab.id, + }; + + // Update layout + let newLayout = updatePane(paneLayout, updatedSource); + + // If source pane is now empty, remove it + if (newSourceTabs.length === 0 && paneLayout.panes.length > 1) { + newLayout = removePane(newLayout, sourcePaneId); + } + + newLayout = insertPane( + newLayout, + updatedSource.id !== sourcePaneId ? paneLayout.panes[0].id : sourcePaneId, + newPane, + direction + ); + newLayout = { ...newLayout, focusedPaneId: newPaneId }; + + set(syncRootState(newLayout)); + + // Sync sidebar for the new pane's active tab + if (tab.type === 'session') { + get().setActiveTab(tab.id); + } + }, + + closePane: (paneId: string) => { + const state = get(); + const { paneLayout } = state; + + if (paneLayout.panes.length <= 1) return; // Can't close the last pane + + const pane = findPane(paneLayout, paneId); + if (!pane) return; + + // Cleanup tab UI state for all tabs in the pane + for (const tab of pane.tabs) { + state.cleanupTabUIState(tab.id); + } + + const newLayout = removePane(paneLayout, paneId); + set(syncRootState(newLayout)); + + // Sync sidebar for the newly focused pane + const focusedPane = findPane(newLayout, newLayout.focusedPaneId); + if (focusedPane?.activeTabId) { + get().setActiveTab(focusedPane.activeTabId); + } + }, + + moveTabToPane: ( + tabId: string, + sourcePaneId: string, + targetPaneId: string, + insertIndex?: number + ) => { + const state = get(); + const { paneLayout } = state; + + if (sourcePaneId === targetPaneId) return; + + const sourcePane = findPane(paneLayout, sourcePaneId); + const targetPane = findPane(paneLayout, targetPaneId); + if (!sourcePane || !targetPane) return; + + const tab = sourcePane.tabs.find((t) => t.id === tabId); + if (!tab) return; + + // Remove from source + const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); + let newSourceActiveTabId = sourcePane.activeTabId; + if (sourcePane.activeTabId === tabId) { + const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); + newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; + } + + // Add to target at insertion index + const newTargetTabs = [...targetPane.tabs]; + if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= newTargetTabs.length) { + newTargetTabs.splice(insertIndex, 0, tab); + } else { + newTargetTabs.push(tab); + } + + let newLayout = updatePane(paneLayout, { + ...sourcePane, + tabs: newSourceTabs, + activeTabId: newSourceActiveTabId, + selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), + }); + newLayout = updatePane(newLayout, { + ...targetPane, + tabs: newTargetTabs, + activeTabId: tab.id, + }); + + // Auto-close source pane if it's empty and not the sole pane + if (newSourceTabs.length === 0 && newLayout.panes.length > 1) { + newLayout = removePane(newLayout, sourcePaneId); + } + + // Focus the target pane + newLayout = { ...newLayout, focusedPaneId: targetPaneId }; + + set(syncRootState(newLayout)); + }, + + moveTabToNewPane: ( + tabId: string, + sourcePaneId: string, + adjacentPaneId: string, + direction: 'left' | 'right' + ) => { + const state = get(); + const { paneLayout } = state; + + if (paneLayout.panes.length >= MAX_PANES) return; + + const sourcePane = findPane(paneLayout, sourcePaneId); + if (!sourcePane) return; + + const tab = sourcePane.tabs.find((t) => t.id === tabId); + if (!tab) return; + + // Remove from source + const newSourceTabs = sourcePane.tabs.filter((t) => t.id !== tabId); + let newSourceActiveTabId = sourcePane.activeTabId; + if (sourcePane.activeTabId === tabId) { + const oldIndex = sourcePane.tabs.findIndex((t) => t.id === tabId); + newSourceActiveTabId = newSourceTabs[oldIndex]?.id ?? newSourceTabs[oldIndex - 1]?.id ?? null; + } + + const newPaneId = crypto.randomUUID(); + const newPane = { + ...createEmptyPane(newPaneId), + tabs: [tab], + activeTabId: tab.id, + }; + + let newLayout = updatePane(paneLayout, { + ...sourcePane, + tabs: newSourceTabs, + activeTabId: newSourceActiveTabId, + selectedTabIds: sourcePane.selectedTabIds.filter((id) => id !== tabId), + }); + + // Auto-close source pane if it's empty and not the sole pane + if (newSourceTabs.length === 0 && newLayout.panes.length > 1) { + newLayout = removePane(newLayout, sourcePaneId); + } + + newLayout = insertPane(newLayout, adjacentPaneId, newPane, direction); + newLayout = { ...newLayout, focusedPaneId: newPaneId }; + + set(syncRootState(newLayout)); + }, + + reorderTabInPane: (paneId: string, fromIndex: number, toIndex: number) => { + const { paneLayout } = get(); + const pane = findPane(paneLayout, paneId); + if (!pane) return; + + if (fromIndex < 0 || fromIndex >= pane.tabs.length) return; + if (toIndex < 0 || toIndex >= pane.tabs.length) return; + if (fromIndex === toIndex) return; + + const newTabs = [...pane.tabs]; + const [moved] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, moved); + + const newLayout = updatePane(paneLayout, { ...pane, tabs: newTabs }); + set(syncRootState(newLayout)); + }, + + resizePanes: (paneId: string, newWidthFraction: number) => { + const { paneLayout } = get(); + const paneIndex = paneLayout.panes.findIndex((p) => p.id === paneId); + if (paneIndex === -1 || paneIndex >= paneLayout.panes.length - 1) return; + + const MIN_FRACTION = 0.1; + const clamped = Math.max( + MIN_FRACTION, + Math.min(1 - MIN_FRACTION * (paneLayout.panes.length - 1), newWidthFraction) + ); + const currentPane = paneLayout.panes[paneIndex]; + const nextPane = paneLayout.panes[paneIndex + 1]; + const combinedWidth = currentPane.widthFraction + nextPane.widthFraction; + const nextWidth = combinedWidth - clamped; + + if (nextWidth < MIN_FRACTION) return; + + const newPanes = paneLayout.panes.map((p, i) => { + if (i === paneIndex) return { ...p, widthFraction: clamped }; + if (i === paneIndex + 1) return { ...p, widthFraction: nextWidth }; + return p; + }); + + set({ paneLayout: { ...paneLayout, panes: newPanes } }); + }, + + getPaneForTab: (tabId: string) => { + const pane = findPaneByTabId(get().paneLayout, tabId); + return pane?.id ?? null; + }, + + getAllPaneTabs: () => { + return getAllTabs(get().paneLayout); + }, +}); diff --git a/src/renderer/store/slices/projectSlice.ts b/src/renderer/store/slices/projectSlice.ts new file mode 100644 index 00000000..989679f2 --- /dev/null +++ b/src/renderer/store/slices/projectSlice.ts @@ -0,0 +1,66 @@ +/** + * Project slice - manages project list state and selection. + */ + +import { getSessionResetState } from '../utils/stateResetHelpers'; + +import type { AppState } from '../types'; +import type { Project } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface ProjectSlice { + // State + projects: Project[]; + selectedProjectId: string | null; + projectsLoading: boolean; + projectsError: string | null; + + // Actions + fetchProjects: () => Promise; + selectProject: (id: string) => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createProjectSlice: StateCreator = (set, get) => ({ + // Initial state + projects: [], + selectedProjectId: null, + projectsLoading: false, + projectsError: null, + + // Fetch all projects from main process + fetchProjects: async () => { + set({ projectsLoading: true, projectsError: null }); + try { + const projects = await window.electronAPI.getProjects(); + // Sort by most recent session (descending) + const sorted = [...projects].sort( + (a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0) + ); + set({ projects: sorted, projectsLoading: false }); + } catch (error) { + set({ + projectsError: error instanceof Error ? error.message : 'Failed to fetch projects', + projectsLoading: false, + }); + } + }, + + // Select a project and fetch its sessions (paginated) + selectProject: (id: string) => { + set({ + selectedProjectId: id, + ...getSessionResetState(), + }); + + // Fetch sessions for this project (paginated) + void get().fetchSessionsInitial(id); + }, +}); diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts new file mode 100644 index 00000000..14476c84 --- /dev/null +++ b/src/renderer/store/slices/repositorySlice.ts @@ -0,0 +1,133 @@ +/** + * Repository slice - manages repository grouping state (worktree support). + */ + +import { createLogger } from '@shared/utils/logger'; + +import { getSessionResetState } from '../utils/stateResetHelpers'; + +import type { AppState } from '../types'; +import type { RepositoryGroup } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:repository'); + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface RepositorySlice { + // State + repositoryGroups: RepositoryGroup[]; + selectedRepositoryId: string | null; + selectedWorktreeId: string | null; + repositoryGroupsLoading: boolean; + repositoryGroupsError: string | null; + viewMode: 'flat' | 'grouped'; + + // Actions + fetchRepositoryGroups: () => Promise; + selectRepository: (repositoryId: string) => void; + selectWorktree: (worktreeId: string) => void; + setViewMode: (mode: 'flat' | 'grouped') => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createRepositorySlice: StateCreator = ( + set, + get +) => ({ + // Initial state + repositoryGroups: [], + selectedRepositoryId: null, + selectedWorktreeId: null, + repositoryGroupsLoading: false, + repositoryGroupsError: null, + viewMode: 'grouped', // Default to grouped view + + // Fetch all repository groups (projects grouped by git repo) + fetchRepositoryGroups: async () => { + set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); + try { + const groups = await window.electronAPI.getRepositoryGroups(); + // Already sorted by most recent session in the scanner + set({ repositoryGroups: groups, repositoryGroupsLoading: false }); + } catch (error) { + set({ + repositoryGroupsError: + error instanceof Error ? error.message : 'Failed to fetch repository groups', + repositoryGroupsLoading: false, + }); + } + }, + + // Select a repository group and auto-select a worktree + selectRepository: (repositoryId: string) => { + const { repositoryGroups } = get(); + const repo = repositoryGroups.find((r) => r.id === repositoryId); + + if (!repo) { + logger.warn('Repository not found:', repositoryId); + return; + } + + // Auto-select worktree: + // 1. Prefer the "Default" worktree (isMainWorktree = true) + // 2. Otherwise, select the first worktree (already sorted by most recent) + const defaultWorktree = repo.worktrees.find((w) => w.isMainWorktree); + const worktreeToSelect = defaultWorktree ?? repo.worktrees[0]; + + if (worktreeToSelect) { + set({ + selectedRepositoryId: repositoryId, + selectedWorktreeId: worktreeToSelect.id, + selectedProjectId: worktreeToSelect.id, + activeProjectId: worktreeToSelect.id, + ...getSessionResetState(), + }); + // Fetch sessions for this worktree + void get().fetchSessionsInitial(worktreeToSelect.id); + } else { + // No worktrees available (shouldn't happen normally) + set({ + selectedRepositoryId: repositoryId, + selectedWorktreeId: null, + ...getSessionResetState(), + }); + } + }, + + // Select a worktree within a repository group + selectWorktree: (worktreeId: string) => { + set({ + selectedWorktreeId: worktreeId, + selectedProjectId: worktreeId, + activeProjectId: worktreeId, + ...getSessionResetState(), + }); + + // Fetch sessions for this worktree + void get().fetchSessionsInitial(worktreeId); + }, + + // Toggle between flat and grouped view modes + setViewMode: (mode: 'flat' | 'grouped') => { + set({ + viewMode: mode, + selectedRepositoryId: null, + selectedWorktreeId: null, + selectedProjectId: null, + ...getSessionResetState(), + }); + + // Fetch the appropriate data for the new mode + if (mode === 'grouped') { + void get().fetchRepositoryGroups(); + } else { + void get().fetchProjects(); + } + }, +}); diff --git a/src/renderer/store/slices/sessionDetailSlice.ts b/src/renderer/store/slices/sessionDetailSlice.ts new file mode 100644 index 00000000..f4542a82 --- /dev/null +++ b/src/renderer/store/slices/sessionDetailSlice.ts @@ -0,0 +1,658 @@ +/** + * Session detail slice - manages session detail, conversation, and stats. + */ + +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { findTabBySession, truncateLabel } from '@renderer/types/tabs'; +import { processSessionClaudeMd } from '@renderer/utils/claudeMdTracker'; +import { processSessionContextWithPhases } from '@renderer/utils/contextTracker'; +import { + extractFileReferences, + transformChunksToConversation, +} from '@renderer/utils/groupTransformer'; +import { createLogger } from '@shared/utils/logger'; + +import { resolveFilePath } from '../utils/pathResolution'; + +const logger = createLogger('Store:sessionDetail'); + +/** + * Tracks latest refresh generation per session to avoid stale overwrites when + * many file-change events trigger concurrent in-place refreshes. + */ +const sessionRefreshGeneration = new Map(); +const sessionRefreshInFlight = new Set(); +const sessionRefreshQueued = new Set(); +let sessionDetailFetchGeneration = 0; + +import { getAllTabs } from '../utils/paneHelpers'; + +import type { AppState } from '../types'; +import type { ClaudeMdStats } from '@renderer/types/claudeMd'; +import type { + ContextPhaseInfo, + ContextStats, + MentionedFileInfo, +} from '@renderer/types/contextInjection'; +import type { ClaudeMdFileInfo, SessionDetail } from '@renderer/types/data'; +import type { AIGroup, SessionConversation } from '@renderer/types/groups'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Per-tab session data type +// ============================================================================= + +export interface TabSessionData { + sessionDetail: SessionDetail | null; + conversation: SessionConversation | null; + conversationLoading: boolean; + sessionDetailLoading: boolean; + sessionDetailError: string | null; + sessionClaudeMdStats: Map | null; + sessionContextStats: Map | null; + sessionPhaseInfo: ContextPhaseInfo | null; + visibleAIGroupId: string | null; + selectedAIGroup: AIGroup | null; +} + +function createEmptyTabSessionData(): TabSessionData { + return { + sessionDetail: null, + conversation: null, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: null, + sessionContextStats: null, + sessionPhaseInfo: null, + visibleAIGroupId: null, + selectedAIGroup: null, + }; +} + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface SessionDetailSlice { + // State + sessionDetail: SessionDetail | null; + sessionDetailLoading: boolean; + sessionDetailError: string | null; + + // Conversation state + conversation: SessionConversation | null; + conversationLoading: boolean; + + // CLAUDE.md stats (injection tracking per AI group) + sessionClaudeMdStats: Map | null; + // Unified context stats (CLAUDE.md + mentioned files + tool outputs) + sessionContextStats: Map | null; + // Context phase info (compaction boundaries) + sessionPhaseInfo: ContextPhaseInfo | null; + + // Visible AI Group + visibleAIGroupId: string | null; + selectedAIGroup: AIGroup | null; + + // Per-tab session data (keyed by tabId) + tabSessionData: Record; + + // Actions + fetchSessionDetail: (projectId: string, sessionId: string, tabId?: string) => Promise; + /** Refresh session without loading states or UI resets - for real-time updates */ + refreshSessionInPlace: (projectId: string, sessionId: string) => Promise; + setVisibleAIGroup: (aiGroupId: string | null) => void; + /** Set visible AI group for a specific tab */ + setTabVisibleAIGroup: (tabId: string, aiGroupId: string | null) => void; + /** Clean up per-tab session data when tab is closed */ + cleanupTabSessionData: (tabId: string) => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createSessionDetailSlice: StateCreator = ( + set, + get +) => ({ + // Initial state + sessionDetail: null, + sessionDetailLoading: false, + sessionDetailError: null, + + conversation: null, + conversationLoading: false, + + // CLAUDE.md stats (injection tracking per AI group) + sessionClaudeMdStats: null, + // Unified context stats (CLAUDE.md + mentioned files + tool outputs) + sessionContextStats: null, + // Context phase info (compaction boundaries) + sessionPhaseInfo: null, + + visibleAIGroupId: null, + selectedAIGroup: null, + + // Per-tab session data + tabSessionData: {}, + + // Fetch full session detail with chunks and subagents + fetchSessionDetail: async (projectId: string, sessionId: string, tabId?: string) => { + const requestGeneration = ++sessionDetailFetchGeneration; + set({ + sessionDetailLoading: true, + sessionDetailError: null, + conversationLoading: true, + }); + + // Also set per-tab loading state + if (tabId) { + const prev = get().tabSessionData; + set({ + tabSessionData: { + ...prev, + [tabId]: { + ...(prev[tabId] ?? createEmptyTabSessionData()), + sessionDetailLoading: true, + sessionDetailError: null, + conversationLoading: true, + }, + }, + }); + } + try { + const detail = await window.electronAPI.getSessionDetail(projectId, sessionId); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + + // Transform chunks to conversation + // Chunks are EnhancedChunk[] at runtime - validate with type guard + // Pass isOngoing to mark the last AI group when session is still in progress + const isOngoing = detail?.session?.isOngoing ?? false; + const enhancedChunks = detail ? asEnhancedChunkArray(detail.chunks) : null; + const conversation: SessionConversation | null = + detail && enhancedChunks + ? transformChunksToConversation(enhancedChunks, detail.processes, isOngoing) + : null; + + // Initialize visibleAIGroupId to first AI Group if available + const firstAIItem = conversation?.items?.find((item) => item.type === 'ai'); + const firstAIGroupId = firstAIItem?.type === 'ai' ? firstAIItem.group.id : null; + const firstAIGroup = firstAIItem?.type === 'ai' ? firstAIItem.group : null; + + // Compute CLAUDE.md stats for the session + const projectRoot = detail?.session?.projectPath ?? ''; + let claudeMdStats: Map | null = null; + let contextStats: Map | null = null; + let phaseInfo: ContextPhaseInfo | null = null; + if (conversation?.items) { + // Fetch real CLAUDE.md token data + let claudeMdTokenData: Record = {}; + try { + claudeMdTokenData = await window.electronAPI.readClaudeMdFiles(projectRoot); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + } catch (err) { + logger.error('Failed to read CLAUDE.md files:', err); + } + + claudeMdStats = processSessionClaudeMd(conversation.items, projectRoot, claudeMdTokenData); + + // Fetch real tokens for directory CLAUDE.md files + // Directory injections are detected dynamically from Read tool paths and aren't in pre-fetched tokenData + // We need to validate these BEFORE calling processSessionContext so both trackers have consistent data + const directoryTokenData: Record = {}; // Validated directory token data + + if (claudeMdStats && claudeMdStats.size > 0) { + // Collect all unique directory injection paths + const directoryPaths = new Set(); + for (const stats of claudeMdStats.values()) { + for (const injection of stats.accumulatedInjections) { + if (injection.source === 'directory') { + directoryPaths.add(injection.path); + } + } + } + + // Fetch real tokens for each directory path (parallel IPC calls) + if (directoryPaths.size > 0) { + const directoryTokens = new Map(); + const nonExistentPaths = new Set(); + + const directoryResults = await Promise.all( + Array.from(directoryPaths).map(async (fullPath) => { + try { + const dirPath = fullPath.replace(/[\\/]CLAUDE\.md$/, ''); + const fileInfo = await window.electronAPI.readDirectoryClaudeMd(dirPath); + return { fullPath, fileInfo, error: false }; + } catch (err) { + logger.error('Failed to read directory CLAUDE.md:', fullPath, err); + return { fullPath, fileInfo: null, error: true }; + } + }) + ); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + + for (const { fullPath, fileInfo, error } of directoryResults) { + if (error || !fileInfo) { + nonExistentPaths.add(fullPath); + } else if (fileInfo.exists && fileInfo.estimatedTokens > 0) { + directoryTokens.set(fullPath, fileInfo.estimatedTokens); + directoryTokenData[fullPath] = fileInfo; + } else { + nonExistentPaths.add(fullPath); + } + } + + // Update stats: set real tokens and REMOVE non-existent files + for (const [, stats] of claudeMdStats.entries()) { + // Filter out non-existent paths + stats.accumulatedInjections = stats.accumulatedInjections.filter( + (inj) => inj.source !== 'directory' || !nonExistentPaths.has(inj.path) + ); + stats.newInjections = stats.newInjections.filter( + (inj) => inj.source !== 'directory' || !nonExistentPaths.has(inj.path) + ); + + // Update tokens for existing files + for (const injection of stats.accumulatedInjections) { + if (injection.source === 'directory' && directoryTokens.has(injection.path)) { + injection.estimatedTokens = directoryTokens.get(injection.path)!; + } + } + for (const injection of stats.newInjections) { + if (injection.source === 'directory' && directoryTokens.has(injection.path)) { + injection.estimatedTokens = directoryTokens.get(injection.path)!; + } + } + + // Recalculate totals and counts + stats.totalEstimatedTokens = stats.accumulatedInjections.reduce( + (sum, inj) => sum + inj.estimatedTokens, + 0 + ); + stats.accumulatedCount = stats.accumulatedInjections.length; + stats.newCount = stats.newInjections.length; + } + } + } + + // Compute unified context stats (CLAUDE.md + mentioned files + tool outputs) + // Extract all mentioned file paths from user groups + const mentionedFilePaths = new Set(); + for (const item of conversation.items) { + if (item.type === 'user' && item.group.content.fileReferences) { + for (const ref of item.group.content.fileReferences) { + // Use resolveFilePath to properly handle ./ and ../ prefixes + const absolutePath = resolveFilePath(projectRoot, ref.path); + mentionedFilePaths.add(absolutePath); + } + } + } + + // Also collect @-mentions from isMeta:true user messages in AI responses + for (const item of conversation.items) { + if (item.type === 'ai') { + for (const msg of item.group.responses) { + if (msg.type !== 'user') continue; + let text = ''; + if (typeof msg.content === 'string') { + text = msg.content; + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) text += block.text; + } + } + if (text) { + for (const ref of extractFileReferences(text)) { + const absolutePath = resolveFilePath(projectRoot, ref.path); + mentionedFilePaths.add(absolutePath); + } + } + } + } + } + + // Fetch token data for each mentioned file (parallel IPC calls) + const mentionedFileTokenData = new Map(); + const mentionedFileResults = await Promise.all( + Array.from(mentionedFilePaths).map(async (filePath) => { + try { + const fileInfo = await window.electronAPI.readMentionedFile(filePath, projectRoot); + return { filePath, fileInfo }; + } catch (err) { + logger.error('Failed to read mentioned file:', filePath, err); + return { filePath, fileInfo: null }; + } + }) + ); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + for (const { filePath, fileInfo } of mentionedFileResults) { + if (fileInfo) { + mentionedFileTokenData.set(filePath, fileInfo); + } + } + + // Process Visible Context with all token data + // Pass validated directory token data so contextTracker can filter non-existent files + const phaseResult = processSessionContextWithPhases( + conversation.items, + projectRoot, + claudeMdTokenData, + mentionedFileTokenData, + directoryTokenData + ); + contextStats = phaseResult.statsMap; + phaseInfo = phaseResult.phaseInfo; + } + + // Update tab label if this session is open in a tab + const currentState = get(); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + const activeTab = currentState.getActiveTab(); + const stillViewingSession = + currentState.selectedSessionId === sessionId || + (activeTab?.type === 'session' && + activeTab.sessionId === sessionId && + activeTab.projectId === projectId); + if (!stillViewingSession) { + set({ + sessionDetailLoading: false, + conversationLoading: false, + }); + return; + } + const existingTab = findTabBySession(currentState.openTabs, sessionId); + if (existingTab && detail) { + const newLabel = detail.session.firstMessage + ? truncateLabel(detail.session.firstMessage) + : `Session ${sessionId.slice(0, 8)}`; + currentState.updateTabLabel(existingTab.id, newLabel); + } + + set({ + sessionDetail: detail, + sessionDetailLoading: false, + conversation, + conversationLoading: false, + visibleAIGroupId: firstAIGroupId, + selectedAIGroup: firstAIGroup, + sessionClaudeMdStats: claudeMdStats, + sessionContextStats: contextStats, + sessionPhaseInfo: phaseInfo, + }); + + // Store per-tab session data + if (tabId) { + const prev = get().tabSessionData; + set({ + tabSessionData: { + ...prev, + [tabId]: { + sessionDetail: detail, + conversation, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: claudeMdStats, + sessionContextStats: contextStats, + sessionPhaseInfo: phaseInfo, + visibleAIGroupId: firstAIGroupId, + selectedAIGroup: firstAIGroup, + }, + }, + }); + } + } catch (error) { + logger.error('fetchSessionDetail error:', error); + if (requestGeneration !== sessionDetailFetchGeneration) { + return; + } + const errorMsg = error instanceof Error ? error.message : 'Failed to fetch session detail'; + set({ + sessionDetailError: errorMsg, + sessionDetailLoading: false, + conversationLoading: false, + }); + + // Store per-tab error state + if (tabId) { + const prev = get().tabSessionData; + set({ + tabSessionData: { + ...prev, + [tabId]: { + ...(prev[tabId] ?? createEmptyTabSessionData()), + sessionDetailError: errorMsg, + sessionDetailLoading: false, + conversationLoading: false, + }, + }, + }); + } + } + }, + + // Refresh session in place without loading states or UI resets + // Used for real-time file change updates to avoid flickering + refreshSessionInPlace: async (projectId: string, sessionId: string) => { + const currentState = get(); + + // Check if any tab is viewing this session (across all panes) + const allTabs = getAllTabs(currentState.paneLayout); + const tabsViewingSession = allTabs.filter( + (t) => t.type === 'session' && t.sessionId === sessionId + ); + + // Only refresh if we're actually viewing this session + if (currentState.selectedSessionId !== sessionId && tabsViewingSession.length === 0) { + return; + } + + const refreshKey = `${projectId}/${sessionId}`; + const generation = (sessionRefreshGeneration.get(refreshKey) ?? 0) + 1; + sessionRefreshGeneration.set(refreshKey, generation); + + // Coalesce duplicate in-flight refreshes for the same session. + if (sessionRefreshInFlight.has(refreshKey)) { + sessionRefreshQueued.add(refreshKey); + return; + } + sessionRefreshInFlight.add(refreshKey); + + try { + const detail = await window.electronAPI.getSessionDetail(projectId, sessionId); + + // Drop stale responses if a newer refresh started while this one was in flight. + if (sessionRefreshGeneration.get(refreshKey) !== generation) { + return; + } + + if (!detail) { + return; + } + + // Transform chunks to conversation - validate with type guard + const isOngoing = detail.session?.isOngoing ?? false; + const enhancedChunks = asEnhancedChunkArray(detail.chunks); + if (!enhancedChunks) { + return; + } + const newConversation = transformChunksToConversation( + enhancedChunks, + detail.processes, + isOngoing + ); + + if (!newConversation) { + return; + } + + const latestState = get(); + const latestActiveTab = latestState.getActiveTab(); + const stillViewingSession = + latestState.selectedSessionId === sessionId || + (latestActiveTab?.type === 'session' && latestActiveTab.sessionId === sessionId); + if (!stillViewingSession) { + return; + } + + // Preserve current visibleAIGroupId if it still exists in new conversation + // Otherwise keep it (it might be scrolled to an item that still exists) + const currentVisibleId = currentState.visibleAIGroupId; + const currentSelectedGroup = currentState.selectedAIGroup; + + // Check if current visible group still exists + const visibleGroupStillExists = + currentVisibleId && + newConversation.items.some( + (item) => item.type === 'ai' && item.group.id === currentVisibleId + ); + + // Find the updated group if it exists + let updatedSelectedGroup = currentSelectedGroup; + if (visibleGroupStillExists && currentVisibleId) { + const foundItem = newConversation.items.find( + (item) => item.type === 'ai' && item.group.id === currentVisibleId + ); + if (foundItem?.type === 'ai') { + updatedSelectedGroup = foundItem.group; + } + } + + // Also update the session's isOngoing in the sessions array + // This keeps the sidebar in sync with the chat view + const updatedSessions = currentState.sessions.map((s) => + s.id === sessionId ? { ...s, isOngoing: detail.session?.isOngoing ?? false } : s + ); + + // Update only the data, preserve UI states + set({ + sessionDetail: detail, + conversation: newConversation, + sessions: updatedSessions, + // Preserve visible group if it still exists, otherwise keep current + ...(visibleGroupStillExists + ? { + selectedAIGroup: updatedSelectedGroup, + } + : {}), + // Note: aiGroupExpansionLevels and expandedStepIds are NOT touched + // so expansion states are preserved + }); + + // Also update per-tab session data for all tabs viewing this session + const latestTabSessionData = { ...get().tabSessionData }; + const latestAllTabs = getAllTabs(get().paneLayout); + for (const tab of latestAllTabs) { + if (tab.type === 'session' && tab.sessionId === sessionId && latestTabSessionData[tab.id]) { + const tabData = latestTabSessionData[tab.id]; + // Preserve per-tab visibleAIGroupId + const tabVisibleId = tabData.visibleAIGroupId; + const tabGroupStillExists = + tabVisibleId && + newConversation.items.some( + (item) => item.type === 'ai' && item.group.id === tabVisibleId + ); + let tabSelectedGroup = tabData.selectedAIGroup; + if (tabGroupStillExists && tabVisibleId) { + const found = newConversation.items.find( + (item) => item.type === 'ai' && item.group.id === tabVisibleId + ); + if (found?.type === 'ai') tabSelectedGroup = found.group; + } + + latestTabSessionData[tab.id] = { + ...tabData, + sessionDetail: detail, + conversation: newConversation, + ...(tabGroupStillExists ? { selectedAIGroup: tabSelectedGroup } : {}), + }; + } + } + set({ tabSessionData: latestTabSessionData }); + } catch (error) { + logger.error('refreshSessionInPlace error:', error); + // Don't set error state - this is a background refresh + } finally { + sessionRefreshInFlight.delete(refreshKey); + if (sessionRefreshQueued.has(refreshKey)) { + sessionRefreshQueued.delete(refreshKey); + void get().refreshSessionInPlace(projectId, sessionId); + } + } + }, + + // Set visible AI Group (called by scroll observer) + setVisibleAIGroup: (aiGroupId: string | null) => { + const state = get(); + + if (aiGroupId === state.visibleAIGroupId) return; + + // Find the AIGroup in the conversation + let selectedAIGroup: AIGroup | null = null; + if (aiGroupId && state.conversation) { + for (const item of state.conversation.items) { + if (item.type === 'ai' && item.group.id === aiGroupId) { + selectedAIGroup = item.group; + break; + } + } + } + + set({ + visibleAIGroupId: aiGroupId, + selectedAIGroup, + }); + }, + + // Set visible AI Group for a specific tab + setTabVisibleAIGroup: (tabId: string, aiGroupId: string | null) => { + const state = get(); + const tabData = state.tabSessionData[tabId]; + if (!tabData) return; + + if (aiGroupId === tabData.visibleAIGroupId) return; + + // Find the AIGroup in the tab's conversation + let selectedAIGroup: AIGroup | null = null; + if (aiGroupId && tabData.conversation) { + for (const item of tabData.conversation.items) { + if (item.type === 'ai' && item.group.id === aiGroupId) { + selectedAIGroup = item.group; + break; + } + } + } + + set({ + tabSessionData: { + ...state.tabSessionData, + [tabId]: { + ...tabData, + visibleAIGroupId: aiGroupId, + selectedAIGroup, + }, + }, + }); + }, + + // Clean up per-tab session data when tab is closed + cleanupTabSessionData: (tabId: string) => { + const prev = get().tabSessionData; + if (!(tabId in prev)) return; + const next = { ...prev }; + delete next[tabId]; + set({ tabSessionData: next }); + }, +}); diff --git a/src/renderer/store/slices/sessionSlice.ts b/src/renderer/store/slices/sessionSlice.ts new file mode 100644 index 00000000..3eb285bf --- /dev/null +++ b/src/renderer/store/slices/sessionSlice.ts @@ -0,0 +1,274 @@ +/** + * Session slice - manages session list state and pagination. + */ + +import { createLogger } from '@shared/utils/logger'; + +import type { AppState } from '../types'; +import type { Session } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +const logger = createLogger('Store:session'); + +/** + * Tracks the latest in-place refresh generation per project. + * Used to guarantee last-write-wins under rapid file change events. + */ +const projectRefreshGeneration = new Map(); + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface SessionSlice { + // State + sessions: Session[]; + selectedSessionId: string | null; + sessionsLoading: boolean; + sessionsError: string | null; + // Pagination state + sessionsCursor: string | null; + sessionsHasMore: boolean; + sessionsTotalCount: number; + sessionsLoadingMore: boolean; + // Pinned sessions + pinnedSessionIds: string[]; + + // Actions + fetchSessions: (projectId: string) => Promise; + fetchSessionsInitial: (projectId: string) => Promise; + fetchSessionsMore: () => Promise; + resetSessionsPagination: () => void; + selectSession: (id: string) => void; + clearSelection: () => void; + /** Refresh sessions list without loading states - for real-time updates */ + refreshSessionsInPlace: (projectId: string) => Promise; + /** Toggle pin/unpin for a session */ + togglePinSession: (sessionId: string) => Promise; + /** Load pinned sessions from config for current project */ + loadPinnedSessions: () => Promise; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createSessionSlice: StateCreator = (set, get) => ({ + // Initial state + sessions: [], + selectedSessionId: null, + sessionsLoading: false, + sessionsError: null, + // Pagination state + sessionsCursor: null, + sessionsHasMore: false, + sessionsTotalCount: 0, + sessionsLoadingMore: false, + // Pinned sessions + pinnedSessionIds: [], + + // Fetch sessions for a specific project (legacy - not paginated) + fetchSessions: async (projectId: string) => { + set({ sessionsLoading: true, sessionsError: null }); + try { + const sessions = await window.electronAPI.getSessions(projectId); + // Sort by createdAt (descending) + const sorted = [...sessions].sort((a, b) => b.createdAt - a.createdAt); + set({ sessions: sorted, sessionsLoading: false }); + } catch (error) { + set({ + sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions', + sessionsLoading: false, + }); + } + }, + + // Fetch initial page of sessions (paginated) + fetchSessionsInitial: async (projectId: string) => { + set({ + sessionsLoading: true, + sessionsError: null, + sessions: [], + sessionsCursor: null, + sessionsHasMore: false, + sessionsTotalCount: 0, + }); + try { + const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, { + includeTotalCount: false, + prefilterAll: false, + }); + set({ + sessions: result.sessions, + sessionsCursor: result.nextCursor, + sessionsHasMore: result.hasMore, + sessionsTotalCount: result.totalCount, + sessionsLoading: false, + }); + + // Load pinned sessions after fetching session list + void get().loadPinnedSessions(); + } catch (error) { + set({ + sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions', + sessionsLoading: false, + }); + } + }, + + // Fetch more sessions (next page) + fetchSessionsMore: async () => { + const state = get(); + const { selectedProjectId, sessionsCursor, sessionsHasMore, sessionsLoadingMore } = state; + + // Guard: don't fetch if already loading, no more pages, or no project + if (!selectedProjectId || !sessionsHasMore || sessionsLoadingMore || !sessionsCursor) { + return; + } + + set({ sessionsLoadingMore: true }); + try { + const result = await window.electronAPI.getSessionsPaginated( + selectedProjectId, + sessionsCursor, + 20, + { + includeTotalCount: false, + prefilterAll: false, + } + ); + set((prevState) => ({ + sessions: [...prevState.sessions, ...result.sessions], + sessionsCursor: result.nextCursor, + sessionsHasMore: result.hasMore, + sessionsLoadingMore: false, + })); + } catch (error) { + set({ + sessionsError: error instanceof Error ? error.message : 'Failed to fetch more sessions', + sessionsLoadingMore: false, + }); + } + }, + + // Reset pagination state + resetSessionsPagination: () => { + set({ + sessions: [], + sessionsCursor: null, + sessionsHasMore: false, + sessionsTotalCount: 0, + sessionsLoadingMore: false, + sessionsError: null, + }); + }, + + // Select a session and fetch its detail + selectSession: (id: string) => { + set({ + selectedSessionId: id, + sessionDetail: null, + sessionContextStats: null, + sessionDetailError: null, + }); + + // Fetch detail for this session, passing the active tabId for per-tab data + const state = get(); + const projectId = state.selectedProjectId; + if (projectId) { + const activeTabId = state.activeTabId ?? undefined; + void state.fetchSessionDetail(projectId, id, activeTabId); + } else { + logger.warn('Cannot fetch session detail: no project selected'); + } + }, + + // Clear all selections + clearSelection: () => { + set({ + selectedProjectId: null, + selectedSessionId: null, + sessions: [], + sessionDetail: null, + sessionContextStats: null, + }); + }, + + // Refresh sessions list in place without loading states + // Used for real-time updates when new sessions are added + refreshSessionsInPlace: async (projectId: string) => { + const currentState = get(); + + // Only refresh if viewing this project + if (currentState.selectedProjectId !== projectId) { + return; + } + + const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1; + projectRefreshGeneration.set(projectId, generation); + + try { + const result = await window.electronAPI.getSessionsPaginated(projectId, null, 20, { + includeTotalCount: false, + prefilterAll: false, + }); + + // Drop stale responses from older in-flight refreshes + if (projectRefreshGeneration.get(projectId) !== generation) { + return; + } + + // Update sessions without loading state + set({ + sessions: result.sessions, + sessionsCursor: result.nextCursor, + sessionsHasMore: result.hasMore, + sessionsTotalCount: result.totalCount, + // Don't touch sessionsLoading - keep it as-is + }); + } catch (error) { + logger.error('refreshSessionsInPlace error:', error); + // Don't set error state - this is a background refresh + } + }, + + // Toggle pin/unpin for a session + togglePinSession: async (sessionId: string) => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId) return; + + const isPinned = state.pinnedSessionIds.includes(sessionId); + + try { + if (isPinned) { + await window.electronAPI.config.unpinSession(projectId, sessionId); + set({ pinnedSessionIds: state.pinnedSessionIds.filter((id) => id !== sessionId) }); + } else { + await window.electronAPI.config.pinSession(projectId, sessionId); + set({ pinnedSessionIds: [sessionId, ...state.pinnedSessionIds] }); + } + } catch (error) { + logger.error('togglePinSession error:', error); + } + }, + + // Load pinned sessions from config for current project + loadPinnedSessions: async () => { + const state = get(); + const projectId = state.selectedProjectId; + if (!projectId) { + set({ pinnedSessionIds: [] }); + return; + } + + try { + const config = await window.electronAPI.config.get(); + const pins = config.sessions?.pinnedSessions?.[projectId] ?? []; + set({ pinnedSessionIds: pins.map((p) => p.sessionId) }); + } catch (error) { + logger.error('loadPinnedSessions error:', error); + set({ pinnedSessionIds: [] }); + } + }, +}); diff --git a/src/renderer/store/slices/subagentSlice.ts b/src/renderer/store/slices/subagentSlice.ts new file mode 100644 index 00000000..06c5dbc6 --- /dev/null +++ b/src/renderer/store/slices/subagentSlice.ts @@ -0,0 +1,143 @@ +/** + * Subagent slice - manages subagent drill-down state. + */ + +import type { AppState, BreadcrumbItem } from '../types'; +import type { SubagentDetail } from '@renderer/types/data'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface SubagentSlice { + // State + drillDownStack: BreadcrumbItem[]; + currentSubagentDetail: SubagentDetail | null; + subagentDetailLoading: boolean; + subagentDetailError: string | null; + + // Actions + drillDownSubagent: ( + projectId: string, + sessionId: string, + subagentId: string, + description: string + ) => Promise; + navigateToBreadcrumb: (index: number) => void; + closeSubagentModal: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createSubagentSlice: StateCreator = (set, get) => ({ + // Initial state + drillDownStack: [], + currentSubagentDetail: null, + subagentDetailLoading: false, + subagentDetailError: null, + + // Drill down into a subagent + drillDownSubagent: async ( + projectId: string, + sessionId: string, + subagentId: string, + description: string + ) => { + set({ subagentDetailLoading: true, subagentDetailError: null }); + try { + const detail = await window.electronAPI.getSubagentDetail(projectId, sessionId, subagentId); + + if (!detail) { + set({ + subagentDetailError: 'Failed to load subagent details', + subagentDetailLoading: false, + }); + return; + } + + // Add to breadcrumb stack + const currentStack = get().drillDownStack; + set({ + drillDownStack: [...currentStack, { id: subagentId, description }], + currentSubagentDetail: detail, + subagentDetailLoading: false, + }); + } catch (error) { + set({ + subagentDetailError: error instanceof Error ? error.message : 'Failed to load subagent', + subagentDetailLoading: false, + }); + } + }, + + // Navigate to a specific breadcrumb (pop stack to that level) + navigateToBreadcrumb: (index: number) => { + const state = get(); + + // If navigating to index 0 or negative, close modal + if (index <= 0) { + set({ + drillDownStack: [], + currentSubagentDetail: null, + subagentDetailError: null, + }); + return; + } + + // Pop stack to the specified index + const newStack = state.drillDownStack.slice(0, index); + + if (newStack.length === 0) { + set({ + drillDownStack: [], + currentSubagentDetail: null, + subagentDetailError: null, + }); + return; + } + + // Reload detail for the target level + const targetItem = newStack[newStack.length - 1]; + const projectId = state.selectedProjectId; + const sessionId = state.selectedSessionId; + + if (!projectId || !sessionId) return; + + set({ subagentDetailLoading: true, subagentDetailError: null }); + + window.electronAPI + .getSubagentDetail(projectId, sessionId, targetItem.id) + .then((detail) => { + if (detail) { + set({ + drillDownStack: newStack, + currentSubagentDetail: detail, + subagentDetailLoading: false, + }); + } else { + set({ + subagentDetailError: 'Failed to load subagent details', + subagentDetailLoading: false, + }); + } + }) + .catch((error) => { + set({ + subagentDetailError: error instanceof Error ? error.message : 'Failed to load subagent', + subagentDetailLoading: false, + }); + }); + }, + + // Close the subagent modal + closeSubagentModal: () => { + set({ + drillDownStack: [], + currentSubagentDetail: null, + subagentDetailError: null, + }); + }, +}); diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts new file mode 100644 index 00000000..a6da3673 --- /dev/null +++ b/src/renderer/store/slices/tabSlice.ts @@ -0,0 +1,740 @@ +/** + * Tab slice - manages tab state and actions. + * + * Facade pattern: All tab mutations operate on the paneLayout and sync + * root-level openTabs/activeTabId/selectedTabIds from the focused pane + * for backward compatibility. + */ + +import { + createSearchNavigationRequest, + findTabBySession, + findTabBySessionAndProject, + truncateLabel, +} from '@renderer/types/tabs'; + +import { + findPane, + findPaneByTabId, + getAllTabs, + removePane as removePaneHelper, + syncFocusedPaneState, + updatePane, +} from '../utils/paneHelpers'; +import { getFullResetState } from '../utils/stateResetHelpers'; + +import type { AppState, SearchNavigationContext } from '../types'; +import type { PaneLayout } from '@renderer/types/panes'; +import type { OpenTabOptions, Tab, TabInput, TabNavigationRequest } from '@renderer/types/tabs'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface TabSlice { + // State (synced from focused pane for backward compat) + openTabs: Tab[]; + activeTabId: string | null; + selectedTabIds: string[]; + + // Project context state + activeProjectId: string | null; + + // Actions + openTab: (tab: TabInput, options?: OpenTabOptions) => void; + closeTab: (tabId: string) => void; + setActiveTab: (tabId: string) => void; + openDashboard: () => void; + getActiveTab: () => Tab | null; + isSessionOpen: (sessionId: string) => boolean; + enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void; + consumeTabNavigation: (tabId: string, requestId: string) => void; + saveTabScrollPosition: (tabId: string, scrollTop: number) => void; + + // Project context actions + setActiveProject: (projectId: string) => void; + + // Per-tab UI state actions + setTabContextPanelVisible: (tabId: string, visible: boolean) => void; + updateTabLabel: (tabId: string, label: string) => void; + + // Multi-select actions + setSelectedTabIds: (ids: string[]) => void; + clearTabSelection: () => void; + + // Bulk close actions + closeOtherTabs: (tabId: string) => void; + closeTabsToRight: (tabId: string) => void; + closeAllTabs: () => void; + closeTabs: (tabIds: string[]) => void; + + // Navigation actions + navigateToSession: ( + projectId: string, + sessionId: string, + fromSearch?: boolean, + searchContext?: SearchNavigationContext + ) => void; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +/** + * Sync root-level state from the focused pane. + */ +function syncFromLayout(layout: PaneLayout): Record { + const synced = syncFocusedPaneState(layout); + return { + paneLayout: layout, + openTabs: synced.openTabs, + activeTabId: synced.activeTabId, + selectedTabIds: synced.selectedTabIds, + }; +} + +/** + * Update a tab in whichever pane contains it, returning the new layout. + */ +function updateTabInLayout( + layout: PaneLayout, + tabId: string, + updater: (tab: Tab) => Tab +): PaneLayout { + const pane = findPaneByTabId(layout, tabId); + if (!pane) return layout; + return updatePane(layout, { + ...pane, + tabs: pane.tabs.map((t) => (t.id === tabId ? updater(t) : t)), + }); +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createTabSlice: StateCreator = (set, get) => ({ + // Initial state (synced from focused pane) + openTabs: [], + activeTabId: null, + selectedTabIds: [], + + // Project context state + activeProjectId: null, + + // Open a tab in the focused pane, or focus existing if sessionId matches (within focused pane) + openTab: (tab: TabInput, options?: OpenTabOptions) => { + const state = get(); + const { paneLayout } = state; + const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId); + if (!focusedPane) return; + + // If opening a session tab, check for duplicates first (unless forceNewTab) + if (tab.type === 'session' && tab.sessionId && !options?.forceNewTab) { + // Check across ALL panes for dedup + const allTabs = getAllTabs(paneLayout); + const existing = findTabBySession(allTabs, tab.sessionId); + if (existing) { + // Focus existing tab (which will also focus its pane) + state.setActiveTab(existing.id); + return; + } + + // Replace active tab if replaceActiveTab option is set or active tab is a dashboard + const activeTab = focusedPane.tabs.find((t) => t.id === focusedPane.activeTabId); + if (activeTab && (options?.replaceActiveTab || activeTab.type === 'dashboard')) { + // Cleanup old tab's state if it was a session tab + if (activeTab.type === 'session') { + state.cleanupTabUIState(activeTab.id); + state.cleanupTabSessionData(activeTab.id); + } + + const replacementTab: Tab = { + ...tab, + id: activeTab.id, + label: truncateLabel(tab.label), + createdAt: Date.now(), + }; + + const updatedPane = { + ...focusedPane, + tabs: focusedPane.tabs.map((t) => (t.id === activeTab.id ? replacementTab : t)), + activeTabId: replacementTab.id, + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + return; + } + } + + // Create new tab with generated id and timestamp + const newTab: Tab = { + ...tab, + id: crypto.randomUUID(), + label: truncateLabel(tab.label), + createdAt: Date.now(), + }; + + const updatedPane = { + ...focusedPane, + tabs: [...focusedPane.tabs, newTab], + activeTabId: newTab.id, + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + }, + + // Close a tab by ID in whichever pane contains it + closeTab: (tabId: string) => { + const state = get(); + const { paneLayout } = state; + const pane = findPaneByTabId(paneLayout, tabId); + if (!pane) return; + + const index = pane.tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + + // Cleanup per-tab UI state and session data + state.cleanupTabUIState(tabId); + state.cleanupTabSessionData(tabId); + + const newTabs = pane.tabs.filter((t) => t.id !== tabId); + + // Determine new active tab within this pane + let newActiveId = pane.activeTabId; + if (pane.activeTabId === tabId) { + newActiveId = newTabs[index]?.id ?? newTabs[index - 1]?.id ?? null; + } + + // If pane becomes empty and it's not the only pane, close the pane + if (newTabs.length === 0 && paneLayout.panes.length > 1) { + state.closePane(pane.id); + return; + } + + // If all tabs across all panes are gone, reset to initial state + const allOtherTabs = paneLayout.panes.filter((p) => p.id !== pane.id).flatMap((p) => p.tabs); + if (newTabs.length === 0 && allOtherTabs.length === 0) { + const updatedPane = { ...pane, tabs: [], activeTabId: null, selectedTabIds: [] }; + const newLayout = updatePane(paneLayout, updatedPane); + set({ + ...syncFromLayout(newLayout), + ...getFullResetState(), + }); + return; + } + + const updatedPane = { + ...pane, + tabs: newTabs, + activeTabId: newActiveId, + selectedTabIds: pane.selectedTabIds.filter((id) => id !== tabId), + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + + // Sync sidebar state for the newly active tab (project, repository, sessions) + if (newActiveId) { + get().setActiveTab(newActiveId); + } + }, + + // Switch focus to an existing tab + // Also syncs sidebar state for session tabs to match the tab's project/session + setActiveTab: (tabId: string) => { + const state = get(); + const { paneLayout } = state; + + // Find which pane contains this tab + const pane = findPaneByTabId(paneLayout, tabId); + if (!pane) return; + + const tab = pane.tabs.find((t) => t.id === tabId); + if (!tab) return; + + // Update pane's activeTabId and focus the pane + const updatedPane = { ...pane, activeTabId: tabId }; + let newLayout = updatePane(paneLayout, updatedPane); + newLayout = { ...newLayout, focusedPaneId: pane.id }; + set(syncFromLayout(newLayout)); + + // For session tabs, sync sidebar state to match + if (tab.type === 'session' && tab.sessionId && tab.projectId) { + const sessionId = tab.sessionId; + const projectId = tab.projectId; + const sessionChanged = state.selectedSessionId !== sessionId; + + // Check if per-tab data is already cached + const cachedTabData = state.tabSessionData[tabId]; + const hasCachedData = cachedTabData?.conversation != null; + + // Find the repository and worktree containing this session + let foundRepo: string | null = null; + let foundWorktree: string | null = null; + + for (const repo of state.repositoryGroups) { + for (const wt of repo.worktrees) { + if (wt.sessions.includes(sessionId)) { + foundRepo = repo.id; + foundWorktree = wt.id; + break; + } + } + if (foundRepo) break; + } + + if (foundRepo && foundWorktree) { + const worktreeChanged = state.selectedWorktreeId !== foundWorktree; + set({ + selectedRepositoryId: foundRepo, + selectedWorktreeId: foundWorktree, + selectedSessionId: sessionId, + activeProjectId: foundWorktree, + selectedProjectId: foundWorktree, + }); + if (worktreeChanged) { + void get().fetchSessionsInitial(foundWorktree); + } + if (sessionChanged) { + if (hasCachedData) { + // Swap global state from per-tab cache (no re-fetch) + set({ + sessionDetail: cachedTabData.sessionDetail, + conversation: cachedTabData.conversation, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, + sessionContextStats: cachedTabData.sessionContextStats, + sessionPhaseInfo: cachedTabData.sessionPhaseInfo, + visibleAIGroupId: cachedTabData.visibleAIGroupId, + selectedAIGroup: cachedTabData.selectedAIGroup, + }); + } else { + void get().fetchSessionDetail(foundWorktree, sessionId, tabId); + } + } + return; + } + + // Fallback: search in flat projects + const project = state.projects.find( + (p) => p.id === projectId || p.sessions.includes(sessionId) + ); + if (project) { + const projectChanged = state.selectedProjectId !== project.id; + set({ + activeProjectId: project.id, + selectedProjectId: project.id, + selectedSessionId: sessionId, + }); + if (projectChanged) { + void get().fetchSessionsInitial(project.id); + } + if (sessionChanged) { + if (hasCachedData) { + // Swap global state from per-tab cache (no re-fetch) + set({ + sessionDetail: cachedTabData.sessionDetail, + conversation: cachedTabData.conversation, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, + sessionContextStats: cachedTabData.sessionContextStats, + sessionPhaseInfo: cachedTabData.sessionPhaseInfo, + visibleAIGroupId: cachedTabData.visibleAIGroupId, + selectedAIGroup: cachedTabData.selectedAIGroup, + }); + } else { + void get().fetchSessionDetail(project.id, sessionId, tabId); + } + } + return; + } + } + }, + + // Open a new dashboard tab in the focused pane + openDashboard: () => { + const state = get(); + const { paneLayout } = state; + const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId); + if (!focusedPane) return; + + const newTab: Tab = { + id: crypto.randomUUID(), + type: 'dashboard', + label: 'Dashboard', + createdAt: Date.now(), + }; + + const updatedPane = { + ...focusedPane, + tabs: [...focusedPane.tabs, newTab], + activeTabId: newTab.id, + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + }, + + // Get the currently active tab (from the focused pane) + getActiveTab: () => { + const state = get(); + const focusedPane = findPane(state.paneLayout, state.paneLayout.focusedPaneId); + if (!focusedPane?.activeTabId) return null; + return focusedPane.tabs.find((t) => t.id === focusedPane.activeTabId) ?? null; + }, + + // Check if a session is already open in any pane + isSessionOpen: (sessionId: string) => { + const allTabs = getAllTabs(get().paneLayout); + return allTabs.some((t) => t.type === 'session' && t.sessionId === sessionId); + }, + + // Enqueue a navigation request on a tab (in whichever pane contains it) + enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => { + const { paneLayout } = get(); + const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({ + ...tab, + pendingNavigation: request, + })); + set(syncFromLayout(newLayout)); + }, + + // Mark a navigation request as consumed + consumeTabNavigation: (tabId: string, requestId: string) => { + const { paneLayout } = get(); + const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => + tab.pendingNavigation?.id === requestId + ? { ...tab, pendingNavigation: undefined, lastConsumedNavigationId: requestId } + : tab + ); + set(syncFromLayout(newLayout)); + }, + + // Save scroll position for a tab + saveTabScrollPosition: (tabId: string, scrollTop: number) => { + const { paneLayout } = get(); + const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({ + ...tab, + savedScrollTop: scrollTop, + })); + set(syncFromLayout(newLayout)); + }, + + // Update a tab's label (used by sessionDetailSlice after fetching session data) + updateTabLabel: (tabId: string, label: string) => { + const { paneLayout } = get(); + const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({ + ...tab, + label, + })); + set(syncFromLayout(newLayout)); + }, + + // Set context panel visibility for a specific tab + setTabContextPanelVisible: (tabId: string, visible: boolean) => { + const { paneLayout } = get(); + const newLayout = updateTabInLayout(paneLayout, tabId, (tab) => ({ + ...tab, + showContextPanel: visible, + })); + set(syncFromLayout(newLayout)); + }, + + // Set multi-selected tab IDs (within the focused pane) + setSelectedTabIds: (ids: string[]) => { + const { paneLayout } = get(); + const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId); + if (!focusedPane) return; + + const updatedPane = { ...focusedPane, selectedTabIds: ids }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + }, + + // Clear multi-selection in the focused pane + clearTabSelection: () => { + const { paneLayout } = get(); + const focusedPane = findPane(paneLayout, paneLayout.focusedPaneId); + if (!focusedPane) return; + + const updatedPane = { ...focusedPane, selectedTabIds: [] }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + }, + + // Close all tabs except the specified one (within the pane containing the tab) + closeOtherTabs: (tabId: string) => { + const state = get(); + const { paneLayout } = state; + const pane = findPaneByTabId(paneLayout, tabId); + if (!pane) return; + + const tabsToClose = pane.tabs.filter((t) => t.id !== tabId); + for (const tab of tabsToClose) { + state.cleanupTabUIState(tab.id); + } + + const keepTab = pane.tabs.find((t) => t.id === tabId); + if (!keepTab) return; + + const updatedPane = { + ...pane, + tabs: [keepTab], + activeTabId: tabId, + selectedTabIds: [], + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + + // Sync sidebar state for the remaining tab + get().setActiveTab(tabId); + }, + + // Close all tabs to the right (within the pane containing the tab) + closeTabsToRight: (tabId: string) => { + const state = get(); + const { paneLayout } = state; + const pane = findPaneByTabId(paneLayout, tabId); + if (!pane) return; + + const index = pane.tabs.findIndex((t) => t.id === tabId); + if (index === -1) return; + + const tabsToClose = pane.tabs.slice(index + 1); + for (const tab of tabsToClose) { + state.cleanupTabUIState(tab.id); + } + + const newTabs = pane.tabs.slice(0, index + 1); + const activeStillExists = newTabs.some((t) => t.id === pane.activeTabId); + const newActiveId = activeStillExists ? pane.activeTabId : tabId; + const updatedPane = { + ...pane, + tabs: newTabs, + activeTabId: newActiveId, + selectedTabIds: [], + }; + const newLayout = updatePane(paneLayout, updatedPane); + set(syncFromLayout(newLayout)); + + // Sync sidebar state for the active tab + if (newActiveId) { + get().setActiveTab(newActiveId); + } + }, + + // Close all tabs across all panes, reset to initial state + closeAllTabs: () => { + const state = get(); + const allTabs = getAllTabs(state.paneLayout); + for (const tab of allTabs) { + state.cleanupTabUIState(tab.id); + state.cleanupTabSessionData(tab.id); + } + + // Reset to single empty pane + const defaultPaneId = state.paneLayout.panes[0]?.id ?? 'pane-default'; + const newLayout: PaneLayout = { + panes: [ + { + id: defaultPaneId, + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 1, + }, + ], + focusedPaneId: defaultPaneId, + }; + + set({ + ...syncFromLayout(newLayout), + ...getFullResetState(), + }); + }, + + // Close multiple tabs by ID (within the pane containing them) + closeTabs: (tabIds: string[]) => { + const state = get(); + const idSet = new Set(tabIds); + + // Cleanup UI state and session data + for (const id of idSet) { + state.cleanupTabUIState(id); + state.cleanupTabSessionData(id); + } + + // Group tabs by pane for batch removal + let { paneLayout } = state; + const panesToRemove: string[] = []; + + for (const pane of paneLayout.panes) { + const remainingTabs = pane.tabs.filter((t) => !idSet.has(t.id)); + + if (remainingTabs.length === pane.tabs.length) continue; // No tabs removed from this pane + + if (remainingTabs.length === 0 && paneLayout.panes.length > 1) { + panesToRemove.push(pane.id); + continue; + } + + // Determine new active tab + let newActiveId = pane.activeTabId; + if (newActiveId && idSet.has(newActiveId)) { + const oldIndex = pane.tabs.findIndex((t) => t.id === newActiveId); + newActiveId = null; + for (let i = oldIndex; i < pane.tabs.length; i++) { + if (!idSet.has(pane.tabs[i].id)) { + newActiveId = pane.tabs[i].id; + break; + } + } + if (!newActiveId) { + for (let i = oldIndex - 1; i >= 0; i--) { + if (!idSet.has(pane.tabs[i].id)) { + newActiveId = pane.tabs[i].id; + break; + } + } + } + newActiveId = newActiveId ?? remainingTabs[0]?.id ?? null; + } + + paneLayout = updatePane(paneLayout, { + ...pane, + tabs: remainingTabs, + activeTabId: newActiveId, + selectedTabIds: pane.selectedTabIds.filter((id) => !idSet.has(id)), + }); + } + + // Check if ALL tabs are now gone + const allRemainingTabs = getAllTabs(paneLayout); + if (allRemainingTabs.length === 0) { + state.closeAllTabs(); + return; + } + + // Remove empty panes + for (const paneId of panesToRemove) { + paneLayout = removePaneHelper(paneLayout, paneId); + } + + set(syncFromLayout(paneLayout)); + + // Sync sidebar state for the new active tab + const newActiveTabId = get().activeTabId; + if (newActiveTabId) { + get().setActiveTab(newActiveTabId); + } + }, + + // Set active project and fetch its sessions + setActiveProject: (projectId: string) => { + set({ activeProjectId: projectId }); + get().selectProject(projectId); + }, + + // Navigate to a session (from search or other sources) + navigateToSession: ( + projectId: string, + sessionId: string, + fromSearch = false, + searchContext?: SearchNavigationContext + ) => { + const state = get(); + + // If different project, select it first + if (state.selectedProjectId !== projectId) { + state.selectProject(projectId); + } + + // Check if session tab is already open in any pane + const allTabs = getAllTabs(state.paneLayout); + const existingTab = + findTabBySessionAndProject(allTabs, sessionId, projectId) ?? + findTabBySession(allTabs, sessionId); + + if (existingTab) { + // Focus existing tab via setActiveTab for proper sidebar sync + state.setActiveTab(existingTab.id); + + // Enqueue search navigation if search context provided + if (searchContext) { + const searchPayload = { + query: searchContext.query, + messageTimestamp: searchContext.messageTimestamp, + matchedText: searchContext.matchedText, + ...(searchContext.targetGroupId !== undefined + ? { targetGroupId: searchContext.targetGroupId } + : {}), + ...(searchContext.targetMatchIndexInItem !== undefined + ? { targetMatchIndexInItem: searchContext.targetMatchIndexInItem } + : {}), + ...(searchContext.targetMatchStartOffset !== undefined + ? { targetMatchStartOffset: searchContext.targetMatchStartOffset } + : {}), + ...(searchContext.targetMessageUuid !== undefined + ? { targetMessageUuid: searchContext.targetMessageUuid } + : {}), + }; + const navRequest = createSearchNavigationRequest({ + ...searchPayload, + }); + state.enqueueTabNavigation(existingTab.id, navRequest); + } + } else { + // Open the session in a new tab + state.openTab({ + type: 'session', + label: 'Loading...', + projectId, + sessionId, + fromSearch, + }); + + // Enqueue search navigation on the newly created tab + if (searchContext) { + const newState = get(); + const newTabId = newState.activeTabId; + if (newTabId) { + const searchPayload = { + query: searchContext.query, + messageTimestamp: searchContext.messageTimestamp, + matchedText: searchContext.matchedText, + ...(searchContext.targetGroupId !== undefined + ? { targetGroupId: searchContext.targetGroupId } + : {}), + ...(searchContext.targetMatchIndexInItem !== undefined + ? { targetMatchIndexInItem: searchContext.targetMatchIndexInItem } + : {}), + ...(searchContext.targetMatchStartOffset !== undefined + ? { targetMatchStartOffset: searchContext.targetMatchStartOffset } + : {}), + ...(searchContext.targetMessageUuid !== undefined + ? { targetMessageUuid: searchContext.targetMessageUuid } + : {}), + }; + const navRequest = createSearchNavigationRequest({ + ...searchPayload, + }); + state.enqueueTabNavigation(newTabId, navRequest); + } + } + + // Fetch session detail for the new tab (with tabId for per-tab data) + const newTabIdForFetch = get().activeTabId ?? undefined; + void state.fetchSessionDetail(projectId, sessionId, newTabIdForFetch); + } + + // If opened from search, clear sidebar selection to deselect + if (fromSearch) { + set({ selectedSessionId: null }); + } + }, +}); diff --git a/src/renderer/store/slices/tabUISlice.ts b/src/renderer/store/slices/tabUISlice.ts new file mode 100644 index 00000000..0f43588d --- /dev/null +++ b/src/renderer/store/slices/tabUISlice.ts @@ -0,0 +1,319 @@ +/** + * Tab UI slice - manages per-tab UI state (expansion states, scroll positions, etc.) + * + * This slice provides COMPLETE isolation of UI state between tabs. Each tab has its + * own independent state for: + * - AI group expansion (collapsed/expanded) + * - Display item expansion within AI groups + * - Subagent trace expansion + * - Context panel visibility + * - Scroll position + * + * The state is keyed by tabId, so opening the same session in two tabs gives each + * tab its own independent UI state. + */ + +import type { AppState } from '../types'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Types +// ============================================================================= + +/** + * UI state for a single tab. + * All values are optional - defaults are applied when reading. + */ +export interface TabUIState { + /** Which AI groups are expanded (by aiGroupId) */ + expandedAIGroupIds: Set; + + /** Which display items within AI groups are expanded: Map> */ + expandedDisplayItemIds: Map>; + + /** Which subagent traces are manually expanded (by subagentId) */ + expandedSubagentTraceIds: Set; + + /** Whether the context panel is visible */ + showContextPanel: boolean; + + /** Selected context phase for filtering (null = current/latest phase) */ + selectedContextPhase: number | null; + + /** Saved scroll position for restoring when switching back to this tab */ + savedScrollTop?: number; +} + +/** + * Creates a default/empty TabUIState. + */ +function createDefaultTabUIState(): TabUIState { + return { + expandedAIGroupIds: new Set(), + expandedDisplayItemIds: new Map(), + expandedSubagentTraceIds: new Set(), + showContextPanel: false, + selectedContextPhase: null, + savedScrollTop: undefined, + }; +} + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface TabUISlice { + /** Per-tab UI states: Map */ + tabUIStates: Map; + + // Initialization & cleanup + /** Initialize UI state for a new tab */ + initTabUIState: (tabId: string) => void; + /** Clean up UI state when a tab is closed */ + cleanupTabUIState: (tabId: string) => void; + + // AI Group expansion (per-tab) + /** Toggle AI group expansion for a specific tab */ + toggleAIGroupExpansionForTab: (tabId: string, aiGroupId: string) => void; + /** Check if AI group is expanded for a specific tab */ + isAIGroupExpandedForTab: (tabId: string, aiGroupId: string) => boolean; + /** Expand AI group for a specific tab (for auto-expand scenarios) */ + expandAIGroupForTab: (tabId: string, aiGroupId: string) => void; + + // Display item expansion (per-tab) + /** Toggle display item expansion within an AI group for a specific tab */ + toggleDisplayItemExpansionForTab: (tabId: string, aiGroupId: string, itemId: string) => void; + /** Get expanded display item IDs for an AI group in a specific tab */ + getExpandedDisplayItemIdsForTab: (tabId: string, aiGroupId: string) => Set; + /** Expand a display item for a specific tab (for auto-expand scenarios) */ + expandDisplayItemForTab: (tabId: string, aiGroupId: string, itemId: string) => void; + + // Subagent trace expansion (per-tab) + /** Toggle subagent trace expansion for a specific tab */ + toggleSubagentTraceExpansionForTab: (tabId: string, subagentId: string) => void; + /** Expand subagent trace for a specific tab (no-op if already expanded) */ + expandSubagentTraceForTab: (tabId: string, subagentId: string) => void; + /** Check if subagent trace is expanded for a specific tab */ + isSubagentTraceExpandedForTab: (tabId: string, subagentId: string) => boolean; + + // Context panel (per-tab) + /** Set context panel visibility for a specific tab */ + setContextPanelVisibleForTab: (tabId: string, visible: boolean) => void; + /** Get context panel visibility for a specific tab */ + isContextPanelVisibleForTab: (tabId: string) => boolean; + + // Context phase selection (per-tab) + /** Set the selected context phase for a specific tab */ + setSelectedContextPhaseForTab: (tabId: string, phase: number | null) => void; + + // Scroll position (per-tab) + /** Save scroll position for a specific tab */ + saveScrollPositionForTab: (tabId: string, scrollTop: number) => void; + /** Get saved scroll position for a specific tab */ + getScrollPositionForTab: (tabId: string) => number | undefined; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createTabUISlice: StateCreator = (set, get) => ({ + tabUIStates: new Map(), + + // ========================================================================== + // Initialization & Cleanup + // ========================================================================== + + initTabUIState: (tabId: string) => { + const state = get(); + if (state.tabUIStates.has(tabId)) return; // Already initialized + + const newMap = new Map(state.tabUIStates); + newMap.set(tabId, createDefaultTabUIState()); + set({ tabUIStates: newMap }); + }, + + cleanupTabUIState: (tabId: string) => { + const state = get(); + if (!state.tabUIStates.has(tabId)) return; + + const newMap = new Map(state.tabUIStates); + newMap.delete(tabId); + set({ tabUIStates: newMap }); + }, + + // ========================================================================== + // AI Group Expansion + // ========================================================================== + + toggleAIGroupExpansionForTab: (tabId: string, aiGroupId: string) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + const newExpandedIds = new Set(tabState.expandedAIGroupIds); + if (newExpandedIds.has(aiGroupId)) { + newExpandedIds.delete(aiGroupId); + } else { + newExpandedIds.add(aiGroupId); + } + + newMap.set(tabId, { ...tabState, expandedAIGroupIds: newExpandedIds }); + set({ tabUIStates: newMap }); + }, + + isAIGroupExpandedForTab: (tabId: string, aiGroupId: string) => { + const tabState = get().tabUIStates.get(tabId); + return tabState?.expandedAIGroupIds.has(aiGroupId) ?? false; + }, + + expandAIGroupForTab: (tabId: string, aiGroupId: string) => { + const state = get(); + const tabState = state.tabUIStates.get(tabId); + if (tabState?.expandedAIGroupIds.has(aiGroupId)) return; // Already expanded + + const newMap = new Map(state.tabUIStates); + const currentTabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + const newExpandedIds = new Set(currentTabState.expandedAIGroupIds); + newExpandedIds.add(aiGroupId); + + newMap.set(tabId, { ...currentTabState, expandedAIGroupIds: newExpandedIds }); + set({ tabUIStates: newMap }); + }, + + // ========================================================================== + // Display Item Expansion + // ========================================================================== + + toggleDisplayItemExpansionForTab: (tabId: string, aiGroupId: string, itemId: string) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + const newDisplayItemMap = new Map(tabState.expandedDisplayItemIds); + const currentSet = newDisplayItemMap.get(aiGroupId) ?? new Set(); + const newSet = new Set(currentSet); + + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + + newDisplayItemMap.set(aiGroupId, newSet); + newMap.set(tabId, { ...tabState, expandedDisplayItemIds: newDisplayItemMap }); + set({ tabUIStates: newMap }); + }, + + getExpandedDisplayItemIdsForTab: (tabId: string, aiGroupId: string) => { + const tabState = get().tabUIStates.get(tabId); + return tabState?.expandedDisplayItemIds.get(aiGroupId) ?? new Set(); + }, + + expandDisplayItemForTab: (tabId: string, aiGroupId: string, itemId: string) => { + const state = get(); + const tabState = state.tabUIStates.get(tabId); + const currentSet = tabState?.expandedDisplayItemIds.get(aiGroupId); + if (currentSet?.has(itemId)) return; // Already expanded + + const newMap = new Map(state.tabUIStates); + const currentTabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + const newDisplayItemMap = new Map(currentTabState.expandedDisplayItemIds); + const newSet = new Set(newDisplayItemMap.get(aiGroupId) ?? new Set()); + newSet.add(itemId); + newDisplayItemMap.set(aiGroupId, newSet); + + newMap.set(tabId, { ...currentTabState, expandedDisplayItemIds: newDisplayItemMap }); + set({ tabUIStates: newMap }); + }, + + // ========================================================================== + // Subagent Trace Expansion + // ========================================================================== + + toggleSubagentTraceExpansionForTab: (tabId: string, subagentId: string) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + const newExpandedIds = new Set(tabState.expandedSubagentTraceIds); + if (newExpandedIds.has(subagentId)) { + newExpandedIds.delete(subagentId); + } else { + newExpandedIds.add(subagentId); + } + + newMap.set(tabId, { ...tabState, expandedSubagentTraceIds: newExpandedIds }); + set({ tabUIStates: newMap }); + }, + + expandSubagentTraceForTab: (tabId: string, subagentId: string) => { + const state = get(); + const tabState = state.tabUIStates.get(tabId) ?? createDefaultTabUIState(); + + // No-op if already expanded + if (tabState.expandedSubagentTraceIds.has(subagentId)) return; + + const newExpandedIds = new Set(tabState.expandedSubagentTraceIds); + newExpandedIds.add(subagentId); + + const newMap = new Map(state.tabUIStates); + newMap.set(tabId, { ...tabState, expandedSubagentTraceIds: newExpandedIds }); + set({ tabUIStates: newMap }); + }, + + isSubagentTraceExpandedForTab: (tabId: string, subagentId: string) => { + const tabState = get().tabUIStates.get(tabId); + return tabState?.expandedSubagentTraceIds.has(subagentId) ?? false; + }, + + // ========================================================================== + // Context Panel + // ========================================================================== + + setContextPanelVisibleForTab: (tabId: string, visible: boolean) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + newMap.set(tabId, { ...tabState, showContextPanel: visible }); + set({ tabUIStates: newMap }); + }, + + isContextPanelVisibleForTab: (tabId: string) => { + const tabState = get().tabUIStates.get(tabId); + return tabState?.showContextPanel ?? false; + }, + + // ========================================================================== + // Context Phase Selection + // ========================================================================== + + setSelectedContextPhaseForTab: (tabId: string, phase: number | null) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + newMap.set(tabId, { ...tabState, selectedContextPhase: phase }); + set({ tabUIStates: newMap }); + }, + + // ========================================================================== + // Scroll Position + // ========================================================================== + + saveScrollPositionForTab: (tabId: string, scrollTop: number) => { + const state = get(); + const newMap = new Map(state.tabUIStates); + const tabState = newMap.get(tabId) ?? createDefaultTabUIState(); + + newMap.set(tabId, { ...tabState, savedScrollTop: scrollTop }); + set({ tabUIStates: newMap }); + }, + + getScrollPositionForTab: (tabId: string) => { + const tabState = get().tabUIStates.get(tabId); + return tabState?.savedScrollTop; + }, +}); diff --git a/src/renderer/store/slices/uiSlice.ts b/src/renderer/store/slices/uiSlice.ts new file mode 100644 index 00000000..8543b507 --- /dev/null +++ b/src/renderer/store/slices/uiSlice.ts @@ -0,0 +1,45 @@ +/** + * UI slice - manages command palette and sidebar state. + */ + +import type { AppState } from '../types'; +import type { StateCreator } from 'zustand'; + +// ============================================================================= +// Slice Interface +// ============================================================================= + +export interface UISlice { + // State + commandPaletteOpen: boolean; + sidebarCollapsed: boolean; + + // Actions + openCommandPalette: () => void; + closeCommandPalette: () => void; + toggleSidebar: () => void; +} + +// ============================================================================= +// Slice Creator +// ============================================================================= + +export const createUISlice: StateCreator = (set) => ({ + // Initial state + commandPaletteOpen: false, + sidebarCollapsed: false, + + // Command palette actions + openCommandPalette: () => { + set({ commandPaletteOpen: true }); + }, + + closeCommandPalette: () => { + set({ commandPaletteOpen: false }); + }, + + // Sidebar actions + toggleSidebar: () => { + set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })); + }, +}); diff --git a/src/renderer/store/types.ts b/src/renderer/store/types.ts new file mode 100644 index 00000000..de0bb833 --- /dev/null +++ b/src/renderer/store/types.ts @@ -0,0 +1,87 @@ +/** + * Store type definitions. + * Contains the combined AppState interface and shared types used across slices. + */ + +import type { ConfigSlice } from './slices/configSlice'; +import type { ConversationSlice } from './slices/conversationSlice'; +import type { NotificationSlice } from './slices/notificationSlice'; +import type { PaneSlice } from './slices/paneSlice'; +import type { ProjectSlice } from './slices/projectSlice'; +import type { RepositorySlice } from './slices/repositorySlice'; +import type { SessionDetailSlice } from './slices/sessionDetailSlice'; +import type { SessionSlice } from './slices/sessionSlice'; +import type { SubagentSlice } from './slices/subagentSlice'; +import type { TabSlice } from './slices/tabSlice'; +import type { TabUISlice } from './slices/tabUISlice'; +import type { UISlice } from './slices/uiSlice'; + +// ============================================================================= +// Shared Types +// ============================================================================= + +/** + * Breadcrumb item for subagent drill-down navigation. + */ +export interface BreadcrumbItem { + id: string; + description: string; +} + +/** + * Represents a single search match in the conversation. + * Only searches: user message text and AI lastOutput text (not tool results, thinking, or subagents) + */ +export interface SearchMatch { + /** ID of the chat item containing this match */ + itemId: string; + /** Type of item ('user' | 'ai') - system items are not searched */ + itemType: 'user' | 'ai'; + /** Which match within this item (0-based) */ + matchIndexInItem: number; + /** Global index across all matches */ + globalIndex: number; + /** Display item ID within the AI group (e.g., "lastOutput") */ + displayItemId?: string; +} + +/** + * Search context for navigating from Command Palette results. + */ +export interface SearchNavigationContext { + /** The search query */ + query: string; + /** Timestamp of the message containing the search match */ + messageTimestamp: number; + /** The matched text */ + matchedText: string; + /** Optional exact target group ID (e.g., "user-..." or "ai-...") */ + targetGroupId?: string; + /** Optional exact match index within the target group's searchable text */ + targetMatchIndexInItem?: number; + /** Optional character offset of the match in the searchable text */ + targetMatchStartOffset?: number; + /** Optional source message UUID for diagnostics/fallback mapping */ + targetMessageUuid?: string; +} + +// ============================================================================= +// Combined AppState Type +// ============================================================================= + +/** + * Combined application state type. + * Combines all slice interfaces into a single unified state type. + */ +export type AppState = ProjectSlice & + RepositorySlice & + SessionSlice & + SessionDetailSlice & + SubagentSlice & + ConversationSlice & + TabSlice & + TabUISlice & + PaneSlice & + UISlice & + NotificationSlice & + ConfigSlice; diff --git a/src/renderer/store/utils/paneHelpers.ts b/src/renderer/store/utils/paneHelpers.ts new file mode 100644 index 00000000..e6e2164b --- /dev/null +++ b/src/renderer/store/utils/paneHelpers.ts @@ -0,0 +1,134 @@ +/** + * Pure utility functions for immutable pane manipulation. + * All functions return new objects (no mutation). + */ + +import type { Pane, PaneLayout } from '@renderer/types/panes'; +import type { Tab } from '@renderer/types/tabs'; + +/** + * Find a pane by its ID. + */ +export function findPane(layout: PaneLayout, paneId: string): Pane | undefined { + return layout.panes.find((p) => p.id === paneId); +} + +/** + * Find which pane contains a given tab. + */ +export function findPaneByTabId(layout: PaneLayout, tabId: string): Pane | undefined { + return layout.panes.find((p) => p.tabs.some((t) => t.id === tabId)); +} + +/** + * Replace a pane immutably in the layout. + */ +export function updatePane(layout: PaneLayout, updatedPane: Pane): PaneLayout { + return { + ...layout, + panes: layout.panes.map((p) => (p.id === updatedPane.id ? updatedPane : p)), + }; +} + +/** + * Remove a pane and redistribute its width to a neighbor. + * If removing the focused pane, focus shifts to the nearest neighbor. + */ +export function removePane(layout: PaneLayout, paneId: string): PaneLayout { + const index = layout.panes.findIndex((p) => p.id === paneId); + if (index === -1 || layout.panes.length <= 1) return layout; + + const removedPane = layout.panes[index]; + const newPanes = layout.panes.filter((p) => p.id !== paneId); + + // Redistribute width to the nearest neighbor + const neighborIndex = index > 0 ? index - 1 : 0; + const redistributed = newPanes.map((p, i) => + i === neighborIndex ? { ...p, widthFraction: p.widthFraction + removedPane.widthFraction } : p + ); + + // Equalize to avoid floating point drift + const equalized = redistributeWidths(redistributed); + + // Update focus if the removed pane was focused + let newFocusedId = layout.focusedPaneId; + if (layout.focusedPaneId === paneId) { + const focusTarget = equalized[Math.min(index, equalized.length - 1)]; + newFocusedId = focusTarget.id; + } + + return { + panes: equalized, + focusedPaneId: newFocusedId, + }; +} + +/** + * Insert a new pane adjacent to an existing pane. + */ +export function insertPane( + layout: PaneLayout, + adjacentPaneId: string, + newPane: Pane, + direction: 'left' | 'right' +): PaneLayout { + const index = layout.panes.findIndex((p) => p.id === adjacentPaneId); + if (index === -1) return layout; + + const insertAt = direction === 'right' ? index + 1 : index; + const newPanes = [...layout.panes]; + newPanes.splice(insertAt, 0, newPane); + + return { + ...layout, + panes: redistributeWidths(newPanes), + }; +} + +/** + * Equalize widths across all panes so they sum to 1. + */ +function redistributeWidths(panes: Pane[]): Pane[] { + if (panes.length === 0) return panes; + const fraction = 1 / panes.length; + return panes.map((p) => ({ ...p, widthFraction: fraction })); +} + +/** + * Extract the focused pane's tab state for root-level sync. + */ +export function syncFocusedPaneState(layout: PaneLayout): { + openTabs: Tab[]; + activeTabId: string | null; + selectedTabIds: string[]; +} { + const focused = findPane(layout, layout.focusedPaneId); + if (!focused) { + return { openTabs: [], activeTabId: null, selectedTabIds: [] }; + } + return { + openTabs: focused.tabs, + activeTabId: focused.activeTabId, + selectedTabIds: focused.selectedTabIds, + }; +} + +/** + * Get all tabs across all panes (flat list). + */ +export function getAllTabs(layout: PaneLayout): Tab[] { + return layout.panes.flatMap((p) => p.tabs); +} + +/** + * Create a new empty pane with a unique ID. + */ +export function createEmptyPane(id: string): Pane { + return { + id, + tabs: [], + activeTabId: null, + selectedTabIds: [], + widthFraction: 0, + }; +} diff --git a/src/renderer/store/utils/pathResolution.ts b/src/renderer/store/utils/pathResolution.ts new file mode 100644 index 00000000..7d520af8 --- /dev/null +++ b/src/renderer/store/utils/pathResolution.ts @@ -0,0 +1,121 @@ +/** + * Path resolution utilities for the store. + */ + +/** + * Resolves a relative path against a base path, handling various path formats. + * Handles: + * - Absolute paths: /full/path/file.tsx (returned as-is) + * - Relative paths with ./: ./apps/foo/bar.tsx (strips ./) + * - Parent paths with ../: ../other/file.tsx (walks up directories) + * - Plain paths: apps/foo/bar.tsx (joins with base) + * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) + */ +export function resolveFilePath(base: string, relativePath: string): string { + // If already absolute, return as-is + if (isAbsolutePath(relativePath)) { + return relativePath; + } + + const cleanBase = trimTrailingSeparator(base); + + // Handle @ prefix (file mention marker) - strip it if present + let cleanRelative = relativePath; + if (cleanRelative.startsWith('@')) { + cleanRelative = cleanRelative.slice(1); + } + + // Tilde paths (~/) are home-relative absolute paths - pass through as-is + // The main process will expand ~ to the actual home directory + if (cleanRelative.startsWith('~/') || cleanRelative.startsWith('~\\') || cleanRelative === '~') { + return cleanRelative; + } + + // Handle ./ prefix (current directory) + if (cleanRelative.startsWith('./')) { + cleanRelative = cleanRelative.slice(2); + } + + // Handle ../ prefixes (parent directory) + const separator = cleanBase.includes('\\') ? '\\' : '/'; + const hasUnixRoot = cleanBase.startsWith('/'); + const hasUncRoot = cleanBase.startsWith('\\\\'); + const normalizedRelative = normalizeSeparators(cleanRelative, separator); + const baseParts = splitPath(cleanBase); + let remainingRelative = normalizedRelative; + + while (remainingRelative.startsWith(`..${separator}`)) { + remainingRelative = remainingRelative.slice(3); + if (baseParts.length > 1) { + baseParts.pop(); + } + } + + // Join the normalized paths + let normalizedBase = baseParts.join(separator); + if (hasUnixRoot && !normalizedBase.startsWith('/')) { + normalizedBase = `/${normalizedBase}`; + } + if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { + normalizedBase = `\\\\${normalizedBase}`; + } + return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; +} + +function isAbsolutePath(input: string): boolean { + return input.startsWith('/') || input.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(input); +} + +function trimTrailingSeparator(input: string): string { + let end = input.length; + while (end > 0) { + const char = input[end - 1]; + if (char !== '/' && char !== '\\') { + break; + } + end--; + } + return input.slice(0, end); +} + +function normalizeSeparators(input: string, separator: '/' | '\\'): string { + let output = ''; + let prevWasSeparator = false; + + for (const char of input) { + const isSeparator = char === '/' || char === '\\'; + if (isSeparator) { + if (!prevWasSeparator) { + output += separator; + } + prevWasSeparator = true; + } else { + output += char; + prevWasSeparator = false; + } + } + + return output; +} + +function splitPath(input: string): string[] { + const parts: string[] = []; + let current = ''; + + for (const char of input) { + if (char === '/' || char === '\\') { + if (current.length > 0) { + parts.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current.length > 0) { + parts.push(current); + } + + return parts; +} diff --git a/src/renderer/store/utils/stateResetHelpers.ts b/src/renderer/store/utils/stateResetHelpers.ts new file mode 100644 index 00000000..7fdc4a0b --- /dev/null +++ b/src/renderer/store/utils/stateResetHelpers.ts @@ -0,0 +1,43 @@ +/** + * Shared state reset helpers to eliminate duplicated reset blocks across slices. + * + * These return partial state objects that can be spread into Zustand `set()` calls. + */ + +import type { AppState } from '../types'; + +/** + * Reset session-related state (sessions list, detail, pagination, context stats). + * Used when switching projects, worktrees, or repositories. + */ +export function getSessionResetState(): Partial { + return { + selectedSessionId: null, + sessionDetail: null, + sessionContextStats: null, + sessions: [], + sessionsError: null, + sessionsCursor: null, + sessionsHasMore: false, + sessionsTotalCount: 0, + sessionsLoadingMore: false, + }; +} + +/** + * Full state reset (session + project + repository + conversation). + * Used when closing all tabs or resetting to initial state. + */ +export function getFullResetState(): Partial { + return { + ...getSessionResetState(), + selectedRepositoryId: null, + selectedWorktreeId: null, + selectedProjectId: null, + activeProjectId: null, + conversation: null, + visibleAIGroupId: null, + selectedAIGroup: null, + sessionClaudeMdStats: null, + }; +} diff --git a/src/renderer/types/api.ts b/src/renderer/types/api.ts new file mode 100644 index 00000000..82d3f0e0 --- /dev/null +++ b/src/renderer/types/api.ts @@ -0,0 +1,8 @@ +/** + * IPC API type definitions for Electron preload bridge. + * + * Re-exports types from shared for backwards compatibility. + * The canonical definitions are in @shared/types/api. + */ + +export { type ClaudeMdFileInfo } from '@shared/types'; diff --git a/src/renderer/types/claudeMd.ts b/src/renderer/types/claudeMd.ts new file mode 100644 index 00000000..9af32b7d --- /dev/null +++ b/src/renderer/types/claudeMd.ts @@ -0,0 +1,74 @@ +/** + * Type definitions for CLAUDE.md injection tracking. + * Tracks system context injections from various sources throughout the session. + */ + +// ============================================================================= +// Source Types +// ============================================================================= + +/** + * Source types for CLAUDE.md injections. + * - enterprise: Enterprise-level configuration + * - user-memory: User's global memory settings (~/.claude/CLAUDE.md) + * - project-memory: Project-level memory + * - project-rules: Project rules configuration + * - project-local: Local project CLAUDE.md (checked into codebase) + * - directory: Directory-specific CLAUDE.md files + */ +export type ClaudeMdSource = + | 'enterprise' + | 'user-memory' + | 'user-rules' + | 'auto-memory' + | 'project-memory' + | 'project-rules' + | 'project-local' + | 'directory'; + +// ============================================================================= +// Injection Types +// ============================================================================= + +/** + * Represents a single CLAUDE.md injection detected in the session. + */ +export interface ClaudeMdInjection { + /** Unique identifier for this injection */ + id: string; + /** File path of the CLAUDE.md source */ + path: string; + /** Source type categorization */ + source: ClaudeMdSource; + /** Human-readable display name */ + displayName: string; + /** Whether this is a global (user-level) injection */ + isGlobal: boolean; + /** Estimated token count (chars / 4) */ + estimatedTokens: number; + /** ID of the AI group where this injection was first seen */ + firstSeenInGroup: string; +} + +// ============================================================================= +// Statistics Types +// ============================================================================= + +/** + * Statistics about CLAUDE.md injections for an AI group. + * Tracks both new injections in the current group and accumulated totals. + */ +export interface ClaudeMdStats { + /** Injections that are new in THIS group */ + newInjections: ClaudeMdInjection[]; + /** All injections accumulated up to and including this group */ + accumulatedInjections: ClaudeMdInjection[]; + /** Total estimated tokens from all accumulated injections */ + totalEstimatedTokens: number; + /** Percentage of context window used (vs input tokens) */ + percentageOfContext: number; + /** Count of new injections in this group */ + newCount: number; + /** Total count of accumulated injections */ + accumulatedCount: number; +} diff --git a/src/renderer/types/contextInjection.ts b/src/renderer/types/contextInjection.ts new file mode 100644 index 00000000..3ad3be56 --- /dev/null +++ b/src/renderer/types/contextInjection.ts @@ -0,0 +1,307 @@ +/** + * Type definitions for unified context injection tracking. + * Extends CLAUDE.md tracking to include mentioned files (@mentions) and tool outputs. + * This provides a comprehensive view of all context sources injected into the conversation. + */ + +import type { ClaudeMdInjection } from './claudeMd'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Maximum tokens to estimate for a mentioned file. + * Files larger than this are capped to prevent unrealistic token estimates. + */ +export const MAX_MENTIONED_FILE_TOKENS = 25000; + +// ============================================================================= +// Mentioned File Types +// ============================================================================= + +/** + * Represents a file mentioned via @-mention that was injected into context. + * Tracks the file path, token estimate, and where it first appeared in the session. + */ +export interface MentionedFileInjection { + /** Unique identifier for this injection */ + id: string; + /** Discriminator for type narrowing */ + category: 'mentioned-file'; + /** Absolute file path of the mentioned file */ + path: string; + /** Relative path or filename for display purposes */ + displayName: string; + /** Estimated token count for this file's content */ + estimatedTokens: number; + /** Turn index where this file was first mentioned */ + firstSeenTurnIndex: number; + /** AI group ID (e.g., "ai-0") where this file was first seen, for navigation */ + firstSeenInGroup: string; + /** Whether the file exists on disk */ + exists: boolean; +} + +/** + * Information about a mentioned file returned from IPC. + * Used to get file metadata before creating a MentionedFileInjection. + */ +export interface MentionedFileInfo { + /** Absolute file path */ + path: string; + /** Whether the file exists on disk */ + exists: boolean; + /** Character count of file content */ + charCount: number; + /** Estimated token count (typically charCount / 4) */ + estimatedTokens: number; +} + +// ============================================================================= +// Tool Output Types +// ============================================================================= + +/** + * Breakdown of tokens contributed by a single tool in a turn. + */ +export interface ToolTokenBreakdown { + /** Name of the tool (e.g., "Read", "Grep", "Bash") */ + toolName: string; + /** Number of tokens in the tool's output */ + tokenCount: number; + /** Whether the tool execution resulted in an error */ + isError: boolean; +} + +/** + * Represents aggregated tool output context for a single AI turn. + * Multiple tools may execute in one turn; this aggregates their token contributions. + */ +export interface ToolOutputInjection { + /** Unique identifier (e.g., "tool-output-ai-0") */ + id: string; + /** Discriminator for type narrowing */ + category: 'tool-output'; + /** Turn index where these tool outputs occurred */ + turnIndex: number; + /** AI group ID for navigation (e.g., "ai-0") */ + aiGroupId: string; + /** Total estimated tokens from all tools in this turn */ + estimatedTokens: number; + /** Number of tools that contributed output */ + toolCount: number; + /** Detailed breakdown of tokens by individual tool */ + toolBreakdown: ToolTokenBreakdown[]; +} + +// ============================================================================= +// Thinking/Text Output Types +// ============================================================================= + +/** + * Breakdown of thinking vs text tokens within a turn. + */ +export interface ThinkingTextBreakdown { + /** Type of content */ + type: 'thinking' | 'text'; + /** Estimated token count */ + tokenCount: number; +} + +/** + * Thinking and Text output token injection for a single turn. + * Aggregates all thinking blocks and text outputs within one AI response turn. + */ +export interface ThinkingTextInjection { + /** Unique identifier (e.g., "thinking-text-ai-0") */ + id: string; + /** Discriminator for type narrowing */ + category: 'thinking-text'; + /** Turn index where this content occurred */ + turnIndex: number; + /** AI group ID for navigation (e.g., "ai-0") */ + aiGroupId: string; + /** Total estimated tokens from thinking + text in this turn */ + estimatedTokens: number; + /** Detailed breakdown of thinking vs text tokens */ + breakdown: ThinkingTextBreakdown[]; +} + +// ============================================================================= +// User Message Types +// ============================================================================= + +/** + * Represents a user message injected into context for a single turn. + * User prompts are a real part of the context window — tracking them + * provides a more complete picture of what consumes tokens. + */ +export interface UserMessageInjection { + /** Unique identifier (e.g., "user-msg-ai-0") */ + id: string; + /** Discriminator for type narrowing */ + category: 'user-message'; + /** Turn index where this user message occurred */ + turnIndex: number; + /** AI group ID for navigation (e.g., "ai-0") */ + aiGroupId: string; + /** Estimated token count for the user message content */ + estimatedTokens: number; + /** First ~80 characters of the message for preview */ + textPreview: string; +} + +// ============================================================================= +// Task Coordination Types +// ============================================================================= + +/** + * Breakdown of tokens contributed by a single task coordination item. + */ +export interface TaskCoordinationBreakdown { + /** Type of task coordination item */ + type: 'teammate-message' | 'send-message' | 'task-tool'; + /** Tool name (e.g., "TeamCreate", "TaskCreate", "SendMessage") */ + toolName?: string; + /** Estimated token count */ + tokenCount: number; + /** Display label (e.g., teammate name, "TaskCreate #3") */ + label: string; +} + +/** + * Represents aggregated task coordination context for a single AI turn. + * Tracks SendMessage, TeamCreate, TaskCreate, and other task tools separately + * from generic tool outputs. + */ +export interface TaskCoordinationInjection { + /** Unique identifier (e.g., "task-coord-ai-0") */ + id: string; + /** Discriminator for type narrowing */ + category: 'task-coordination'; + /** Turn index where these task coordination items occurred */ + turnIndex: number; + /** AI group ID for navigation (e.g., "ai-0") */ + aiGroupId: string; + /** Total estimated tokens from all task coordination items in this turn */ + estimatedTokens: number; + /** Detailed breakdown of tokens by individual item */ + breakdown: TaskCoordinationBreakdown[]; +} + +// ============================================================================= +// Union Types +// ============================================================================= + +/** + * Extended ClaudeMdInjection with category discriminator for union compatibility. + */ +export type ClaudeMdContextInjection = ClaudeMdInjection & { category: 'claude-md' }; + +/** + * Discriminated union of all context injection types. + * Use the `category` field to narrow the type: + * - 'claude-md': CLAUDE.md configuration injections + * - 'mentioned-file': User @-mentioned file injections + * - 'tool-output': Tool execution output injections + * - 'thinking-text': Thinking and text output token injections + * - 'task-coordination': Task coordination tool and message injections + * - 'user-message': User message prompt injections + */ +export type ContextInjection = + | ClaudeMdContextInjection + | MentionedFileInjection + | ToolOutputInjection + | ThinkingTextInjection + | TaskCoordinationInjection + | UserMessageInjection; + +// ============================================================================= +// Statistics Types +// ============================================================================= + +/** + * Token counts broken down by context source category. + */ +export interface TokensByCategory { + /** Tokens from CLAUDE.md injections */ + claudeMd: number; + /** Tokens from mentioned files */ + mentionedFiles: number; + /** Tokens from tool outputs */ + toolOutputs: number; + /** Tokens from thinking blocks and text outputs */ + thinkingText: number; + /** Tokens from task coordination (SendMessage, TeamCreate, TaskCreate, etc.) */ + taskCoordination: number; + /** Tokens from user messages */ + userMessages: number; +} + +/** + * Counts of new injections broken down by context source category. + */ +export interface NewCountsByCategory { + /** Count of new CLAUDE.md injections */ + claudeMd: number; + /** Count of new mentioned file injections */ + mentionedFiles: number; + /** Count of new tool output injections */ + toolOutputs: number; + /** Count of new thinking/text injections */ + thinkingText: number; + /** Count of new task coordination injections */ + taskCoordination: number; + /** Count of new user message injections */ + userMessages: number; +} + +/** + * Comprehensive statistics about context injections for an AI group. + * Tracks both new injections in the current group and accumulated totals, + * with breakdowns by category. + */ +export interface ContextStats { + /** Injections that are new in THIS group */ + newInjections: ContextInjection[]; + /** All injections accumulated up to and including this group */ + accumulatedInjections: ContextInjection[]; + /** Total estimated tokens from all accumulated injections */ + totalEstimatedTokens: number; + /** Token counts broken down by category */ + tokensByCategory: TokensByCategory; + /** Counts of new injections in this group, by category */ + newCounts: NewCountsByCategory; + /** Which context phase this stats belongs to (1-based) */ + phaseNumber?: number; +} + +// ============================================================================= +// Context Phase Types +// ============================================================================= + +/** Token change at a compaction boundary */ +export interface CompactionTokenDelta { + preCompactionTokens: number; + postCompactionTokens: number; + delta: number; // negative = context freed +} + +/** Metadata about a single context phase */ +export interface ContextPhase { + phaseNumber: number; // 1-based + firstAIGroupId: string; + lastAIGroupId: string; + compactGroupId: string | null; // null for phase 1 + startTokens?: number; + endTokens?: number; +} + +/** Session-wide phase information */ +export interface ContextPhaseInfo { + phases: ContextPhase[]; + compactionCount: number; + aiGroupPhaseMap: Map; // aiGroupId → phaseNumber + compactionTokenDeltas: Map; // compactGroupId → delta +} diff --git a/src/renderer/types/data.ts b/src/renderer/types/data.ts new file mode 100644 index 00000000..79f5268a --- /dev/null +++ b/src/renderer/types/data.ts @@ -0,0 +1,136 @@ +/** + * Type definitions for the renderer process. + * + * This module re-exports types from the main process types and adds + * renderer-specific types and utilities. For most uses, import from + * the index.ts barrel file instead. + * + * Import hierarchy: + * - Main types: Domain models, JSONL format, parsed messages, chunks + * - Renderer types: API interfaces, notifications, visualization + */ + +// ============================================================================= +// Re-exports from Main Process Types +// ============================================================================= + +// Domain types +export type { + Project, + RepositoryGroup, + SearchResult, + Session, + SessionMetrics, + Worktree, + WorktreeSource, +} from '@shared/types'; + +// Message types +export type { ParsedMessage } from '@shared/types'; + +// Chunk types +export type { + Chunk, + EnhancedAIChunk, + EnhancedChunk, + EnhancedCompactChunk, + EnhancedSystemChunk, + EnhancedUserChunk, + Process, + SemanticStep, + SessionDetail, + SubagentDetail, +} from '@shared/types'; + +// Chunk type guards +export { isEnhancedAIChunk } from '@shared/types'; + +// JSONL types (for components that need content block types) +export type { ToolUseResultData } from '@shared/types'; + +// ============================================================================= +// Re-exports from Renderer-Specific Types +// ============================================================================= + +// API types +export type { ClaudeMdFileInfo } from './api'; + +// Notification types +export type { + AppConfig, + DetectedError, + NotificationTrigger, + TriggerContentType, + TriggerMatchField, + TriggerMode, + TriggerTestResult, + TriggerTokenType, + TriggerToolName, +} from './notifications'; + +// ============================================================================= +// Renderer-Specific Type Guards +// ============================================================================= + +import type { + Chunk, + EnhancedChunk, + EnhancedCompactChunk, + EnhancedSystemChunk, + EnhancedUserChunk, + ParsedMessage, +} from '@shared/types'; + +/** + * Type guard: Check if message is an assistant message. + */ +export function isAssistantMessage(msg: ParsedMessage): boolean { + return msg.type === 'assistant'; +} + +/** + * Type guard to check if a chunk is an EnhancedUserChunk. + */ +export function isEnhancedUserChunk(chunk: Chunk | EnhancedChunk): chunk is EnhancedUserChunk { + return 'chunkType' in chunk && chunk.chunkType === 'user' && 'rawMessages' in chunk; +} + +/** + * Type guard to check if a chunk is an EnhancedSystemChunk. + */ +export function isEnhancedSystemChunk(chunk: Chunk | EnhancedChunk): chunk is EnhancedSystemChunk { + return 'chunkType' in chunk && chunk.chunkType === 'system' && 'rawMessages' in chunk; +} + +/** + * Type guard to check if a chunk is an EnhancedCompactChunk. + */ +export function isEnhancedCompactChunk( + chunk: Chunk | EnhancedChunk +): chunk is EnhancedCompactChunk { + return 'chunkType' in chunk && chunk.chunkType === 'compact' && 'rawMessages' in chunk; +} + +/** + * Type guard to check if a single chunk is an EnhancedChunk. + * Enhanced chunks have 'chunkType' and 'rawMessages' properties. + */ +function isEnhancedChunk(chunk: Chunk | EnhancedChunk): chunk is EnhancedChunk { + return 'chunkType' in chunk && 'rawMessages' in chunk; +} + +/** + * Type guard to check if an array of chunks are all EnhancedChunks. + * Returns the array typed as EnhancedChunk[] if valid. + */ +export function asEnhancedChunkArray(chunks: Chunk[]): EnhancedChunk[] | null { + if (chunks.length === 0) { + return []; + } + // Check first chunk - if it has enhanced properties, assume all do + // (they come from the same builder) + if (isEnhancedChunk(chunks[0])) { + return chunks as EnhancedChunk[]; + } + return null; +} diff --git a/src/renderer/types/groups.ts b/src/renderer/types/groups.ts new file mode 100644 index 00000000..938af9a7 --- /dev/null +++ b/src/renderer/types/groups.ts @@ -0,0 +1,398 @@ +/** + * Type definitions for the new chat history architecture. + * These types separate user input from AI responses for a chat-style display. + */ + +import type { + ParsedMessage, + Process, + SemanticStep, + SessionMetrics, + ToolUseResultData, +} from './data'; +export type { SemanticStep }; +import type { ClaudeMdStats } from './claudeMd'; +import type { CompactionTokenDelta } from './contextInjection'; +import type { ModelInfo } from '@shared/utils/modelParser'; + +// ============================================================================= +// Expansion Levels +// ============================================================================= + +/** + * AI Group expansion levels for the collapsible UI. + * - collapsed: Show only summary line "1 tool call, 3 messages" + * - items: Show list of items (thinking, tool calls, etc.) + * - full: Show full content of each item + */ +export type AIGroupExpansionLevel = 'collapsed' | 'items' | 'full'; + +// ============================================================================= +// User Group Types +// ============================================================================= + +/** + * Command reference extracted from user input (e.g., /isolate-context, /context). + */ +export interface CommandInfo { + /** Command name without slash (e.g., "isolate-context") */ + name: string; + /** Optional arguments after the command */ + args?: string; + /** Full raw text including slash */ + raw: string; + /** Position in the text where command starts */ + startIndex: number; + /** Position in the text where command ends */ + endIndex: number; +} + +/** + * Image data from user message. + */ +export interface ImageData { + /** Unique identifier */ + id: string; + /** MIME type */ + mediaType: 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'; + /** Base64 encoded data for display */ + data?: string; +} + +/** + * File reference mentioned in user message (e.g., @file.ts). + */ +export interface FileReference { + /** File path */ + path: string; + /** Optional line range */ + lineRange?: { + start: number; + end?: number; + }; + /** Raw text as written */ + raw: string; +} + +/** + * Parsed content from a user message. + */ +export interface UserGroupContent { + /** Plain text content (with commands removed for display) */ + text?: string; + /** Raw text content (original) */ + rawText?: string; + /** Extracted commands */ + commands: CommandInfo[]; + /** Extracted images */ + images: ImageData[]; + /** Extracted file references */ + fileReferences: FileReference[]; +} + +/** + * User Group - represents a user's complete input. + * This is one side of a conversation turn. + */ +export interface UserGroup { + /** Unique identifier */ + id: string; + /** Original ParsedMessage */ + message: ParsedMessage; + /** Timestamp of the message */ + timestamp: Date; + /** Parsed content */ + content: UserGroupContent; + /** Index within the session (for ordering) */ + index: number; +} + +/** + * System Group - represents command output rendered like AI. + */ +export interface SystemGroup { + id: string; + message: ParsedMessage; + timestamp: Date; + commandOutput: string; // Raw output text + commandName?: string; // Optional: extracted command name +} + +// ============================================================================= +// AI Group Types +// ============================================================================= + +/** + * Summary statistics for the collapsed AI Group view. + */ +export interface AIGroupSummary { + /** Preview of thinking content (first ~100 chars) */ + thinkingPreview?: string; + /** Number of tool calls in this group */ + toolCallCount: number; + /** Number of output messages */ + outputMessageCount: number; + /** Number of subagent executions */ + subagentCount: number; + /** Total duration in milliseconds */ + totalDurationMs: number; + /** Total tokens used */ + totalTokens: number; + /** Output tokens */ + outputTokens: number; + /** Cached tokens */ + cachedTokens: number; +} + +/** + * Linked tool item pairing a tool call with its result. + * Includes preview text for display in collapsed/item views. + */ +export interface LinkedToolItem { + /** Tool call ID */ + id: string; + /** Tool name */ + name: string; + /** Tool input parameters */ + input: Record; + /** + * Token count for the tool CALL (what Claude generated). + * From message.usage.output_tokens, proportioned if multiple tools in message. + * For Write: includes file content. For Edit: includes old_string + new_string. + */ + callTokens?: number; + /** Tool result if received */ + result?: { + content: string | unknown[]; + isError: boolean; + toolUseResult?: ToolUseResultData; + /** Pre-computed token count for the result content */ + tokenCount?: number; + }; + /** Preview of input (first 100 chars) */ + inputPreview: string; + /** Preview of output (first 200 chars) */ + outputPreview?: string; + /** When the tool was called */ + startTime: Date; + /** When the result was received */ + endTime?: Date; + /** Duration in milliseconds */ + durationMs?: number; + /** Whether this is an orphaned call (no result) */ + isOrphaned: boolean; + /** Model used for the assistant message containing this tool call */ + sourceModel?: string; + /** + * Skill instructions content for Skill tool calls. + * Contains the follow-up text message starting with "Base directory for this skill:". + * This is captured from isMeta:true user messages with matching sourceToolUseID. + */ + skillInstructions?: string; + /** Pre-computed token count for skill instructions */ + skillInstructionsTokenCount?: number; +} + +/** + * Unified slash item - represents any slash command invocation. + * All slash commands follow the same format: + * /xxx + * xxx + * optional + * + * This includes: + * - Skills (e.g., /isolate-context) + * - Built-in commands (e.g., /model, /context) + * - Plugin commands + * - MCP commands + * - User-defined commands + */ +export interface SlashItem { + /** Unique ID (generated from command message uuid) */ + id: string; + /** Slash name extracted from /xxx */ + name: string; + /** Message content from */ + message?: string; + /** Optional arguments from */ + args?: string; + /** The command message uuid */ + commandMessageUuid: string; + /** Instructions/output content (from follow-up isMeta:true message with parentUuid) */ + instructions?: string; + /** Pre-computed token count for instructions */ + instructionsTokenCount?: number; + /** Timestamp of the command message */ + timestamp: Date; +} + +/** + * Teammate message received from a team member agent. + */ +export interface TeammateMessage { + id: string; + teammateId: string; + color: string; + summary: string; + content: string; + timestamp: Date; + tokenCount?: number; + /** Summary of the SendMessage that triggered this response (reply context) */ + replyToSummary?: string; + /** Tool call ID of the SendMessage that triggered this response */ + replyToToolId?: string; +} + +/** + * Display item for the AI Group - union of possible items to show. + * These are flattened and shown in chronological order. + */ +export type AIGroupDisplayItem = + | { type: 'thinking'; content: string; timestamp: Date; tokenCount?: number } + | { type: 'tool'; tool: LinkedToolItem } + | { type: 'subagent'; subagent: Process } + | { type: 'output'; content: string; timestamp: Date; tokenCount?: number } + | { type: 'slash'; slash: SlashItem } + | { type: 'teammate_message'; teammateMessage: TeammateMessage }; + +/** + * The last output in an AI Group - what user sees as "the answer". + * Either text output, the last tool result, an interruption, ongoing (still in progress), + * or plan_exit (ExitPlanMode tool call with plan content). + */ +export interface AIGroupLastOutput { + /** Output type */ + type: 'text' | 'tool_result' | 'interruption' | 'ongoing' | 'plan_exit'; + /** Text content if type === 'text' */ + text?: string; + /** Tool name if type === 'tool_result' */ + toolName?: string; + /** Tool result content if type === 'tool_result' */ + toolResult?: string; + /** Whether the tool result was an error */ + isError?: boolean; + /** Interruption message text if type === 'interruption' */ + interruptionMessage?: string; + /** Plan content if type === 'plan_exit' (from ExitPlanMode tool input) */ + planContent?: string; + /** Preamble text before plan exit (e.g., "The plan is complete. Let me exit plan mode...") */ + planPreamble?: string; + /** Timestamp of this output */ + timestamp: Date; +} + +/** + * Enhanced AI Group with display-ready data for the new UI. + * Extends the base AIGroup with computed properties for rendering. + */ +export interface EnhancedAIGroup extends AIGroup { + /** The last visible output (text or tool result) */ + lastOutput: AIGroupLastOutput | null; + /** Flattened display items in chronological order */ + displayItems: AIGroupDisplayItem[]; + /** Map of tool call IDs to linked tool items */ + linkedTools: Map; + /** Human-readable summary of items (e.g., "2 thinking, 4 tool calls, 3 subagents") */ + itemsSummary: string; + /** Model used by main agent (most common if mixed) */ + mainModel: ModelInfo | null; + /** Unique models used by subagents (if different from main) */ + subagentModels: ModelInfo[]; + /** CLAUDE.md injection statistics for this group */ + claudeMdStats: ClaudeMdStats | null; +} + +/** + * Status of an AI Group. + */ +export type AIGroupStatus = 'complete' | 'interrupted' | 'error' | 'in_progress'; + +/** + * Token metrics for an AI Group. + */ +export interface AIGroupTokens { + input: number; + output: number; + cached: number; + thinking?: number; +} + +/** + * AI Group - represents a single assistant response cycle. + * AI Groups are independent items in the flat conversation list. + */ +export interface AIGroup { + /** Unique identifier */ + id: string; + /** 0-based index of this AI group within the session (for turn navigation) */ + turnIndex: number; + /** Start timestamp */ + startTime: Date; + /** End timestamp */ + endTime: Date; + /** Duration in milliseconds */ + durationMs: number; + /** Semantic steps within this response */ + steps: SemanticStep[]; + /** Token metrics */ + tokens: AIGroupTokens; + /** Summary for collapsed view */ + summary: AIGroupSummary; + /** Completion status */ + status: AIGroupStatus; + /** Associated processes */ + processes: Process[]; + /** Source chunk ID */ + chunkId: string; + /** Metrics for this AI response (summed across all messages) */ + metrics: SessionMetrics; + /** All response messages (assistant + internal user messages) for accessing raw usage data */ + responses: ParsedMessage[]; + /** Whether this is the last AI group in an ongoing session */ + isOngoing?: boolean; +} + +// ============================================================================= +// Conversation Types +// ============================================================================= + +/** + * Compact Group - marks where conversation was compacted. + * Contains the compact summary message with the conversation summary. + */ +export interface CompactGroup { + id: string; + timestamp: Date; + message: ParsedMessage; // Contains compact summary in message.content + tokenDelta?: CompactionTokenDelta; + startingPhaseNumber?: number; +} + +/** + * Chat item - can be user, system, ai, or compact. + * These are INDEPENDENT items in a flat list, not paired turns. + */ +export type ChatItem = + | { type: 'user'; group: UserGroup } + | { type: 'system'; group: SystemGroup } + | { type: 'ai'; group: AIGroup } + | { type: 'compact'; group: CompactGroup }; + +/** + * Session conversation as a flat list of independent chat items. + * NO LONGER uses turns - each item stands alone. + */ +export interface SessionConversation { + /** Session ID */ + sessionId: string; + /** All chat items in chronological order */ + items: ChatItem[]; + /** Total count of user groups */ + totalUserGroups: number; + /** Total count of system groups */ + totalSystemGroups: number; + /** Total count of AI groups */ + totalAIGroups: number; + /** Total count of compact groups */ + totalCompactGroups: number; +} diff --git a/src/renderer/types/notifications.ts b/src/renderer/types/notifications.ts new file mode 100644 index 00000000..ad9aadaf --- /dev/null +++ b/src/renderer/types/notifications.ts @@ -0,0 +1,18 @@ +/** + * Notification and configuration types for Claude Code Context. + * + * Re-exports types from shared for backwards compatibility. + * The canonical definitions are in @shared/types/notifications. + */ + +export { + type AppConfig, + type DetectedError, + type NotificationTrigger, + type TriggerContentType, + type TriggerMatchField, + type TriggerMode, + type TriggerTestResult, + type TriggerTokenType, + type TriggerToolName, +} from '@shared/types'; diff --git a/src/renderer/types/panes.ts b/src/renderer/types/panes.ts new file mode 100644 index 00000000..6f061a4c --- /dev/null +++ b/src/renderer/types/panes.ts @@ -0,0 +1,35 @@ +/** + * Pane type definitions for the multi-pane split layout feature. + * Supports up to MAX_PANES horizontal panes, each with its own TabBar and tab state. + */ + +import type { Tab } from './tabs'; + +export const MAX_PANES = 4; + +/** + * Represents a single pane in the split layout. + * Each pane has its own set of tabs and active tab. + */ +export interface Pane { + /** Unique identifier (UUID) */ + id: string; + /** Tabs in this pane */ + tabs: Tab[]; + /** Active tab within this pane */ + activeTabId: string | null; + /** Multi-selected tabs within this pane */ + selectedTabIds: string[]; + /** Width as fraction of total (0-1, sum of all panes = 1) */ + widthFraction: number; +} + +/** + * The overall pane layout state. + */ +export interface PaneLayout { + /** Ordered left-to-right panes */ + panes: Pane[]; + /** Which pane receives keyboard/sidebar actions */ + focusedPaneId: string; +} diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts new file mode 100644 index 00000000..4e60603e --- /dev/null +++ b/src/renderer/types/tabs.ts @@ -0,0 +1,236 @@ +/** + * Tab type definitions for the tabbed layout feature. + * Based on specs/001-tabbed-layout-dashboard/contracts/tab-state.ts + */ + +import type { Session } from './data'; +import type { TriggerColor } from '@shared/constants/triggerColors'; + +// ============================================================================= +// Navigation Request Types +// ============================================================================= + +/** + * Payload for error-based navigation (from notifications or trigger preview). + */ +export interface ErrorNavigationPayload { + /** Error ID for tracking */ + errorId: string; + /** Error timestamp for finding the correct AI group */ + errorTimestamp: number; + /** Tool use ID for precise tool item highlighting */ + toolUseId?: string; + /** Subagent ID for subagent-aware group lookup */ + subagentId?: string; + /** Line number (fallback) */ + lineNumber?: number; +} + +/** + * Payload for search-based navigation (from Command Palette). + */ +export interface SearchNavigationPayload { + /** The search query */ + query: string; + /** Timestamp of the message containing the search match */ + messageTimestamp: number; + /** The matched text */ + matchedText: string; + /** Optional exact target group ID (e.g., "user-..." or "ai-...") */ + targetGroupId?: string; + /** Optional exact match index within the target group's searchable text */ + targetMatchIndexInItem?: number; + /** Optional character offset of the match in the searchable text */ + targetMatchStartOffset?: number; + /** Optional source message UUID for diagnostics/fallback mapping */ + targetMessageUuid?: string; +} + +/** + * Unified tab navigation request. + * Each click/action creates a new request with a unique nonce (id). + * The nonce ensures repeated clicks produce new navigations. + */ +export interface TabNavigationRequest { + /** Unique nonce per click/action (crypto.randomUUID) */ + id: string; + /** Kind of navigation */ + kind: 'error' | 'search' | 'autoBottom'; + /** Source of the navigation action */ + source: 'notification' | 'triggerPreview' | 'commandPalette' | 'sessionOpen'; + /** Highlight color to use */ + highlight: TriggerColor | 'yellow' | 'none'; + /** Navigation payload (depends on kind) */ + payload: ErrorNavigationPayload | SearchNavigationPayload | Record; +} + +// ============================================================================= +// Core Types +// ============================================================================= + +/** + * Represents a single open tab in the main content area + */ +export interface Tab { + /** Unique identifier (UUID v4) */ + id: string; + + /** Type of content displayed in this tab */ + type: 'session' | 'dashboard' | 'notifications' | 'settings'; + + /** Session ID (required when type === 'session') */ + sessionId?: string; + + /** Project ID (required when type === 'session') */ + projectId?: string; + + /** Display name for the tab (max 50 chars) */ + label: string; + + /** Unix timestamp when tab was opened */ + createdAt: number; + + /** Whether this tab was opened from CommandPalette search */ + fromSearch?: boolean; + + /** Pending navigation request (replaces legacy deep-link fields) */ + pendingNavigation?: TabNavigationRequest; + + /** ID of the last consumed navigation request (prevents re-processing) */ + lastConsumedNavigationId?: string; + + /** Saved scroll position for restoring when tab becomes active again */ + savedScrollTop?: number; + + /** Whether the Context panel is shown (per-tab UI state) */ + showContextPanel?: boolean; +} + +/** + * Options for opening a tab + */ +export interface OpenTabOptions { + /** Force open in new tab even if session already exists (e.g., for Ctrl+click) */ + forceNewTab?: boolean; + /** Replace the current active tab instead of creating a new one */ + replaceActiveTab?: boolean; +} + +/** + * Input type for creating a new tab (id and createdAt are auto-generated) + */ +export type TabInput = Omit; + +/** + * Categories for date-based session grouping + */ +export type DateCategory = 'Today' | 'Yesterday' | 'Previous 7 Days' | 'Older'; + +/** + * Sessions grouped by relative date category + */ +export type DateGroupedSessions = Record; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Maximum characters for tab label before truncation */ +const TAB_LABEL_MAX_LENGTH = 50; + +/** Date category order for rendering */ +export const DATE_CATEGORY_ORDER: DateCategory[] = [ + 'Today', + 'Yesterday', + 'Previous 7 Days', + 'Older', +]; + +// ============================================================================= +// Validation Helpers +// ============================================================================= + +/** + * Find tab by session ID (for backwards compatibility) + * NOTE: Prefer findTabBySessionAndProject when projectId is available + */ +export function findTabBySession(tabs: Tab[], sessionId: string): Tab | undefined { + return tabs.find((t) => t.type === 'session' && t.sessionId === sessionId); +} + +/** + * Find tab by both session ID AND project ID + * This prevents finding a tab with the same sessionId but different project + * (e.g., same filename in different repositories) + */ +export function findTabBySessionAndProject( + tabs: Tab[], + sessionId: string, + projectId: string +): Tab | undefined { + return tabs.find( + (t) => t.type === 'session' && t.sessionId === sessionId && t.projectId === projectId + ); +} + +/** + * Truncate label to max length with ellipsis + */ +export function truncateLabel(label: string): string { + if (label.length <= TAB_LABEL_MAX_LENGTH) return label; + return label.slice(0, TAB_LABEL_MAX_LENGTH - 1) + '…'; +} + +// ============================================================================= +// Navigation Request Helpers +// ============================================================================= + +/** + * Create an error navigation request (from notification click or trigger preview). + */ +export function createErrorNavigationRequest( + payload: ErrorNavigationPayload, + source: 'notification' | 'triggerPreview' = 'notification', + highlightColor?: TriggerColor +): TabNavigationRequest { + return { + id: crypto.randomUUID(), + kind: 'error', + source, + highlight: highlightColor ?? 'red', + payload, + }; +} + +/** + * Create a search navigation request (from Command Palette). + */ +export function createSearchNavigationRequest( + payload: SearchNavigationPayload +): TabNavigationRequest { + return { + id: crypto.randomUUID(), + kind: 'search', + source: 'commandPalette', + highlight: 'yellow', + payload, + }; +} + +/** + * Type guard for error navigation payload. + */ +export function isErrorPayload( + request: TabNavigationRequest +): request is TabNavigationRequest & { payload: ErrorNavigationPayload } { + return request.kind === 'error'; +} + +/** + * Type guard for search navigation payload. + */ +export function isSearchPayload( + request: TabNavigationRequest +): request is TabNavigationRequest & { payload: SearchNavigationPayload } { + return request.kind === 'search'; +} diff --git a/src/renderer/utils/aiGroupEnhancer.ts b/src/renderer/utils/aiGroupEnhancer.ts new file mode 100644 index 00000000..a5c1dd51 --- /dev/null +++ b/src/renderer/utils/aiGroupEnhancer.ts @@ -0,0 +1,78 @@ +/** + * AI Group Enhancer - Orchestrator for AI Group enhancement + * + * This module transforms raw AIGroup data into EnhancedAIGroup with display-ready + * properties for the chat-style UI. It coordinates between specialized utility modules: + * - lastOutputDetector: Find the last visible output + * - slashCommandExtractor: Handle slash command extraction + * - toolLinkingEngine: Link tool calls to their results + * - displayItemBuilder: Build display items from steps/messages + * - modelExtractor: Extract model information + * - displaySummary: Build human-readable summaries + * - aiGroupHelpers: Small utility functions + */ + +// Import from specialized modules +import { attachMainSessionImpact } from './aiGroupHelpers'; +import { buildDisplayItems } from './displayItemBuilder'; +import { buildSummary } from './displaySummary'; +import { findLastOutput } from './lastOutputDetector'; +import { extractMainModel, extractSubagentModels } from './modelExtractor'; +import { type PrecedingSlashInfo } from './slashCommandExtractor'; +import { linkToolCallsToResults } from './toolLinkingEngine'; + +import type { ClaudeMdStats } from '../types/claudeMd'; +import type { AIGroup, EnhancedAIGroup } from '../types/groups'; + +// Re-export types and functions that are part of the public API +export { truncateText } from './aiGroupHelpers'; +export { buildDisplayItems, buildDisplayItemsFromMessages } from './displayItemBuilder'; +export { buildSummary } from './displaySummary'; +export { findLastOutput } from './lastOutputDetector'; +export { type PrecedingSlashInfo } from './slashCommandExtractor'; +export { linkToolCallsToResults } from './toolLinkingEngine'; + +/** + * Main enhancement function - transforms AIGroup into EnhancedAIGroup. + * + * This is the primary entry point that ties together all the helper functions + * to produce a display-ready enhanced group. + * + * @param aiGroup - Base AI Group to enhance + * @param claudeMdStats - Optional CLAUDE.md injection stats for this group + * @param precedingSlash - Optional slash info from the preceding UserGroup + * @returns Enhanced AI Group with display data + */ +export function enhanceAIGroup( + aiGroup: AIGroup, + claudeMdStats?: ClaudeMdStats, + precedingSlash?: PrecedingSlashInfo +): EnhancedAIGroup { + // Pass isOngoing to findLastOutput - if ongoing, it returns 'ongoing' type instead of forcing a last output + const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); + // Pass responses to linkToolCallsToResults for slash instruction extraction + const linkedTools = linkToolCallsToResults(aiGroup.steps, aiGroup.responses); + // Attach main session impact tokens to subagents (Task tool call/result tokens) + attachMainSessionImpact(aiGroup.processes, linkedTools); + const displayItems = buildDisplayItems( + aiGroup.steps, + lastOutput, + aiGroup.processes, + aiGroup.responses, + precedingSlash + ); + const summary = buildSummary(displayItems); + const mainModel = extractMainModel(aiGroup.steps); + const subagentModels = extractSubagentModels(aiGroup.processes, mainModel); + + return { + ...aiGroup, + lastOutput, + linkedTools, + displayItems, + itemsSummary: summary, + mainModel, + subagentModels, + claudeMdStats: claudeMdStats ?? null, + }; +} diff --git a/src/renderer/utils/aiGroupHelpers.ts b/src/renderer/utils/aiGroupHelpers.ts new file mode 100644 index 00000000..f1448ba3 --- /dev/null +++ b/src/renderer/utils/aiGroupHelpers.ts @@ -0,0 +1,100 @@ +/** + * AI Group Helpers - Utility functions for AI Group enhancement + * + * Small, focused utility functions used across the AI Group enhancement modules. + */ + +import { createLogger } from '@shared/utils/logger'; +import { estimateTokens } from '@shared/utils/tokenFormatting'; + +import type { Process } from '../types/data'; +import type { LinkedToolItem } from '../types/groups'; + +const logger = createLogger('Util:aiGroupHelpers'); + +// Re-export for backwards compatibility +export { estimateTokens }; + +/** + * Safely converts a timestamp to a Date object. + * Handles both Date objects and ISO string timestamps (from IPC serialization). + */ +export function toDate(timestamp: Date | string | number): Date { + if (timestamp instanceof Date) { + return timestamp; + } + return new Date(timestamp); +} + +/** + * Truncates text to a maximum length and adds ellipsis if needed. + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength) + '...'; +} + +/** + * Converts tool input object to a preview string. + */ +export function formatToolInput(input: Record): string { + try { + const json = JSON.stringify(input, null, 2); + return truncateText(json, 100); + } catch (error) { + logger.debug('formatToolInput failed:', error); + return '[Invalid JSON]'; + } +} + +/** + * Converts tool result content to a preview string. + */ +export function formatToolResult(content: string | unknown[]): string { + try { + if (typeof content === 'string') { + return truncateText(content, 200); + } + const json = JSON.stringify(content, null, 2); + return truncateText(json, 200); + } catch (error) { + logger.debug('formatToolResult failed:', error); + return '[Invalid content]'; + } +} + +/** + * Attaches main session impact tokens to subagents. + * For each subagent with a parentTaskId, finds the matching Task tool + * and extracts the callTokens and resultTokens that affect the main session. + * + * This allows SubagentItem to display both: + * - Main session impact: tokens consumed by the Task tool_call + tool_result in the parent session + * - Subagent isolated context: the subagent's internal token usage + * + * @param subagents - Array of subagents to enhance + * @param linkedTools - Map of tool IDs to LinkedToolItem (includes Task tools) + * @returns The same subagents array with mainSessionImpact populated + */ +export function attachMainSessionImpact( + subagents: Process[], + linkedTools: Map +): Process[] { + for (const subagent of subagents) { + if (subagent.parentTaskId) { + const taskTool = linkedTools.get(subagent.parentTaskId); + if (taskTool) { + const callTokens = taskTool.callTokens ?? 0; + const resultTokens = taskTool.result?.tokenCount ?? 0; + subagent.mainSessionImpact = { + callTokens, + resultTokens, + totalTokens: callTokens + resultTokens, + }; + } + } + } + return subagents; +} diff --git a/src/renderer/utils/claudeMdTracker.ts b/src/renderer/utils/claudeMdTracker.ts new file mode 100644 index 00000000..90a64b13 --- /dev/null +++ b/src/renderer/utils/claudeMdTracker.ts @@ -0,0 +1,656 @@ +/** + * CLAUDE.md Injection Tracker + * + * Tracks system context injections from various CLAUDE.md sources throughout a session. + * Detects injections based on: + * - Global sources (enterprise, user-memory, project-memory, project-rules, project-local) + * - Directory-specific CLAUDE.md files (detected from Read tool calls and @ mentions) + */ + +import { extractFileReferences } from './groupTransformer'; + +import type { ClaudeMdInjection, ClaudeMdSource, ClaudeMdStats } from '../types/claudeMd'; +import type { ClaudeMdFileInfo, ParsedMessage, SemanticStep } from '../types/data'; +import type { AIGroup, ChatItem, FileReference, UserGroup } from '../types/groups'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Default estimated tokens for global CLAUDE.md sources */ +const DEFAULT_ESTIMATED_TOKENS = 500; + +/** CLAUDE.md filename to search for */ +const CLAUDE_MD_FILENAME = 'CLAUDE.md'; + +/** Source identifier for project memory CLAUDE.md files */ +const SOURCE_PROJECT_MEMORY: ClaudeMdSource = 'project-memory'; + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Generate a unique ID for an injection based on its path. + * Uses a simple hash-like approach for readability. + */ +export function generateInjectionId(path: string): string { + // Create a simple hash from the path + let hash = 0; + for (let i = 0; i < path.length; i++) { + const char = path.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + // Convert to positive hex string + const positiveHash = Math.abs(hash).toString(16); + return `cmd-${positiveHash}`; +} + +/** + * Create a display name for a CLAUDE.md injection. + * Returns the raw path for transparency. + */ +export function getDisplayName(path: string, _source: ClaudeMdSource): string { + return path; +} + +/** + * Check if a path is absolute (starts with /). + */ +function isAbsolutePath(path: string): boolean { + return path.startsWith('/') || path.startsWith('\\\\') || /^[a-zA-Z]:[\\/]/.test(path); +} + +/** + * Join paths, handling various path formats properly. + * Handles: + * - Absolute paths: /full/path/file.tsx (returned as-is) + * - Relative paths with ./: ./apps/foo/bar.tsx (strips ./) + * - Parent paths with ../: ../other/file.tsx (walks up directories) + * - Plain paths: apps/foo/bar.tsx (joins with base) + * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) + */ +function joinPaths(base: string, relative: string): string { + if (isAbsolutePath(relative)) { + return relative; + } + + // Remove trailing slash from base if present + const cleanBase = trimTrailingSeparator(base); + + // Handle @ prefix (file mention marker) - strip it if present + let cleanRelative = relative; + if (cleanRelative.startsWith('@')) { + cleanRelative = cleanRelative.slice(1); + } + + // Handle ./ prefix (current directory) + if (cleanRelative.startsWith('./')) { + cleanRelative = cleanRelative.slice(2); + } + + // Handle ../ prefixes (parent directory) + const separator = cleanBase.includes('\\') ? '\\' : '/'; + const hasUnixRoot = cleanBase.startsWith('/'); + const hasUncRoot = cleanBase.startsWith('\\\\'); + const normalizedRelative = normalizeSeparators(cleanRelative, separator); + const baseParts = splitPath(cleanBase); + let remainingRelative = normalizedRelative; + while (remainingRelative.startsWith(`..${separator}`)) { + remainingRelative = remainingRelative.slice(3); + if (baseParts.length > 1) { + baseParts.pop(); + } + } + + // Join the normalized paths + let normalizedBase = baseParts.join(separator); + if (hasUnixRoot && !normalizedBase.startsWith('/')) { + normalizedBase = `/${normalizedBase}`; + } + if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { + normalizedBase = `\\\\${normalizedBase}`; + } + return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; +} + +function trimTrailingSeparator(input: string): string { + let end = input.length; + while (end > 0) { + const char = input[end - 1]; + if (char !== '/' && char !== '\\') { + break; + } + end--; + } + return input.slice(0, end); +} + +function normalizeSeparators(input: string, separator: '/' | '\\'): string { + let output = ''; + let prevWasSeparator = false; + + for (const char of input) { + const isSeparator = char === '/' || char === '\\'; + if (isSeparator) { + if (!prevWasSeparator) { + output += separator; + } + prevWasSeparator = true; + } else { + output += char; + prevWasSeparator = false; + } + } + + return output; +} + +function splitPath(input: string): string[] { + const parts: string[] = []; + let current = ''; + + for (const char of input) { + if (char === '/' || char === '\\') { + if (current.length > 0) { + parts.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current.length > 0) { + parts.push(current); + } + + return parts; +} + +function normalizeForComparison(input: string): string { + return input.replace(/\\/g, '/'); +} + +/** + * Get the directory containing a file. + */ +export function getDirectory(filePath: string): string { + const lastSep = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')); + if (lastSep === -1) return ''; + return filePath.slice(0, lastSep); +} + +/** + * Get the parent directory of a path. + */ +export function getParentDirectory(dirPath: string): string | null { + const lastSep = Math.max(dirPath.lastIndexOf('/'), dirPath.lastIndexOf('\\')); + if (lastSep <= 0) return null; // At root or invalid + return dirPath.slice(0, lastSep); +} + +/** + * Check if dirPath is at or above stopPath in the directory tree. + */ +function isAtOrAbove(dirPath: string, stopPath: string): boolean { + const normDir = normalizeForComparison(dirPath).replace(/\/$/, ''); + const normStop = normalizeForComparison(stopPath).replace(/\/$/, ''); + + // dirPath is at or above stopPath if stopPath starts with dirPath + return normStop === normDir || normStop.startsWith(normDir + '/'); +} + +// ============================================================================= +// Path Extraction Functions +// ============================================================================= + +/** + * Extract file paths from Read tool calls in semantic steps. + */ +export function extractReadToolPaths(steps: SemanticStep[]): string[] { + const paths: string[] = []; + + for (const step of steps) { + // Check if this is a Read tool call + if (step.type === 'tool_call' && step.content.toolName === 'Read') { + const toolInput = step.content.toolInput as Record | undefined; + if (toolInput && typeof toolInput.file_path === 'string') { + paths.push(toolInput.file_path); + } + } + } + + return paths; +} + +/** + * Extract file paths from user @ mentions. + * Converts relative paths to absolute using projectRoot. + */ +export function extractUserMentionPaths( + userGroup: UserGroup | null, + projectRoot: string +): string[] { + if (!userGroup) return []; + + const fileReferences = userGroup.content.fileReferences || []; + const paths: string[] = []; + + for (const ref of fileReferences) { + if (ref.path) { + // Convert to absolute if relative + const absolutePath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + paths.push(absolutePath); + } + } + + return paths; +} + +/** + * Extracts file references from isMeta:true user messages within AI group responses. + * These are user-type messages generated by slash commands and other internal mechanisms + * that contain @-mentioned file paths. + */ +export function extractFileRefsFromResponses(responses: ParsedMessage[]): FileReference[] { + const refs: FileReference[] = []; + for (const msg of responses) { + if (msg.type !== 'user') continue; + let text = ''; + if (typeof msg.content === 'string') { + text = msg.content; + } else if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) text += block.text; + } + } + if (text) refs.push(...extractFileReferences(text)); + } + return refs; +} + +// ============================================================================= +// CLAUDE.md Detection Functions +// ============================================================================= + +/** + * Detect potential CLAUDE.md files by walking up from a file's directory to project root. + * Returns paths to CLAUDE.md files that would be injected based on the file path. + */ +export function detectClaudeMdFromFilePath(filePath: string, projectRoot: string): string[] { + const claudeMdPaths: string[] = []; + const sep = filePath.includes('\\') ? '\\' : '/'; + + // Get the directory containing the file + let currentDir = getDirectory(filePath); + + // Walk up to project root (inclusive) + while (currentDir && isAtOrAbove(projectRoot, currentDir)) { + // Add potential CLAUDE.md path for this directory + const claudeMdPath = `${currentDir}${sep}${CLAUDE_MD_FILENAME}`; + claudeMdPaths.push(claudeMdPath); + + // Move to parent directory + const parentDir = getParentDirectory(currentDir); + if (!parentDir || parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return claudeMdPaths; +} + +// ============================================================================= +// Injection Creation Functions +// ============================================================================= + +/** + * Create injection entries for global CLAUDE.md sources. + * These are injected at the start of every session. + * Only includes files that actually exist (tokens > 0). + */ +export function createGlobalInjections( + projectRoot: string, + aiGroupId: string, + tokenData?: Record +): ClaudeMdInjection[] { + const injections: ClaudeMdInjection[] = []; + + // Helper to get token count from tokenData or fallback to default + const getTokens = (key: string): number => { + return tokenData?.[key]?.estimatedTokens ?? DEFAULT_ESTIMATED_TOKENS; + }; + + // 1. Enterprise config + const enterprisePath = + tokenData?.enterprise?.path ?? '/Library/Application Support/ClaudeCode/CLAUDE.md'; + const enterpriseTokens = getTokens('enterprise'); + if (enterpriseTokens > 0) { + injections.push({ + id: generateInjectionId(enterprisePath), + path: enterprisePath, + source: 'enterprise', + displayName: getDisplayName(enterprisePath, 'enterprise'), + isGlobal: true, + estimatedTokens: enterpriseTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 2. User memory (~/.claude/CLAUDE.md) + // Use ~ for display purposes (renderer cannot access Node.js process.env) + const userMemoryPath = '~/.claude/CLAUDE.md'; + const userTokens = getTokens('user'); + if (userTokens > 0) { + injections.push({ + id: generateInjectionId(userMemoryPath), + path: userMemoryPath, + source: 'user-memory', + displayName: getDisplayName(userMemoryPath, 'user-memory'), + isGlobal: true, + estimatedTokens: userTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 3. Project memory - could be at root or in .claude folder + const projectMemoryPath = joinPaths(projectRoot, 'CLAUDE.md'); + const projectMemoryAltPath = joinPaths(projectRoot, '.claude/CLAUDE.md'); + // Add the main project CLAUDE.md + const projectTokens = getTokens('project'); + if (projectTokens > 0) { + injections.push({ + id: generateInjectionId(projectMemoryPath), + path: projectMemoryPath, + source: SOURCE_PROJECT_MEMORY, + displayName: getDisplayName(projectMemoryPath, SOURCE_PROJECT_MEMORY), + isGlobal: true, + estimatedTokens: projectTokens, + firstSeenInGroup: aiGroupId, + }); + } + // Also add the .claude folder variant + const projectAltTokens = getTokens('project-alt'); + if (projectAltTokens > 0) { + injections.push({ + id: generateInjectionId(projectMemoryAltPath), + path: projectMemoryAltPath, + source: SOURCE_PROJECT_MEMORY, + displayName: getDisplayName(projectMemoryAltPath, SOURCE_PROJECT_MEMORY), + isGlobal: true, + estimatedTokens: projectAltTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 4. Project rules (*.md files in .claude/rules/) + const projectRulesPath = joinPaths(projectRoot, '.claude/rules/*.md'); + const projectRulesTokens = getTokens('project-rules'); + if (projectRulesTokens > 0) { + injections.push({ + id: generateInjectionId(projectRulesPath), + path: projectRulesPath, + source: 'project-rules', + displayName: getDisplayName(projectRulesPath, 'project-rules'), + isGlobal: true, + estimatedTokens: projectRulesTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 5. Project local + const projectLocalPath = joinPaths(projectRoot, 'CLAUDE.local.md'); + const projectLocalTokens = getTokens('project-local'); + if (projectLocalTokens > 0) { + injections.push({ + id: generateInjectionId(projectLocalPath), + path: projectLocalPath, + source: 'project-local', + displayName: getDisplayName(projectLocalPath, 'project-local'), + isGlobal: true, + estimatedTokens: projectLocalTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 6. User rules (~/.claude/rules/**/*.md) + const userRulesPath = '~/.claude/rules/**/*.md'; + const userRulesTokens = getTokens('user-rules'); + if (userRulesTokens > 0) { + injections.push({ + id: generateInjectionId(userRulesPath), + path: userRulesPath, + source: 'user-rules', + displayName: getDisplayName(userRulesPath, 'user-rules'), + isGlobal: true, + estimatedTokens: userRulesTokens, + firstSeenInGroup: aiGroupId, + }); + } + + // 7. Auto memory (~/.claude/projects//memory/MEMORY.md) + const autoMemoryPath = + tokenData?.['auto-memory']?.path ?? '~/.claude/projects/.../memory/MEMORY.md'; + const autoMemoryTokens = getTokens('auto-memory'); + if (autoMemoryTokens > 0) { + injections.push({ + id: generateInjectionId(autoMemoryPath), + path: autoMemoryPath, + source: 'auto-memory', + displayName: getDisplayName(autoMemoryPath, 'auto-memory'), + isGlobal: true, + estimatedTokens: autoMemoryTokens, + firstSeenInGroup: aiGroupId, + }); + } + + return injections; +} + +/** + * Create an injection entry for a directory-specific CLAUDE.md. + */ +function createDirectoryInjection(path: string, aiGroupId: string): ClaudeMdInjection { + return { + id: generateInjectionId(path), + path, + source: 'directory', + displayName: getDisplayName(path, 'directory'), + isGlobal: false, + estimatedTokens: DEFAULT_ESTIMATED_TOKENS, + firstSeenInGroup: aiGroupId, + }; +} + +// ============================================================================= +// Stats Computation +// ============================================================================= + +/** + * Parameters for computing CLAUDE.md stats for an AI group. + */ +interface ComputeClaudeMdStatsParams { + aiGroup: AIGroup; + userGroup: UserGroup | null; + isFirstGroup: boolean; + previousInjections: ClaudeMdInjection[]; + projectRoot: string; + contextTokens: number; + tokenData?: Record; +} + +/** + * Compute CLAUDE.md injection statistics for an AI group. + */ +function computeClaudeMdStats(params: ComputeClaudeMdStatsParams): ClaudeMdStats { + const { + aiGroup, + userGroup, + isFirstGroup, + previousInjections, + projectRoot, + contextTokens, + tokenData, + } = params; + + const newInjections: ClaudeMdInjection[] = []; + const previousPaths = new Set(previousInjections.map((inj) => inj.path)); + + // For the first group, add global injections + // Use "ai-N" format for firstSeenInGroup to enable turn navigation in SessionClaudeMdPanel + const turnGroupId = `ai-${aiGroup.turnIndex}`; + if (isFirstGroup) { + const globalInjections = createGlobalInjections(projectRoot, turnGroupId, tokenData); + for (const injection of globalInjections) { + if (!previousPaths.has(injection.path)) { + newInjections.push(injection); + previousPaths.add(injection.path); + } + } + } + + // Collect all file paths from Read tools and user @ mentions + const allFilePaths: string[] = []; + + // Extract from Read tool calls in semantic steps + const readPaths = extractReadToolPaths(aiGroup.steps); + allFilePaths.push(...readPaths); + + // Extract from user @ mentions + const mentionPaths = extractUserMentionPaths(userGroup, projectRoot); + allFilePaths.push(...mentionPaths); + + // Extract from isMeta:true user messages in AI responses (slash command follow-ups) + const responseRefs = extractFileRefsFromResponses(aiGroup.responses); + for (const ref of responseRefs) { + if (ref.path) { + const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + allFilePaths.push(absPath); + } + } + + // For each file path, detect potential CLAUDE.md files + for (const filePath of allFilePaths) { + const claudeMdPaths = detectClaudeMdFromFilePath(filePath, projectRoot); + + for (const claudeMdPath of claudeMdPaths) { + // Skip if already seen + if (previousPaths.has(claudeMdPath)) { + continue; + } + + // Skip if this is a global path (already handled) + const isGlobalPath = + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/CLAUDE.md` || + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/.claude/CLAUDE.md` || + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/CLAUDE.local.md`; + + if (isGlobalPath) { + continue; + } + + // Create directory injection + const injection = createDirectoryInjection(claudeMdPath, turnGroupId); + newInjections.push(injection); + previousPaths.add(claudeMdPath); + } + } + + // Build accumulated injections + const accumulatedInjections = [...previousInjections, ...newInjections]; + + // Calculate totals + const totalEstimatedTokens = accumulatedInjections.reduce( + (sum, inj) => sum + inj.estimatedTokens, + 0 + ); + + // Calculate percentage of context + const percentageOfContext = contextTokens > 0 ? (totalEstimatedTokens / contextTokens) * 100 : 0; + + return { + newInjections, + accumulatedInjections, + totalEstimatedTokens, + percentageOfContext, + newCount: newInjections.length, + accumulatedCount: accumulatedInjections.length, + }; +} + +// ============================================================================= +// Session Processing +// ============================================================================= + +/** + * Process all chat items in a session and compute CLAUDE.md stats for each AI group. + * Returns a map of aiGroupId -> ClaudeMdStats. + */ +export function processSessionClaudeMd( + items: ChatItem[], + projectRoot: string, + tokenData?: Record +): Map { + const statsMap = new Map(); + let accumulatedInjections: ClaudeMdInjection[] = []; + let isFirstAiGroup = true; + let previousUserGroup: UserGroup | null = null; + + for (const item of items) { + // Track user groups for pairing with subsequent AI groups + if (item.type === 'user') { + previousUserGroup = item.group; + continue; + } + + // Handle compact items: reset accumulated state across compaction boundaries + if (item.type === 'compact') { + accumulatedInjections = []; + isFirstAiGroup = true; + previousUserGroup = null; + continue; + } + + // Process AI groups + if (item.type === 'ai') { + const aiGroup = item.group; + + // Get context tokens from the AI group's metrics + // Use input tokens as a proxy for context window usage + const contextTokens = aiGroup.tokens.input || 0; + + // Compute stats for this group + const stats = computeClaudeMdStats({ + aiGroup, + userGroup: previousUserGroup, + isFirstGroup: isFirstAiGroup, + previousInjections: accumulatedInjections, + projectRoot, + contextTokens, + tokenData, + }); + + // Store stats + statsMap.set(aiGroup.id, stats); + + // Update accumulated state for next iteration + accumulatedInjections = stats.accumulatedInjections; + isFirstAiGroup = false; + + // Clear the user group pairing after processing + previousUserGroup = null; + } + } + + return statsMap; +} + +// ============================================================================= +// Utility Exports +// ============================================================================= diff --git a/src/renderer/utils/contextTracker.ts b/src/renderer/utils/contextTracker.ts new file mode 100644 index 00000000..d1b54aef --- /dev/null +++ b/src/renderer/utils/contextTracker.ts @@ -0,0 +1,1099 @@ +/** + * Unified Context Tracker + * + * Provides comprehensive context tracking for all sources of context injection: + * - CLAUDE.md files (enterprise, user, project, directory) + * - Mentioned files (@mentions) + * - Tool outputs + * + * This builds on claudeMdTracker.ts and extends it to track all context sources. + */ + +import { estimateTokens } from '@shared/utils/tokenFormatting'; + +import { MAX_MENTIONED_FILE_TOKENS } from '../types/contextInjection'; + +import { buildDisplayItems, findLastOutput, linkToolCallsToResults } from './aiGroupEnhancer'; +import { + createGlobalInjections, + detectClaudeMdFromFilePath, + extractFileRefsFromResponses, + extractReadToolPaths, + extractUserMentionPaths, + generateInjectionId, + getDisplayName, +} from './claudeMdTracker'; + +import type { ClaudeMdInjection, ClaudeMdSource } from '../types/claudeMd'; +import type { + ClaudeMdContextInjection, + CompactionTokenDelta, + ContextInjection, + ContextPhase, + ContextPhaseInfo, + ContextStats, + MentionedFileInfo, + MentionedFileInjection, + NewCountsByCategory, + TaskCoordinationBreakdown, + TaskCoordinationInjection, + ThinkingTextBreakdown, + ThinkingTextInjection, + TokensByCategory, + ToolOutputInjection, + ToolTokenBreakdown, + UserMessageInjection, +} from '../types/contextInjection'; +import type { ClaudeMdFileInfo } from '../types/data'; +import type { + AIGroup, + AIGroupDisplayItem, + ChatItem, + LinkedToolItem, + UserGroup, +} from '../types/groups'; + +// ============================================================================= +// Constants +// ============================================================================= + +/** Category identifier for mentioned file injections */ +const CATEGORY_MENTIONED_FILE = 'mentioned-file' as const; + +/** Tool names that constitute task coordination overhead */ +const TASK_COORDINATION_TOOL_NAMES = new Set([ + 'SendMessage', + 'TeamCreate', + 'TeamDelete', + 'TaskCreate', + 'TaskUpdate', + 'TaskList', + 'TaskGet', +]); + +// ============================================================================= +// ID Generation Functions +// ============================================================================= + +/** + * Generate a unique ID for a mentioned file injection. + * Uses a similar approach to generateInjectionId but with 'mf-' prefix. + */ +function generateMentionedFileId(path: string): string { + let hash = 0; + for (let i = 0; i < path.length; i++) { + const char = path.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + const positiveHash = Math.abs(hash).toString(16); + return `mf-${positiveHash}`; +} + +/** + * Generate a unique ID for a tool output injection. + */ +function generateToolOutputId(turnIndex: number): string { + return `tool-output-ai-${turnIndex}`; +} + +/** + * Generate unique ID for thinking-text injection. + */ +function generateThinkingTextId(turnIndex: number): string { + return `thinking-text-ai-${turnIndex}`; +} + +/** + * Generate unique ID for task coordination injection. + */ +function generateTaskCoordinationId(turnIndex: number): string { + return `task-coord-ai-${turnIndex}`; +} + +/** + * Generate unique ID for user message injection. + */ +function generateUserMessageId(turnIndex: number): string { + return `user-msg-ai-${turnIndex}`; +} + +// ============================================================================= +// Injection Wrapping Functions +// ============================================================================= + +/** + * Wrap a ClaudeMdInjection with the 'claude-md' category for union compatibility. + */ +function wrapClaudeMdInjection(injection: ClaudeMdInjection): ClaudeMdContextInjection { + return { + ...injection, + category: 'claude-md' as const, + }; +} + +// ============================================================================= +// Mentioned File Injection Creation +// ============================================================================= + +/** + * Parameters for creating a mentioned file injection. + */ +interface CreateMentionedFileInjectionParams { + /** Absolute file path */ + path: string; + /** Display name (relative path or filename) */ + displayName: string; + /** Estimated token count for this file */ + estimatedTokens: number; + /** Turn index where this file was first mentioned */ + turnIndex: number; + /** AI group ID for navigation */ + aiGroupId: string; + /** Whether the file exists on disk */ + exists?: boolean; +} + +/** + * Create a MentionedFileInjection object. + */ +function createMentionedFileInjection( + params: CreateMentionedFileInjectionParams +): MentionedFileInjection { + return { + id: generateMentionedFileId(params.path), + category: CATEGORY_MENTIONED_FILE, + path: params.path, + displayName: params.displayName, + estimatedTokens: params.estimatedTokens, + firstSeenTurnIndex: params.turnIndex, + firstSeenInGroup: params.aiGroupId, + exists: params.exists ?? true, + }; +} + +// ============================================================================= +// Tool Output Aggregation +// ============================================================================= + +/** + * Aggregate tool outputs from all linked tools in a turn. + * Also includes tokens from user-invoked skills (via /skill-name commands). + * Returns a ToolOutputInjection if there are any tool outputs with tokens. + */ +function aggregateToolOutputs( + linkedTools: Map, + turnIndex: number, + aiGroupId: string, + displayItems?: AIGroupDisplayItem[] +): ToolOutputInjection | null { + const toolBreakdown: ToolTokenBreakdown[] = []; + let totalTokens = 0; + + for (const linkedTool of linkedTools.values()) { + // Skip task coordination tools - they are tracked separately + if (TASK_COORDINATION_TOOL_NAMES.has(linkedTool.name)) { + continue; + } + + // Calculate total context tokens for the tool operation + // This matches getToolContextTokens in LinkedToolItem.tsx and ErrorDetector + // + // callTokens: What Claude generated (Write file content, Edit old/new strings) + // resultTokens: What Claude reads back (success message, Read file content) + // skillTokens: Additional context for Skill tools + const callTokens = linkedTool.callTokens ?? 0; + const resultTokens = linkedTool.result?.tokenCount ?? 0; + const skillTokens = linkedTool.skillInstructionsTokenCount ?? 0; + const toolTokenCount = callTokens + resultTokens + skillTokens; + + if (toolTokenCount > 0) { + // Rename "Task" to "Task (Subagent)" for clarity in the UI + const displayName = linkedTool.name === 'Task' ? 'Task (Subagent)' : linkedTool.name; + toolBreakdown.push({ + toolName: displayName, + tokenCount: toolTokenCount, + isError: linkedTool.result?.isError ?? false, + }); + totalTokens += toolTokenCount; + } + } + + // Include user-invoked slash tokens from display items + // These are slashes invoked via /xxx commands + if (displayItems) { + for (const item of displayItems) { + if (item.type === 'slash' && item.slash.instructionsTokenCount) { + toolBreakdown.push({ + toolName: `/${item.slash.name}`, + tokenCount: item.slash.instructionsTokenCount, + isError: false, + }); + totalTokens += item.slash.instructionsTokenCount; + } + } + } + + // Return null if no tokens from tools + if (totalTokens === 0) { + return null; + } + + return { + id: generateToolOutputId(turnIndex), + category: 'tool-output', + turnIndex, + aiGroupId, + estimatedTokens: totalTokens, + toolCount: toolBreakdown.length, + toolBreakdown, + }; +} + +// ============================================================================= +// Task Coordination Aggregation +// ============================================================================= + +/** + * Aggregate task coordination tokens from linked tools and display items. + * Tracks SendMessage, TeamCreate, TaskCreate, and other task tools, + * plus teammate_message items injected into the session. + */ +function aggregateTaskCoordination( + linkedTools: Map, + turnIndex: number, + aiGroupId: string, + displayItems?: AIGroupDisplayItem[] +): TaskCoordinationInjection | null { + const breakdown: TaskCoordinationBreakdown[] = []; + let totalTokens = 0; + + // Scan linked tools for task coordination tools + for (const linkedTool of linkedTools.values()) { + if (!TASK_COORDINATION_TOOL_NAMES.has(linkedTool.name)) { + continue; + } + + const callTokens = linkedTool.callTokens ?? 0; + const resultTokens = linkedTool.result?.tokenCount ?? 0; + const skillTokens = linkedTool.skillInstructionsTokenCount ?? 0; + const toolTokenCount = callTokens + resultTokens + skillTokens; + + if (toolTokenCount > 0) { + // Extract a label from tool input for SendMessage (recipient name) + let label = linkedTool.name; + if (linkedTool.name === 'SendMessage' && linkedTool.input) { + const recipient = linkedTool.input.recipient as string | undefined; + if (recipient) { + label = `SendMessage → ${recipient}`; + } + } + + breakdown.push({ + type: linkedTool.name === 'SendMessage' ? 'send-message' : 'task-tool', + toolName: linkedTool.name, + tokenCount: toolTokenCount, + label, + }); + totalTokens += toolTokenCount; + } + } + + // Scan display items for teammate messages + if (displayItems) { + for (const item of displayItems) { + if (item.type === 'teammate_message' && item.teammateMessage.tokenCount) { + breakdown.push({ + type: 'teammate-message', + tokenCount: item.teammateMessage.tokenCount, + label: item.teammateMessage.teammateId, + }); + totalTokens += item.teammateMessage.tokenCount; + } + } + } + + if (totalTokens === 0) { + return null; + } + + return { + id: generateTaskCoordinationId(turnIndex), + category: 'task-coordination', + turnIndex, + aiGroupId, + estimatedTokens: totalTokens, + breakdown, + }; +} + +// ============================================================================= +// User Message Injection Creation +// ============================================================================= + +/** + * Create a UserMessageInjection from a user group. + * Uses rawText (includes commands and @mentions) for token estimation + * since that's what's actually sent to the API. + * + * @returns UserMessageInjection or null if empty text or 0 tokens + */ +function createUserMessageInjection( + userGroup: UserGroup, + turnIndex: number, + aiGroupId: string +): UserMessageInjection | null { + const text = userGroup.content.rawText ?? userGroup.content.text ?? ''; + if (!text) return null; + + const tokens = estimateTokens(text); + if (tokens === 0) return null; + + const textPreview = text.length > 80 ? text.slice(0, 80) + '…' : text; + + return { + id: generateUserMessageId(turnIndex), + category: 'user-message', + turnIndex, + aiGroupId, + estimatedTokens: tokens, + textPreview, + }; +} + +// ============================================================================= +// Thinking/Text Output Aggregation +// ============================================================================= + +/** + * Aggregates thinking and text output tokens for a single turn. + * Creates a ThinkingTextInjection that tracks all thinking blocks and text outputs. + * + * @param displayItems - Display items from the AI group + * @param turnIndex - The turn index (0-based) + * @param aiGroupId - The AI group ID for navigation + * @returns ThinkingTextInjection or null if no tokens + */ +function aggregateThinkingText( + displayItems: AIGroupDisplayItem[], + turnIndex: number, + aiGroupId: string +): ThinkingTextInjection | null { + const breakdown: ThinkingTextBreakdown[] = []; + let totalTokens = 0; + let thinkingTokens = 0; + let textTokens = 0; + + for (const item of displayItems) { + if (item.type === 'thinking' && item.tokenCount && item.tokenCount > 0) { + thinkingTokens += item.tokenCount; + totalTokens += item.tokenCount; + } else if (item.type === 'output' && item.tokenCount && item.tokenCount > 0) { + textTokens += item.tokenCount; + totalTokens += item.tokenCount; + } + } + + if (thinkingTokens > 0) { + breakdown.push({ type: 'thinking', tokenCount: thinkingTokens }); + } + if (textTokens > 0) { + breakdown.push({ type: 'text', tokenCount: textTokens }); + } + + if (totalTokens === 0) { + return null; + } + + return { + id: generateThinkingTextId(turnIndex), + category: 'thinking-text', + turnIndex, + aiGroupId, + estimatedTokens: totalTokens, + breakdown, + }; +} + +// ============================================================================= +// Stats Computation +// ============================================================================= + +/** + * Parameters for computing context stats for an AI group. + */ +interface ComputeContextStatsParams { + /** The AI group being processed */ + aiGroup: AIGroup; + /** The preceding user group (if any) */ + userGroup: UserGroup | null; + /** Linked tools map from the enhanced AI group */ + linkedTools: Map; + /** Display items from enhanced AI group (includes user skills) */ + displayItems?: AIGroupDisplayItem[]; + /** Whether this is the first AI group in the session */ + isFirstGroup: boolean; + /** Accumulated injections from previous groups */ + previousInjections: ContextInjection[]; + /** Project root path for resolving relative paths */ + projectRoot: string; + /** Token data for CLAUDE.md files (global sources) */ + claudeMdTokenData?: Record; + /** Token data for mentioned files */ + mentionedFileTokenData?: Map; + /** Token data for validated directory CLAUDE.md files (keyed by full path) */ + directoryTokenData?: Record; +} + +/** + * Helper to check if a path is absolute. + */ +function isAbsolutePath(path: string): boolean { + return ( + path.startsWith('/') || + path.startsWith('~/') || + path.startsWith('~\\') || + path === '~' || + path.startsWith('\\\\') || + /^[a-zA-Z]:[\\/]/.test(path) + ); +} + +/** + * Helper to join paths, handling various path formats properly. + * Handles: + * - Absolute paths: /full/path/file.tsx (returned as-is) + * - Relative paths with ./: ./apps/foo/bar.tsx (strips ./) + * - Parent paths with ../: ../other/file.tsx (walks up directories) + * - Plain paths: apps/foo/bar.tsx (joins with base) + * - Paths with @ prefix: @apps/foo/bar.tsx (strips @ then joins) + */ +function joinPaths(base: string, relative: string): string { + if (isAbsolutePath(relative)) { + return relative; + } + + const cleanBase = trimTrailingSeparator(base); + + // Handle @ prefix (file mention marker) - strip it if present + let cleanRelative = relative; + if (cleanRelative.startsWith('@')) { + cleanRelative = cleanRelative.slice(1); + } + + // Handle ./ prefix (current directory) + if (cleanRelative.startsWith('./')) { + cleanRelative = cleanRelative.slice(2); + } + + // Handle ../ prefixes (parent directory) + const separator = cleanBase.includes('\\') ? '\\' : '/'; + const hasUnixRoot = cleanBase.startsWith('/'); + const hasUncRoot = cleanBase.startsWith('\\\\'); + const normalizedRelative = normalizeSeparators(cleanRelative, separator); + const baseParts = splitPath(cleanBase); + let remainingRelative = normalizedRelative; + while (remainingRelative.startsWith(`..${separator}`)) { + remainingRelative = remainingRelative.slice(3); + if (baseParts.length > 1) { + baseParts.pop(); + } + } + + // Join the normalized paths + let normalizedBase = baseParts.join(separator); + if (hasUnixRoot && !normalizedBase.startsWith('/')) { + normalizedBase = `/${normalizedBase}`; + } + if (hasUncRoot && !normalizedBase.startsWith('\\\\')) { + normalizedBase = `\\\\${normalizedBase}`; + } + return remainingRelative ? `${normalizedBase}${separator}${remainingRelative}` : normalizedBase; +} + +function trimTrailingSeparator(input: string): string { + let end = input.length; + while (end > 0) { + const char = input[end - 1]; + if (char !== '/' && char !== '\\') { + break; + } + end--; + } + return input.slice(0, end); +} + +function normalizeSeparators(input: string, separator: '/' | '\\'): string { + let output = ''; + let prevWasSeparator = false; + + for (const char of input) { + const isSeparator = char === '/' || char === '\\'; + if (isSeparator) { + if (!prevWasSeparator) { + output += separator; + } + prevWasSeparator = true; + } else { + output += char; + prevWasSeparator = false; + } + } + + return output; +} + +function splitPath(input: string): string[] { + const parts: string[] = []; + let current = ''; + + for (const char of input) { + if (char === '/' || char === '\\') { + if (current.length > 0) { + parts.push(current); + current = ''; + } + } else { + current += char; + } + } + + if (current.length > 0) { + parts.push(current); + } + + return parts; +} + +function normalizeForComparison(input: string): string { + return input.replace(/\\/g, '/'); +} + +/** + * Create a directory injection for a CLAUDE.md file discovered via file paths. + */ +function createDirectoryInjection(path: string, aiGroupId: string): ClaudeMdInjection { + return { + id: generateInjectionId(path), + path, + source: 'directory' as ClaudeMdSource, + displayName: getDisplayName(path, 'directory'), + isGlobal: false, + estimatedTokens: 500, // Default estimated tokens + firstSeenInGroup: aiGroupId, + }; +} + +/** + * Compute context stats for an AI group. + * Tracks CLAUDE.md injections, mentioned files, and tool outputs. + */ +function computeContextStats(params: ComputeContextStatsParams): ContextStats { + const { + aiGroup, + userGroup, + linkedTools, + displayItems, + isFirstGroup, + previousInjections, + projectRoot, + claudeMdTokenData, + mentionedFileTokenData, + directoryTokenData, + } = params; + + const newInjections: ContextInjection[] = []; + const previousPaths = new Set( + previousInjections + .filter( + (inj): inj is ClaudeMdContextInjection | MentionedFileInjection => + inj.category === 'claude-md' || inj.category === CATEGORY_MENTIONED_FILE + ) + .map((inj) => inj.path) + ); + + // Use "ai-N" format for firstSeenInGroup to enable turn navigation + const turnGroupId = `ai-${aiGroup.turnIndex}`; + + // a) For FIRST group only: Add CLAUDE.md global injections + if (isFirstGroup) { + const globalInjections = createGlobalInjections(projectRoot, turnGroupId, claudeMdTokenData); + for (const injection of globalInjections) { + if (!previousPaths.has(injection.path)) { + newInjections.push(wrapClaudeMdInjection(injection)); + previousPaths.add(injection.path); + } + } + } + + // b) Detect directory CLAUDE.md from file paths + // Only include directory CLAUDE.md files that have been validated to exist + const allFilePaths: string[] = []; + + // Extract from Read tool calls in semantic steps + const readPaths = extractReadToolPaths(aiGroup.steps); + allFilePaths.push(...readPaths); + + // Extract from user @ mentions + const mentionPaths = extractUserMentionPaths(userGroup, projectRoot); + allFilePaths.push(...mentionPaths); + + // Extract from isMeta:true user messages in AI responses (slash command follow-ups) + const responseRefs = extractFileRefsFromResponses(aiGroup.responses); + for (const ref of responseRefs) { + if (ref.path) { + const absPath = isAbsolutePath(ref.path) ? ref.path : joinPaths(projectRoot, ref.path); + allFilePaths.push(absPath); + } + } + + // For each file path, detect potential CLAUDE.md files + for (const filePath of allFilePaths) { + const claudeMdPaths = detectClaudeMdFromFilePath(filePath, projectRoot); + + for (const claudeMdPath of claudeMdPaths) { + // Skip if already seen + if (previousPaths.has(claudeMdPath)) { + continue; + } + + // Skip if this is a global path (already handled) + const isGlobalPath = + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/CLAUDE.md` || + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/.claude/CLAUDE.md` || + normalizeForComparison(claudeMdPath) === + `${normalizeForComparison(projectRoot)}/CLAUDE.local.md`; + + if (isGlobalPath) { + continue; + } + + // Only include directory CLAUDE.md files that exist (validated via directoryTokenData) + // If directoryTokenData is provided and doesn't contain this path, the file doesn't exist + if (directoryTokenData) { + const fileInfo = directoryTokenData[claudeMdPath]; + if (!fileInfo || !fileInfo.exists || fileInfo.estimatedTokens <= 0) { + // File doesn't exist or has no content - skip it + continue; + } + // Use validated token count from directoryTokenData + const injection = createDirectoryInjection(claudeMdPath, turnGroupId); + injection.estimatedTokens = fileInfo.estimatedTokens; + newInjections.push(wrapClaudeMdInjection(injection)); + previousPaths.add(claudeMdPath); + } else { + // Fallback: if no directoryTokenData provided, create with default tokens (legacy behavior) + const injection = createDirectoryInjection(claudeMdPath, turnGroupId); + newInjections.push(wrapClaudeMdInjection(injection)); + previousPaths.add(claudeMdPath); + } + } + } + + // c) Process mentioned files (NEW LOGIC) + if (userGroup?.content.fileReferences) { + for (const fileRef of userGroup.content.fileReferences) { + if (!fileRef.path) continue; + + // Convert to absolute path if needed + const absolutePath = isAbsolutePath(fileRef.path) + ? fileRef.path + : joinPaths(projectRoot, fileRef.path); + + // Skip if already seen + if (previousPaths.has(absolutePath)) { + continue; + } + + // Check if we have token data for this file + const fileInfo = mentionedFileTokenData?.get(absolutePath); + + // Only include files that exist and are under the token limit + if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) { + const mentionedFileInjection = createMentionedFileInjection({ + path: absolutePath, + displayName: fileRef.path, // Use original path for display + estimatedTokens: fileInfo.estimatedTokens, + turnIndex: aiGroup.turnIndex, + aiGroupId: turnGroupId, + exists: fileInfo.exists, + }); + + newInjections.push(mentionedFileInjection); + previousPaths.add(absolutePath); + } + } + } + + // c2) Process @-mentions from isMeta:true user messages in AI responses + for (const fileRef of responseRefs) { + if (!fileRef.path) continue; + + const absolutePath = isAbsolutePath(fileRef.path) + ? fileRef.path + : joinPaths(projectRoot, fileRef.path); + + if (previousPaths.has(absolutePath)) { + continue; + } + + const fileInfo = mentionedFileTokenData?.get(absolutePath); + + if (fileInfo && fileInfo.exists && fileInfo.estimatedTokens <= MAX_MENTIONED_FILE_TOKENS) { + const mentionedFileInjection = createMentionedFileInjection({ + path: absolutePath, + displayName: fileRef.path, + estimatedTokens: fileInfo.estimatedTokens, + turnIndex: aiGroup.turnIndex, + aiGroupId: turnGroupId, + exists: fileInfo.exists, + }); + + newInjections.push(mentionedFileInjection); + previousPaths.add(absolutePath); + } + } + + // d) Aggregate tool outputs (includes user-invoked skill tokens from displayItems) + // Task coordination tools are excluded here (tracked separately in step d2) + const toolOutputInjection = aggregateToolOutputs( + linkedTools, + aiGroup.turnIndex, + turnGroupId, + displayItems + ); + if (toolOutputInjection) { + newInjections.push(toolOutputInjection); + } + + // d2) Aggregate task coordination tokens (SendMessage, TeamCreate, TaskCreate, etc.) + const taskCoordinationInjection = aggregateTaskCoordination( + linkedTools, + aiGroup.turnIndex, + turnGroupId, + displayItems + ); + if (taskCoordinationInjection) { + newInjections.push(taskCoordinationInjection); + } + + // d3) Create user message injection + if (userGroup) { + const userMessageInjection = createUserMessageInjection( + userGroup, + aiGroup.turnIndex, + turnGroupId + ); + if (userMessageInjection) { + newInjections.push(userMessageInjection); + } + } + + // e) Aggregate thinking and text output tokens + if (displayItems) { + const thinkingTextInjection = aggregateThinkingText( + displayItems, + aiGroup.turnIndex, + turnGroupId + ); + if (thinkingTextInjection) { + newInjections.push(thinkingTextInjection); + } + } + + // f) Build accumulated injections + const accumulatedInjections = [...previousInjections, ...newInjections]; + + // g) Calculate totals and category breakdowns + const tokensByCategory: TokensByCategory = { + claudeMd: 0, + mentionedFiles: 0, + toolOutputs: 0, + thinkingText: 0, + taskCoordination: 0, + userMessages: 0, + }; + + const newCounts: NewCountsByCategory = { + claudeMd: 0, + mentionedFiles: 0, + toolOutputs: 0, + thinkingText: 0, + taskCoordination: 0, + userMessages: 0, + }; + + // Count new injections by category + for (const injection of newInjections) { + switch (injection.category) { + case 'claude-md': + newCounts.claudeMd++; + break; + case CATEGORY_MENTIONED_FILE: + newCounts.mentionedFiles++; + break; + case 'tool-output': + newCounts.toolOutputs++; + break; + case 'thinking-text': + newCounts.thinkingText++; + break; + case 'task-coordination': + newCounts.taskCoordination++; + break; + case 'user-message': + newCounts.userMessages++; + break; + } + } + + // Sum tokens by category from accumulated injections + for (const injection of accumulatedInjections) { + switch (injection.category) { + case 'claude-md': + tokensByCategory.claudeMd += injection.estimatedTokens; + break; + case CATEGORY_MENTIONED_FILE: + tokensByCategory.mentionedFiles += injection.estimatedTokens; + break; + case 'tool-output': + tokensByCategory.toolOutputs += injection.estimatedTokens; + break; + case 'thinking-text': + tokensByCategory.thinkingText += injection.estimatedTokens; + break; + case 'task-coordination': + tokensByCategory.taskCoordination += injection.estimatedTokens; + break; + case 'user-message': + tokensByCategory.userMessages += injection.estimatedTokens; + break; + } + } + + const totalEstimatedTokens = + tokensByCategory.claudeMd + + tokensByCategory.mentionedFiles + + tokensByCategory.toolOutputs + + tokensByCategory.thinkingText + + tokensByCategory.taskCoordination + + tokensByCategory.userMessages; + + return { + newInjections, + accumulatedInjections, + totalEstimatedTokens, + tokensByCategory, + newCounts, + }; +} + +// ============================================================================= +// Session Processing +// ============================================================================= + +/** + * Get total tokens from the last assistant message in an AI group. + * Sums input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens. + */ +function getLastAssistantTotalTokens(aiGroup: AIGroup): number | undefined { + const responses = aiGroup.responses || []; + for (let i = responses.length - 1; i >= 0; i--) { + const msg = responses[i]; + if (msg.type === 'assistant' && msg.usage) { + return ( + (msg.usage.input_tokens ?? 0) + + (msg.usage.output_tokens ?? 0) + + (msg.usage.cache_read_input_tokens ?? 0) + + (msg.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return undefined; +} + +/** + * Get total tokens from the FIRST assistant message in an AI group. + * Used for post-compaction token measurement: the first response after compaction + * reflects the actual compacted context size before the AI generates more content. + */ +function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined { + const responses = aiGroup.responses || []; + for (const msg of responses) { + if (msg.type === 'assistant' && msg.usage) { + return ( + (msg.usage.input_tokens ?? 0) + + (msg.usage.output_tokens ?? 0) + + (msg.usage.cache_read_input_tokens ?? 0) + + (msg.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return undefined; +} + +/** + * Process all chat items in a session and compute context stats with phase information. + * Returns both the stats map and session-wide phase info. + */ +export function processSessionContextWithPhases( + items: ChatItem[], + projectRoot: string, + claudeMdTokenData?: Record, + mentionedFileTokenData?: Map, + directoryTokenData?: Record +): { statsMap: Map; phaseInfo: ContextPhaseInfo } { + const statsMap = new Map(); + let accumulatedInjections: ContextInjection[] = []; + let isFirstAiGroup = true; + let previousUserGroup: UserGroup | null = null; + + // Phase tracking state + let currentPhaseNumber = 1; + const phases: ContextPhase[] = []; + const aiGroupPhaseMap = new Map(); + const compactionTokenDeltas = new Map(); + + // Track phase boundaries + let currentPhaseFirstAIGroupId: string | null = null; + let currentPhaseLastAIGroupId: string | null = null; + let currentPhaseCompactGroupId: string | null = null; + let lastAIGroupBeforeCompact: AIGroup | null = null; + + for (const item of items) { + // Track user groups for pairing with subsequent AI groups + if (item.type === 'user') { + previousUserGroup = item.group; + continue; + } + + // Handle compact items: reset accumulated state and start new phase + if (item.type === 'compact') { + // Finalize the current phase before starting a new one + if (currentPhaseFirstAIGroupId && currentPhaseLastAIGroupId) { + phases.push({ + phaseNumber: currentPhaseNumber, + firstAIGroupId: currentPhaseFirstAIGroupId, + lastAIGroupId: currentPhaseLastAIGroupId, + compactGroupId: currentPhaseCompactGroupId, + }); + } + + // Reset context tracking state + accumulatedInjections = []; + isFirstAiGroup = true; + previousUserGroup = null; + + // Start new phase + currentPhaseNumber++; + currentPhaseCompactGroupId = item.group.id; + currentPhaseFirstAIGroupId = null; + currentPhaseLastAIGroupId = null; + // Note: lastAIGroupBeforeCompact is intentionally NOT reset here. + // It retains the last AI group from the previous phase so we can + // compute compaction token deltas when the first AI group of the + // new phase is encountered. + + continue; + } + + // Process AI groups + if (item.type === 'ai') { + const aiGroup = item.group; + + // Compute linked tools for this AI group + interface EnhancedAIGroupProps { + linkedTools?: Map; + displayItems?: AIGroupDisplayItem[]; + } + let linkedTools = (aiGroup as AIGroup & EnhancedAIGroupProps).linkedTools; + if (!linkedTools || linkedTools.size === 0) { + linkedTools = linkToolCallsToResults(aiGroup.steps, aiGroup.responses); + } + + let displayItems = (aiGroup as AIGroup & EnhancedAIGroupProps).displayItems; + if (!displayItems && aiGroup.steps && aiGroup.steps.length > 0) { + const lastOutput = findLastOutput(aiGroup.steps, aiGroup.isOngoing ?? false); + displayItems = buildDisplayItems( + aiGroup.steps, + lastOutput, + aiGroup.processes || [], + aiGroup.responses + ); + } + + // Compute stats for this group + const stats = computeContextStats({ + aiGroup, + userGroup: previousUserGroup, + linkedTools, + displayItems, + isFirstGroup: isFirstAiGroup, + previousInjections: accumulatedInjections, + projectRoot, + claudeMdTokenData, + mentionedFileTokenData, + directoryTokenData, + }); + + // Tag with phase number + stats.phaseNumber = currentPhaseNumber; + + // Build compaction token delta for this phase's first AI group + if (isFirstAiGroup && currentPhaseCompactGroupId && lastAIGroupBeforeCompact) { + const preTokens = getLastAssistantTotalTokens(lastAIGroupBeforeCompact); + // Use FIRST assistant message after compaction — it reflects the actual + // compacted context size before the AI generates more content. + const postTokens = getFirstAssistantTotalTokens(aiGroup); + if (preTokens !== undefined && postTokens !== undefined) { + compactionTokenDeltas.set(currentPhaseCompactGroupId, { + preCompactionTokens: preTokens, + postCompactionTokens: postTokens, + delta: postTokens - preTokens, + }); + } + } + + // Store stats + statsMap.set(aiGroup.id, stats); + + // Track phase boundaries + aiGroupPhaseMap.set(aiGroup.id, currentPhaseNumber); + if (!currentPhaseFirstAIGroupId) { + currentPhaseFirstAIGroupId = aiGroup.id; + } + currentPhaseLastAIGroupId = aiGroup.id; + lastAIGroupBeforeCompact = aiGroup; + + // Update accumulated state for next iteration + accumulatedInjections = stats.accumulatedInjections; + isFirstAiGroup = false; + previousUserGroup = null; + } + } + + // Finalize the last phase + if (currentPhaseFirstAIGroupId && currentPhaseLastAIGroupId) { + phases.push({ + phaseNumber: currentPhaseNumber, + firstAIGroupId: currentPhaseFirstAIGroupId, + lastAIGroupId: currentPhaseLastAIGroupId, + compactGroupId: currentPhaseCompactGroupId, + }); + } + + const phaseInfo: ContextPhaseInfo = { + phases, + compactionCount: currentPhaseNumber - 1, + aiGroupPhaseMap, + compactionTokenDeltas, + }; + + return { statsMap, phaseInfo }; +} + +// ============================================================================= +// Utility Functions +// ============================================================================= diff --git a/src/renderer/utils/dateGrouping.ts b/src/renderer/utils/dateGrouping.ts new file mode 100644 index 00000000..718c0351 --- /dev/null +++ b/src/renderer/utils/dateGrouping.ts @@ -0,0 +1,91 @@ +/** + * Date-based session grouping utility. + * Groups sessions by relative date categories: Today, Yesterday, Previous 7 Days, Older. + */ + +import { differenceInDays, isToday, isYesterday } from 'date-fns'; + +import { DATE_CATEGORY_ORDER } from '../types/tabs'; + +import type { Session } from '../types/data'; +import type { DateCategory, DateGroupedSessions } from '../types/tabs'; + +/** + * Groups sessions by relative date category. + * Sessions are categorized based on their createdAt timestamp: + * - Today: Sessions created today + * - Yesterday: Sessions created yesterday + * - Previous 7 Days: Sessions created 2-7 days ago + * - Older: Sessions created more than 7 days ago + * + * Within each category, sessions maintain their original sort order. + * + * @param sessions Array of sessions to group + * @returns Object with sessions grouped by date category + */ +export function groupSessionsByDate(sessions: Session[]): DateGroupedSessions { + const now = new Date(); + + return sessions.reduce( + (acc, session) => { + const sessionDate = new Date(session.createdAt); + + if (isToday(sessionDate)) { + acc.Today.push(session); + } else if (isYesterday(sessionDate)) { + acc.Yesterday.push(session); + } else if (differenceInDays(now, sessionDate) <= 7) { + acc['Previous 7 Days'].push(session); + } else { + acc.Older.push(session); + } + + return acc; + }, + { Today: [], Yesterday: [], 'Previous 7 Days': [], Older: [] } + ); +} + +/** + * Get non-empty date categories in display order. + * Useful for rendering only categories that have sessions. + * + * @param grouped The grouped sessions object + * @returns Array of non-empty category names in display order + */ +export function getNonEmptyCategories(grouped: DateGroupedSessions): DateCategory[] { + return DATE_CATEGORY_ORDER.filter((category) => grouped[category].length > 0); +} + +/** + * Separates sessions into pinned and unpinned groups. + * Pinned sessions are ordered by pin order (from pinnedSessionIds iteration order). + * + * @param sessions All sessions + * @param pinnedSessionIds Ordered array of pinned session IDs (most recently pinned first) + * @returns Object with pinned and unpinned session arrays + */ +export function separatePinnedSessions( + sessions: Session[], + pinnedSessionIds: string[] +): { pinned: Session[]; unpinned: Session[] } { + if (pinnedSessionIds.length === 0) { + return { pinned: [], unpinned: sessions }; + } + + const pinnedSet = new Set(pinnedSessionIds); + const sessionMap = new Map(sessions.map((s) => [s.id, s])); + + // Preserve pin order from pinnedSessionIds + const pinned: Session[] = []; + for (const id of pinnedSessionIds) { + const session = sessionMap.get(id); + if (session) { + pinned.push(session); + } + } + + const unpinned = sessions.filter((s) => !pinnedSet.has(s.id)); + + return { pinned, unpinned }; +} diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts new file mode 100644 index 00000000..b0681e70 --- /dev/null +++ b/src/renderer/utils/displayItemBuilder.ts @@ -0,0 +1,499 @@ +/** + * Display Item Builder - Build display items from semantic steps or messages + * + * Creates a flat chronological list of display items for the AI Group UI. + */ + +import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; + +import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers'; +import { extractSlashes, type PrecedingSlashInfo } from './slashCommandExtractor'; +import { linkToolCallsToResults } from './toolLinkingEngine'; + +import type { ParsedMessage, Process, SemanticStep } from '../types/data'; +import type { AIGroupDisplayItem, AIGroupLastOutput, LinkedToolItem } from '../types/groups'; + +/** + * Get the timestamp from a display item for sorting. + */ +function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date { + switch (item.type) { + case 'thinking': + case 'output': + return toDate(item.timestamp); + case 'tool': + return toDate(item.tool.startTime); + case 'subagent': + return toDate(item.subagent.startTime); + case 'slash': + return toDate(item.slash.timestamp); + case 'teammate_message': + return toDate(item.teammateMessage.timestamp); + } +} + +/** + * Sort display items chronologically. + */ +function sortDisplayItemsChronologically(items: AIGroupDisplayItem[]): void { + items.sort((a, b) => getDisplayItemTimestamp(a).getTime() - getDisplayItemTimestamp(b).getTime()); +} + +/** + * Link TeammateMessages to their triggering SendMessage calls. + * For each TeammateMessage, scans backwards through chronologically sorted items + * to find the most recent SendMessage to that teammate. + * Only matches type: "message" or "broadcast" (not shutdown_request/shutdown_response). + * Proactive messages (no preceding SendMessage) get no badge. + */ +function linkTeammateReplies(items: AIGroupDisplayItem[]): void { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type !== 'teammate_message') continue; + const tmMsg = item.teammateMessage; + + // Scan backwards for the most recent SendMessage to this teammate + for (let j = i - 1; j >= 0; j--) { + const prev = items[j]; + if (prev.type !== 'tool') continue; + if (prev.tool.name !== 'SendMessage') continue; + const input = prev.tool.input; + // Only match outbound messages (not shutdown_request, shutdown_response, etc.) + if (input.type !== 'message' && input.type !== 'broadcast') continue; + // Match by recipient (broadcast goes to all, so always matches) + if (input.type === 'message' && input.recipient !== tmMsg.teammateId) continue; + + tmMsg.replyToSummary = (input.summary as string) || 'message'; + tmMsg.replyToToolId = prev.tool.id; + break; + } + } +} + +/** + * Build a flat chronological list of display items for the AI Group. + * + * Strategy: + * 1. Skip the step that represents lastOutput (to avoid duplication) + * 2. For tool_call steps, use the LinkedToolItem (which includes the result) + * 3. Skip standalone tool_result steps (already linked to calls) + * 4. Skip Task tool_call steps that have associated subagents (avoid duplication) + * 5. Include thinking, subagent, and output steps + * 6. Return items in chronological order + * + * @param steps - Semantic steps from the AI Group + * @param lastOutput - The last output to skip + * @param subagents - Subagents associated with this group + * @param responses - Optional raw messages for extracting slash instructions + * @param precedingSlash - Optional slash info from the preceding UserGroup + * @returns Flat array of display items + */ +export function buildDisplayItems( + steps: SemanticStep[], + lastOutput: AIGroupLastOutput | null, + subagents: Process[], + responses?: ParsedMessage[], + precedingSlash?: PrecedingSlashInfo +): AIGroupDisplayItem[] { + const displayItems: AIGroupDisplayItem[] = []; + const linkedTools = linkToolCallsToResults(steps, responses); + + // Build set of Task IDs that have associated subagents + // This prevents duplicate display of Task tool calls when subagents are shown + const taskIdsWithSubagents = new Set( + subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) + ); + + // Find the step ID of lastOutput to skip it + let lastOutputStepId: string | undefined; + if (lastOutput) { + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if ( + lastOutput.type === 'text' && + step.type === 'output' && + step.content.outputText === lastOutput.text + ) { + lastOutputStepId = step.id; + break; + } + if ( + lastOutput.type === 'tool_result' && + step.type === 'tool_result' && + step.content.toolResultContent === lastOutput.toolResult + ) { + lastOutputStepId = step.id; + break; + } + if ( + lastOutput.type === 'interruption' && + step.type === 'interruption' && + step.content.interruptionText === lastOutput.interruptionMessage + ) { + lastOutputStepId = step.id; + break; + } + } + } + + // Build display items + for (const step of steps) { + // Skip the last output step + if (lastOutputStepId && step.id === lastOutputStepId) { + continue; + } + + switch (step.type) { + case 'thinking': + if (step.content.thinkingText) { + displayItems.push({ + type: 'thinking', + content: step.content.thinkingText, + timestamp: step.startTime, + tokenCount: estimateTokens(step.content.thinkingText), + }); + } + break; + + case 'tool_call': { + const linkedTool = linkedTools.get(step.id); + if (linkedTool) { + // Skip Task tool calls that have associated subagents + // The subagent will be shown separately, so showing the Task call is redundant + const isTaskWithSubagent = + linkedTool.name === 'Task' && taskIdsWithSubagents.has(step.id); + if (!isTaskWithSubagent) { + displayItems.push({ + type: 'tool', + tool: linkedTool, + }); + } + } + break; + } + + case 'tool_result': + // Skip - these are already included in LinkedToolItem + break; + + case 'subagent': { + const subagentId = step.content.subagentId; + const subagent = subagents.find((s) => s.id === subagentId); + if (subagent) { + displayItems.push({ + type: 'subagent', + subagent: subagent, + }); + } + break; + } + + case 'output': + if (step.content.outputText) { + displayItems.push({ + type: 'output', + content: step.content.outputText, + timestamp: step.startTime, + tokenCount: estimateTokens(step.content.outputText), + }); + } + break; + + case 'interruption': + if (step.content.interruptionText) { + displayItems.push({ + type: 'output', + content: step.content.interruptionText, + timestamp: step.startTime, + tokenCount: estimateTokens(step.content.interruptionText), + }); + } + break; + } + } + + // Add slashes as display items + if (responses) { + const slashes = extractSlashes(responses, precedingSlash); + for (const slash of slashes) { + displayItems.push({ + type: 'slash', + slash, + }); + } + } + + // Add teammate messages from responses (one user message may contain multiple blocks) + if (responses) { + for (const msg of responses) { + if (msg.type !== 'user' || msg.isMeta) continue; + const rawText = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content + .filter((b) => b.type === 'text') + .map((b) => b.text) + .join('') + : ''; + const parsedBlocks = parseAllTeammateMessages(rawText); + for (const parsed of parsedBlocks) { + displayItems.push({ + type: 'teammate_message', + teammateMessage: { + id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`, + teammateId: parsed.teammateId, + color: parsed.color, + summary: parsed.summary, + content: parsed.content, + timestamp: toDate(msg.timestamp), + tokenCount: estimateTokens(parsed.content), + }, + }); + } + } + } + + // Sort all items chronologically to ensure slashes appear in correct order + sortDisplayItemsChronologically(displayItems); + + // Link TeammateMessages to their triggering SendMessage calls + linkTeammateReplies(displayItems); + + return displayItems; +} + +/** + * Build display items from raw ParsedMessages (used by subagents). + * This mirrors the logic of buildDisplayItems but works with messages instead of SemanticSteps. + * + * Strategy: + * 1. Extract thinking blocks from assistant messages + * 2. Extract tool_use blocks from assistant messages -> collect in a Map by ID + * 3. Extract text output blocks from assistant messages + * 4. Extract tool_result blocks from user messages (isMeta or toolResults exist) + * 5. Link tool calls with their results using LinkedToolItem structure + * 6. Filter Task tool calls that have matching subagents + * 7. Include subagents as separate items + * 8. Sort all items chronologically + * + * @param messages - Raw ParsedMessages to process + * @param subagents - Subagents associated with these messages + * @returns Flat array of display items + */ +export function buildDisplayItemsFromMessages( + messages: ParsedMessage[], + subagents: Process[] = [] +): AIGroupDisplayItem[] { + const displayItems: AIGroupDisplayItem[] = []; + + // Maps for tool call/result linking + const toolCallsById = new Map< + string, + { + id: string; + name: string; + input: Record; + timestamp: Date; + sourceMessageId: string; + sourceModel?: string; + } + >(); + + const toolResultsById = new Map< + string, + { + content: string | unknown[]; + isError: boolean; + toolUseResult?: Record; + timestamp: Date; + } + >(); + + // Map to collect skill instructions by source tool use ID + // Skill tools have follow-up isMeta:true messages with instructions starting with "Base directory for this skill:" + const skillInstructionsById = new Map(); + + // Build set of Task IDs that have associated subagents + // This prevents duplicate display of Task tool calls when subagents are shown + const taskIdsWithSubagents = new Set( + subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id) + ); + + // First pass: collect tool calls and tool results from messages + for (const msg of messages) { + const msgTimestamp = toDate(msg.timestamp); + + // Check for teammate messages (non-meta user messages with content) + // One user message may contain multiple blocks + if (msg.type === 'user' && !msg.isMeta) { + const rawText = + typeof msg.content === 'string' + ? msg.content + : Array.isArray(msg.content) + ? msg.content + .filter((b) => b.type === 'text') + .map((b) => b.text) + .join('') + : ''; + const parsedBlocks = parseAllTeammateMessages(rawText); + if (parsedBlocks.length > 0) { + for (const parsed of parsedBlocks) { + displayItems.push({ + type: 'teammate_message', + teammateMessage: { + id: `${msg.uuid}-${parsed.teammateId}-${displayItems.length}`, + teammateId: parsed.teammateId, + color: parsed.color, + summary: parsed.summary, + content: parsed.content, + timestamp: msgTimestamp, + tokenCount: estimateTokens(parsed.content), + }, + }); + } + continue; + } + } + + if (msg.type === 'assistant' && Array.isArray(msg.content)) { + // Process assistant message content blocks + for (const block of msg.content) { + if (block.type === 'thinking' && block.thinking) { + // Add thinking block + displayItems.push({ + type: 'thinking', + content: block.thinking, + timestamp: msgTimestamp, + tokenCount: estimateTokens(block.thinking), + }); + } else if (block.type === 'tool_use' && block.id && block.name) { + // Collect tool call for later linking + toolCallsById.set(block.id, { + id: block.id, + name: block.name, + input: block.input ?? {}, + timestamp: msgTimestamp, + sourceMessageId: msg.uuid, + sourceModel: msg.model, + }); + } else if (block.type === 'text' && block.text) { + // Add text output + displayItems.push({ + type: 'output', + content: block.text, + timestamp: msgTimestamp, + tokenCount: estimateTokens(block.text), + }); + } + } + } else if (msg.type === 'user' && (msg.isMeta || msg.toolResults.length > 0)) { + // Process tool results from internal user messages + if (Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'tool_result' && block.tool_use_id) { + // Collect tool result for linking + toolResultsById.set(block.tool_use_id, { + content: block.content ?? '', + isError: block.is_error ?? false, + toolUseResult: msg.toolUseResult, + timestamp: msgTimestamp, + }); + } + + // Check for skill instructions: isMeta:true messages with sourceToolUseID + // containing text starting with "Base directory for this skill:" + if (block.type === 'text' && block.text && msg.sourceToolUseID) { + const text = block.text; + if (text.startsWith('Base directory for this skill:')) { + skillInstructionsById.set(msg.sourceToolUseID, text); + } + } + } + } + + // Also check msg.toolResults array (pre-extracted results) + for (const result of msg.toolResults) { + if (!toolResultsById.has(result.toolUseId)) { + toolResultsById.set(result.toolUseId, { + content: result.content, + isError: result.isError, + toolUseResult: msg.toolUseResult, + timestamp: msgTimestamp, + }); + } + } + } + } + + // Second pass: Build LinkedToolItems by matching calls with results + for (const [toolId, call] of toolCallsById.entries()) { + const result = toolResultsById.get(toolId); + + // Skip Task tool calls that have associated subagents + // The subagent will be shown separately, so showing the Task call is redundant + const isTaskWithSubagent = call.name === 'Task' && taskIdsWithSubagents.has(toolId); + if (isTaskWithSubagent) { + continue; + } + + // Get skill instructions for Skill tool calls + const skillInstructions = call.name === 'Skill' ? skillInstructionsById.get(toolId) : undefined; + + const linkedItem: LinkedToolItem = { + id: toolId, + name: call.name, + input: call.input, + result: result + ? { + content: result.content, + isError: result.isError, + toolUseResult: result.toolUseResult, + } + : undefined, + inputPreview: formatToolInput(call.input), + outputPreview: result ? formatToolResult(result.content) : undefined, + startTime: call.timestamp, + endTime: result?.timestamp, + durationMs: result?.timestamp + ? result.timestamp.getTime() - call.timestamp.getTime() + : undefined, + isOrphaned: !result, + sourceModel: call.sourceModel, + skillInstructions, + skillInstructionsTokenCount: skillInstructions + ? estimateTokens(skillInstructions) + : undefined, + }; + + displayItems.push({ + type: 'tool', + tool: linkedItem, + }); + } + + // Add subagents as display items + for (const subagent of subagents) { + displayItems.push({ + type: 'subagent', + subagent: subagent, + }); + } + + // Add slashes as display items + const slashes = extractSlashes(messages); + for (const slash of slashes) { + displayItems.push({ + type: 'slash', + slash, + }); + } + + // Sort all items chronologically + sortDisplayItemsChronologically(displayItems); + + // Link TeammateMessages to their triggering SendMessage calls + linkTeammateReplies(displayItems); + + return displayItems; +} diff --git a/src/renderer/utils/displaySummary.ts b/src/renderer/utils/displaySummary.ts new file mode 100644 index 00000000..28c6c31d --- /dev/null +++ b/src/renderer/utils/displaySummary.ts @@ -0,0 +1,67 @@ +/** + * Display Summary - Build human-readable summaries of display items + * + * Creates formatted summary strings for AI Group display item counts. + */ + +import type { AIGroupDisplayItem } from '../types/groups'; + +/** + * Build a human-readable summary of display items. + * + * Strategy: + * 1. Count items by type (thinking, tool, output, subagent, slash) + * 2. Format as "X thinking, Y tool calls, Z messages, N subagents, M slashes" + * 3. Skip counts that are zero + * 4. Return formatted string + * + * @param items - Display items to summarize + * @returns Formatted summary string + */ +export function buildSummary(items: AIGroupDisplayItem[]): string { + const counts = { + thinking: 0, + tool: 0, + output: 0, + subagent: 0, + slash: 0, + teammate_message: 0, + }; + const teammateNames = new Set(); + + for (const item of items) { + if (item.type === 'subagent' && item.subagent.team) { + teammateNames.add(item.subagent.team.memberName); + } else { + counts[item.type]++; + } + } + + const parts: string[] = []; + + if (counts.thinking > 0) { + parts.push(`${counts.thinking} thinking`); + } + if (counts.tool > 0) { + parts.push(`${counts.tool} tool ${counts.tool === 1 ? 'call' : 'calls'}`); + } + if (counts.output > 0) { + parts.push(`${counts.output} ${counts.output === 1 ? 'message' : 'messages'}`); + } + if (teammateNames.size > 0) { + parts.push(`${teammateNames.size} ${teammateNames.size === 1 ? 'teammate' : 'teammates'}`); + } + if (counts.subagent > 0) { + parts.push(`${counts.subagent} ${counts.subagent === 1 ? 'subagent' : 'subagents'}`); + } + if (counts.slash > 0) { + parts.push(`${counts.slash} ${counts.slash === 1 ? 'slash' : 'slashes'}`); + } + if (counts.teammate_message > 0) { + parts.push( + `${counts.teammate_message} teammate ${counts.teammate_message === 1 ? 'message' : 'messages'}` + ); + } + + return parts.length > 0 ? parts.join(', ') : 'No items'; +} diff --git a/src/renderer/utils/formatters.ts b/src/renderer/utils/formatters.ts new file mode 100644 index 00000000..1ebc9f16 --- /dev/null +++ b/src/renderer/utils/formatters.ts @@ -0,0 +1,22 @@ +/** + * Formatting utility functions for display values. + */ + +// Re-export token formatting from shared module +export { formatTokensCompact } from '@shared/utils/tokenFormatting'; + +/** + * Formats duration in milliseconds to a human-readable string. + */ +export function formatDuration(ms: number): string { + if (ms < 1000) { + return `${Math.round(ms)}ms`; + } + const seconds = ms / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} diff --git a/src/renderer/utils/groupTransformer.ts b/src/renderer/utils/groupTransformer.ts new file mode 100644 index 00000000..45a68a83 --- /dev/null +++ b/src/renderer/utils/groupTransformer.ts @@ -0,0 +1,700 @@ +/** + * Transforms EnhancedChunk[] into SessionConversation structure. + * + * This module converts chunk-based data into a flat list of ChatItems + * (UserGroups, SystemGroups, AIGroups) for a chat-style display. + * Each item is independent - no pairing between user and AI chunks. + */ + +import { + isAssistantMessage, + isEnhancedAIChunk, + isEnhancedCompactChunk, + isEnhancedSystemChunk, + isEnhancedUserChunk, +} from '@renderer/types/data'; +import { getFirstSegment, hasPathSeparator, isRelativePath } from '@renderer/utils/pathUtils'; +import { isCommandContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; +import { createLogger } from '@shared/utils/logger'; + +import type { + EnhancedAIChunk, + EnhancedChunk, + EnhancedCompactChunk, + EnhancedSystemChunk, + EnhancedUserChunk, + ParsedMessage, + Process, + SemanticStep, +} from '@renderer/types/data'; +import type { + AIGroup, + AIGroupStatus, + AIGroupSummary, + AIGroupTokens, + ChatItem, + CommandInfo, + CompactGroup, + FileReference, + ImageData, + SessionConversation, + SystemGroup, + UserGroup, + UserGroupContent, +} from '@renderer/types/groups'; + +const logger = createLogger('Util:groupTransformer'); + +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Regex pattern for detecting slash commands. + * Matches: /command-name [optional args] + * Uses non-greedy matching and limited repetition to prevent ReDoS. + */ +// eslint-disable-next-line security/detect-unsafe-regex -- Pattern is safe: limited to 1000 chars and used on bounded user input +const COMMAND_PATTERN = /\/([a-z][a-z-]{0,50})(?:\s+(\S[^\n]{0,1000}))?$/gim; + +/** + * Maximum characters to extract for thinking preview. + */ +const THINKING_PREVIEW_LENGTH = 100; + +// ============================================================================= +// Main Transformation Function +// ============================================================================= + +/** + * Transforms EnhancedChunk[] into SessionConversation. + * + * Produces a flat list of independent ChatItems (user, system, AI). + * Each chunk type becomes its own item - no pairing or grouping. + * + * @param chunks - Array of enhanced chunks with semantic steps + * @param _subagents - Array of all subagents in the session (unused, processes come from chunks) + * @param isOngoing - Whether the session is still in progress (marks last AI group) + * @returns SessionConversation structure for chat-style rendering + */ +export function transformChunksToConversation( + chunks: EnhancedChunk[], + _subagents: Process[], + isOngoing: boolean = false +): SessionConversation { + if (!chunks || chunks.length === 0) { + return { + sessionId: '', + items: [], + totalUserGroups: 0, + totalSystemGroups: 0, + totalAIGroups: 0, + totalCompactGroups: 0, + }; + } + + const items: ChatItem[] = []; + let userCount = 0; + let systemCount = 0; + let aiCount = 0; + let compactCount = 0; + + for (const chunk of chunks) { + if (isEnhancedUserChunk(chunk)) { + items.push({ + type: 'user', + group: createUserGroupFromChunk(chunk, userCount++), + }); + } else if (isEnhancedSystemChunk(chunk)) { + items.push({ + type: 'system', + group: createSystemGroup(chunk), + }); + systemCount++; + } else if (isEnhancedAIChunk(chunk)) { + items.push({ + type: 'ai', + group: createAIGroupFromChunk(chunk, aiCount), + }); + aiCount++; + } else if (isEnhancedCompactChunk(chunk)) { + items.push({ + type: 'compact', + group: createCompactGroup(chunk), + }); + compactCount++; + } else { + const unhandledChunkType = + 'chunkType' in chunk ? (chunk as EnhancedChunk).chunkType : 'unknown'; + logger.warn('Unhandled chunk type:', unhandledChunkType); + } + } + + // Post-pass: enrich CompactGroups with token deltas + let phaseCounter = 1; + for (let i = 0; i < items.length; i++) { + if (items[i].type === 'compact') { + phaseCounter++; + const compactItem = items[i] as { type: 'compact'; group: CompactGroup }; + compactItem.group.startingPhaseNumber = phaseCounter; + + // Find last AI group before and first AI group after + const preAi = findLastAiBefore(items, i); + const postAi = findFirstAiAfter(items, i); + if (preAi && postAi) { + const pre = getLastAssistantTotalTokens(preAi); + // Use FIRST assistant message after compaction — it reflects the actual + // compacted context size before the AI generates more content. + const post = getFirstAssistantTotalTokens(postAi); + if (pre !== undefined && post !== undefined) { + compactItem.group.tokenDelta = { + preCompactionTokens: pre, + postCompactionTokens: post, + delta: post - pre, + }; + } + } + } + } + + // If session is ongoing, mark the last AI group (but don't override interrupted status) + if (isOngoing && aiCount > 0) { + // Find the last AI item and mark it as ongoing + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (item.type === 'ai') { + const currentStatus = item.group.status; + // Don't override 'interrupted' status - interruption takes precedence over ongoing + if (currentStatus !== 'interrupted') { + (item.group as AIGroup & { isOngoing?: boolean }).isOngoing = true; + (item.group as AIGroup & { status?: AIGroupStatus }).status = 'in_progress'; + } + break; + } + } + } + + return { + sessionId: chunks[0]?.id ?? 'unknown', + items, + totalUserGroups: userCount, + totalSystemGroups: systemCount, + totalAIGroups: aiCount, + totalCompactGroups: compactCount, + }; +} + +// ============================================================================= +// UserGroup Creation +// ============================================================================= + +/** + * Creates a UserGroup from an EnhancedUserChunk. + * + * @param chunk - The user chunk to transform + * @param index - Index within the session (for ordering) + * @returns UserGroup with parsed content + */ +function createUserGroupFromChunk(chunk: EnhancedUserChunk, index: number): UserGroup { + return createUserGroup(chunk.userMessage, index); +} + +/** + * Creates a UserGroup from a ParsedMessage. + * + * @param message - The user's input message + * @param index - Index within the session (for ordering) + * @returns UserGroup with parsed content + */ +function createUserGroup(message: ParsedMessage, index: number): UserGroup { + const content = extractUserGroupContent(message); + + return { + id: `user-${message.uuid}`, + message, + timestamp: message.timestamp, + content, + index, + }; +} + +/** + * Extracts and parses content from a user message. + * + * @param message - The user message to parse + * @returns Parsed UserGroupContent + */ +function extractUserGroupContent(message: ParsedMessage): UserGroupContent { + let rawText = ''; + const images: ImageData[] = []; + const fileReferences: FileReference[] = []; + + // Extract text from content + // Note: Image handling not yet implemented - images are not part of ContentBlock type + if (typeof message.content === 'string') { + rawText = message.content; + } else if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'text' && block.text) { + rawText += block.text; + } + } + } + + // Sanitize content for display (handles XML tags from command messages) + // This converts /model to "/model" + const sanitizedText = sanitizeDisplayContent(rawText); + + // Check if this is a command message (for special handling) + const isCommand = isCommandContent(rawText); + + // Extract commands from the sanitized text (for inline /commands in regular messages) + // For command messages, the command is already extracted as sanitizedText + const commands = isCommand ? [] : extractCommands(sanitizedText); + + // Extract file references (@file.ts) from sanitized text + fileReferences.push(...extractFileReferences(sanitizedText)); + + // For command messages, use the sanitized command as display text + // For regular messages, remove inline commands from display + let displayText = sanitizedText; + if (!isCommand) { + for (const cmd of commands) { + displayText = displayText.replace(cmd.raw, '').trim(); + } + } + + return { + text: displayText || undefined, + rawText: sanitizedText, // Use sanitized version as rawText for display + commands, + images, + fileReferences, + }; +} + +/** + * Extracts commands from text using regex. + * + * @param text - Text to parse for commands + * @returns Array of CommandInfo objects + */ +function extractCommands(text: string): CommandInfo[] { + if (!text) return []; + + const commands: CommandInfo[] = []; + let match: RegExpExecArray | null; + + // Reset regex state + COMMAND_PATTERN.lastIndex = 0; + + while ((match = COMMAND_PATTERN.exec(text)) !== null) { + const [fullMatch, commandName, args] = match; + commands.push({ + name: commandName, + args: args?.trim(), + raw: fullMatch, + startIndex: match.index, + endIndex: match.index + fullMatch.length, + }); + } + + return commands; +} + +/** + * Known directory prefixes that identify file references. + */ +const KNOWN_DIRS = new Set([ + 'src', + 'apps', + 'app', + 'lib', + 'types', + 'packages', + 'components', + 'utils', + 'services', + 'hooks', + 'store', + 'renderer', + 'main', + 'preload', + 'public', + 'assets', + 'config', + 'tests', + 'test', + 'specs', + 'spec', + 'e2e', + 'docs', + 'scripts', + 'screens', + 'features', + 'pages', + 'views', + 'models', + 'controllers', + 'routes', + 'middleware', + 'api', + 'common', + 'shared', + 'core', + 'modules', + 'client', + 'server', + 'web', + 'mobile', + 'native', + 'electron', + 'node_modules', +]); + +/** + * Simple pattern for detecting @ mentions that could be file paths. + * The filtering logic in extractFileReferences determines validity. + */ +const FILE_REF_PATTERN = /@([~a-zA-Z0-9._/-]+)/g; + +/** + * Checks if a path looks like a valid file reference. + * Must start with known dir, contain /, or start with ./ or ../ + */ +function isValidFileRef(path: string): boolean { + // Check for relative path indicators + if (isRelativePath(path)) { + return true; + } + // Check if starts with known directory + const first = getFirstSegment(path); + if (KNOWN_DIRS.has(first)) { + return true; + } + // Check if contains a path separator (indicates directory structure) + if (hasPathSeparator(path) && path.length > 2) { + return true; + } + return false; +} + +/** + * Extracts file references (@file.ts) from text. + * + * @param text - Text to parse for file references + * @returns Array of FileReference objects + */ +export function extractFileReferences(text: string): FileReference[] { + if (!text) return []; + + const references: FileReference[] = []; + // Reset regex state before use + FILE_REF_PATTERN.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = FILE_REF_PATTERN.exec(text)) !== null) { + const [fullMatch, path] = match; + // Only include if it looks like a valid file reference + if (isValidFileRef(path)) { + references.push({ + path, + raw: fullMatch, + }); + } + } + + return references; +} + +// ============================================================================= +// SystemGroup Creation +// ============================================================================= + +/** + * Creates a SystemGroup from an EnhancedSystemChunk. + * + * @param chunk - The system chunk to transform + * @returns SystemGroup with command output + */ +function createSystemGroup(chunk: EnhancedSystemChunk): SystemGroup { + return { + id: chunk.id, // Use stable chunk ID instead of array index + message: chunk.message, + timestamp: chunk.startTime, + commandOutput: chunk.commandOutput, + }; +} + +// ============================================================================= +// CompactGroup Creation +// ============================================================================= + +/** + * Creates a CompactGroup from an EnhancedCompactChunk. + * + * @param chunk - The compact chunk to transform + * @returns CompactGroup marking where conversation was compacted, with message content + */ +function createCompactGroup(chunk: EnhancedCompactChunk): CompactGroup { + return { + id: chunk.id, // Use stable chunk ID instead of array index + timestamp: chunk.startTime, + message: chunk.message, // Pass through the compact summary message + }; +} + +// ============================================================================= +// AIGroup Creation +// ============================================================================= + +/** + * Creates an AIGroup from an EnhancedAIChunk. + * + * @param chunk - The AI chunk to transform + * @param turnIndex - 0-based index of this AI group within the session + * @returns AIGroup with semantic steps and metrics + */ +function createAIGroupFromChunk(chunk: EnhancedAIChunk, turnIndex: number): AIGroup { + const steps = chunk.semanticSteps; + + // Calculate timing from all steps + const startTime = steps.length > 0 ? steps[0].startTime : chunk.startTime; + const endTime = + steps.length > 0 + ? (steps[steps.length - 1].endTime ?? steps[steps.length - 1].startTime) + : chunk.endTime; + const durationMs = endTime.getTime() - startTime.getTime(); + + // Find any source assistant message for token calculation + const sourceMessage = chunk.responses.find((msg) => isAssistantMessage(msg)) ?? null; + + // Calculate tokens from all steps + const tokens = calculateTokensFromSteps(steps, sourceMessage); + + // Generate summary from all steps + const summary = computeAIGroupSummary(steps); + + // Determine status from all steps + const status = determineAIGroupStatus(steps); + + return { + id: chunk.id, // Use stable chunk ID instead of array index + turnIndex, + startTime, + endTime, + durationMs, + steps, + tokens, + summary, + status, + processes: chunk.processes, + chunkId: chunk.id, + metrics: chunk.metrics, + responses: chunk.responses, + }; +} + +/** + * Calculates token metrics from semantic steps and source message. + * + * @param steps - Semantic steps in this AI Group + * @param sourceMessage - Source assistant message (if available) + * @returns Token metrics + */ +function calculateTokensFromSteps( + steps: SemanticStep[], + sourceMessage: ParsedMessage | null | undefined +): AIGroupTokens { + let input = 0; + let output = 0; + let cached = 0; + let thinking = 0; + + // Sum from steps + for (const step of steps) { + if (step.tokens) { + input += step.tokens.input ?? 0; + output += step.tokens.output ?? 0; + cached += step.tokens.cached ?? 0; + } + if (step.tokenBreakdown) { + input += step.tokenBreakdown.input ?? 0; + output += step.tokenBreakdown.output ?? 0; + cached += step.tokenBreakdown.cacheRead ?? 0; + } + if (step.type === 'thinking' && step.tokens?.output) { + thinking += step.tokens.output; + } + } + + // Override with source message usage if available (more accurate) + if (sourceMessage?.usage) { + input = sourceMessage.usage.input_tokens ?? 0; + output = sourceMessage.usage.output_tokens ?? 0; + cached = sourceMessage.usage.cache_read_input_tokens ?? 0; + } + + return { + input, + output, + cached, + thinking, + }; +} + +// ============================================================================= +// AIGroup Summary & Status Computation +// ============================================================================= + +/** + * Computes summary statistics for an AIGroup's collapsed view. + * + * @param steps - Semantic steps in the AI Group + * @returns Summary statistics + */ +function computeAIGroupSummary(steps: SemanticStep[]): AIGroupSummary { + let thinkingPreview: string | undefined; + let toolCallCount = 0; + let outputMessageCount = 0; + let subagentCount = 0; + let totalDurationMs = 0; + let totalTokens = 0; + let outputTokens = 0; + let cachedTokens = 0; + + for (const step of steps) { + // Extract thinking preview from first thinking step + if (!thinkingPreview && step.type === 'thinking' && step.content.thinkingText) { + const fullText = step.content.thinkingText; + thinkingPreview = + fullText.length > THINKING_PREVIEW_LENGTH + ? fullText.slice(0, THINKING_PREVIEW_LENGTH) + '...' + : fullText; + } + + // Count step types + if (step.type === 'tool_call') toolCallCount++; + if (step.type === 'output') outputMessageCount++; + if (step.type === 'subagent') subagentCount++; + + // Sum duration + totalDurationMs += step.durationMs ?? 0; + + // Sum tokens + if (step.tokens) { + totalTokens += (step.tokens.input ?? 0) + (step.tokens.output ?? 0); + outputTokens += step.tokens.output ?? 0; + cachedTokens += step.tokens.cached ?? 0; + } + if (step.tokenBreakdown) { + totalTokens += step.tokenBreakdown.input + step.tokenBreakdown.output; + outputTokens += step.tokenBreakdown.output; + cachedTokens += step.tokenBreakdown.cacheRead; + } + } + + return { + thinkingPreview, + toolCallCount, + outputMessageCount, + subagentCount, + totalDurationMs, + totalTokens, + outputTokens, + cachedTokens, + }; +} + +/** + * Determines the status of an AIGroup based on its steps. + * + * @param steps - Semantic steps in the AI Group + * @returns AIGroupStatus + */ +function determineAIGroupStatus(steps: SemanticStep[]): AIGroupStatus { + if (steps.length === 0) return 'error'; + + // Check for interruption + const hasInterruption = steps.some((step) => step.type === 'interruption'); + if (hasInterruption) return 'interrupted'; + + // Check for errors + const hasError = steps.some((step) => step.type === 'tool_result' && step.content.isError); + if (hasError) return 'error'; + + // Check if any step is incomplete (no endTime) + const hasIncomplete = steps.some((step) => !step.endTime); + if (hasIncomplete) return 'in_progress'; + + // Otherwise, complete + return 'complete'; +} + +// ============================================================================= +// CompactGroup Enrichment Helpers +// ============================================================================= + +/** + * Find the last AI group before a given index in the items array. + */ +function findLastAiBefore(items: ChatItem[], index: number): AIGroup | null { + for (let i = index - 1; i >= 0; i--) { + if (items[i].type === 'ai') return items[i].group as AIGroup; + } + return null; +} + +/** + * Find the first AI group after a given index in the items array. + */ +function findFirstAiAfter(items: ChatItem[], index: number): AIGroup | null { + for (let i = index + 1; i < items.length; i++) { + if (items[i].type === 'ai') return items[i].group as AIGroup; + } + return null; +} + +/** + * Get total tokens from the last assistant message in an AI group. + * Sums input_tokens, output_tokens, cache_read_input_tokens, and cache_creation_input_tokens. + */ +function getLastAssistantTotalTokens(aiGroup: AIGroup): number | undefined { + const responses = aiGroup.responses || []; + for (let i = responses.length - 1; i >= 0; i--) { + const msg = responses[i]; + if (msg.type === 'assistant' && msg.usage) { + return ( + (msg.usage.input_tokens ?? 0) + + (msg.usage.output_tokens ?? 0) + + (msg.usage.cache_read_input_tokens ?? 0) + + (msg.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return undefined; +} + +/** + * Get total tokens from the FIRST assistant message in an AI group. + * Used for post-compaction token measurement: the first response after compaction + * reflects the actual compacted context size before the AI generates more content. + */ +function getFirstAssistantTotalTokens(aiGroup: AIGroup): number | undefined { + const responses = aiGroup.responses || []; + for (const msg of responses) { + if (msg.type === 'assistant' && msg.usage) { + return ( + (msg.usage.input_tokens ?? 0) + + (msg.usage.output_tokens ?? 0) + + (msg.usage.cache_read_input_tokens ?? 0) + + (msg.usage.cache_creation_input_tokens ?? 0) + ); + } + } + return undefined; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= diff --git a/src/renderer/utils/lastOutputDetector.ts b/src/renderer/utils/lastOutputDetector.ts new file mode 100644 index 00000000..032a8037 --- /dev/null +++ b/src/renderer/utils/lastOutputDetector.ts @@ -0,0 +1,150 @@ +/** + * Last Output Detector - Find the last visible output in an AI Group + * + * Uses a state machine approach to find the last meaningful output + * for display in the chat UI. + */ + +import { toDate } from './aiGroupHelpers'; + +import type { SemanticStep } from '../types/data'; +import type { AIGroupLastOutput } from '../types/groups'; + +/** + * Find the last visible output in the AI Group. + * + * Strategy: + * 1. If isOngoing is true, return 'ongoing' type (session still in progress) + * 2. Check for ExitPlanMode tool_call as special 'plan_exit' type + * 3. Iterate through steps in reverse order + * 4. Find the last 'output' step with outputText + * 5. If no output found, find the last 'tool_result' step + * 6. If no tool_result found, find the last 'interruption' step + * 7. Return null if none exists + * + * Special case: ExitPlanMode + * When the last tool_call is ExitPlanMode, return 'plan_exit' type with the plan content. + * The preamble text (if any) is captured from the preceding output step. + * + * @param steps - Semantic steps from the AI Group + * @param isOngoing - Whether this AI group is still in progress + * @returns The last output or null + */ +export function findLastOutput( + steps: SemanticStep[], + isOngoing: boolean = false +): AIGroupLastOutput | null { + // Check for interruption first - interruption takes precedence over ongoing status + // This ensures user interruptions are always visible even if session appears ongoing + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'interruption') { + return { + type: 'interruption', + timestamp: step.startTime, + }; + } + } + + // If session is ongoing (and no interruption), return 'ongoing' type + if (isOngoing) { + return { + type: 'ongoing', + timestamp: steps.length > 0 ? toDate(steps[steps.length - 1].startTime) : new Date(), + }; + } + + // Check for ExitPlanMode as the last significant activity + // ExitPlanMode is a special ending tool that signals plan completion + let lastExitPlanModeStep: SemanticStep | null = null; + let lastOutputBeforeExitPlanMode: SemanticStep | null = null; + + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'tool_call' && step.content.toolName === 'ExitPlanMode') { + lastExitPlanModeStep = step; + // Look for the preceding output step (preamble text) + for (let j = i - 1; j >= 0; j--) { + if (steps[j].type === 'output' && steps[j].content.outputText) { + lastOutputBeforeExitPlanMode = steps[j]; + break; + } + } + break; + } + } + + // If ExitPlanMode is found, check if it's the "last" activity + // (no other output or tool_result comes after it) + if (lastExitPlanModeStep) { + const exitPlanModeIndex = steps.indexOf(lastExitPlanModeStep); + let hasLaterEnding = false; + + for (let i = exitPlanModeIndex + 1; i < steps.length; i++) { + const step = steps[i]; + if (step.type === 'output' && step.content.outputText) { + hasLaterEnding = true; + break; + } + if (step.type === 'tool_result' && step.content.toolResultContent) { + hasLaterEnding = true; + break; + } + } + + if (!hasLaterEnding) { + // ExitPlanMode is the last significant activity - return plan_exit + const toolInput = lastExitPlanModeStep.content.toolInput as + | Record + | undefined; + const planContent = toolInput?.plan as string | undefined; + + return { + type: 'plan_exit', + planContent: planContent ?? '', + planPreamble: lastOutputBeforeExitPlanMode?.content.outputText, + timestamp: lastExitPlanModeStep.startTime, + }; + } + } + + // First pass: look for last 'output' step with outputText + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'output' && step.content.outputText) { + return { + type: 'text', + text: step.content.outputText, + timestamp: step.startTime, + }; + } + } + + // Second pass: look for last 'tool_result' step + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'tool_result' && step.content.toolResultContent) { + return { + type: 'tool_result', + toolName: step.content.toolName, + toolResult: step.content.toolResultContent, + isError: step.content.isError ?? false, + timestamp: step.startTime, + }; + } + } + + // Third pass: look for last 'interruption' step + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if (step.type === 'interruption' && step.content.interruptionText) { + return { + type: 'interruption', + interruptionMessage: step.content.interruptionText, + timestamp: step.startTime, + }; + } + } + + return null; +} diff --git a/src/renderer/utils/modelExtractor.ts b/src/renderer/utils/modelExtractor.ts new file mode 100644 index 00000000..355b5f61 --- /dev/null +++ b/src/renderer/utils/modelExtractor.ts @@ -0,0 +1,90 @@ +/** + * Model Extractor - Extract model information from AI Group data + * + * Parses and extracts model information from semantic steps and subagent processes. + */ + +import { type ModelInfo, parseModelString } from '@shared/utils/modelParser'; + +import type { Process, SemanticStep } from '@renderer/types/data'; + +/** + * Extract the main model used in an AI Group. + * + * Strategy: + * 1. Look through semantic steps to find tool_call steps with sourceModel + * 2. Count occurrences of each model + * 3. Return the most common model (in case of mixed usage) + * + * @param steps - Semantic steps from the AI Group + * @returns The most common model info, or null if no models found + */ +export function extractMainModel(steps: SemanticStep[]): ModelInfo | null { + const modelCounts = new Map(); + + for (const step of steps) { + // Tool call steps have sourceModel set from the assistant message + if (step.type === 'tool_call' && step.content.sourceModel) { + const model = step.content.sourceModel; + if (model && model !== '') { + const info = parseModelString(model); + if (info) { + const existing = modelCounts.get(info.name); + if (existing) { + existing.count++; + } else { + modelCounts.set(info.name, { count: 1, info }); + } + } + } + } + } + + // Find most common model + let maxCount = 0; + let mainModel: ModelInfo | null = null; + + for (const { count, info } of modelCounts.values()) { + if (count > maxCount) { + maxCount = count; + mainModel = info; + } + } + + return mainModel; +} + +/** + * Extract unique models used by subagents that differ from the main model. + * + * Strategy: + * 1. Iterate through all processes (subagents) + * 2. Find the first assistant message with a valid model in each process + * 3. Parse and collect unique models that differ from mainModel + * + * @param processes - Subagent processes from the AI Group + * @param mainModel - The main agent's model (to filter out) + * @returns Array of unique model infos used by subagents + */ +export function extractSubagentModels( + processes: Process[], + mainModel: ModelInfo | null +): ModelInfo[] { + const uniqueModels = new Map(); + + for (const process of processes) { + // Find first assistant message with a valid model + const assistantMsg = process.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + + if (assistantMsg?.model) { + const modelInfo = parseModelString(assistantMsg.model); + if (modelInfo && modelInfo.name !== mainModel?.name) { + uniqueModels.set(modelInfo.name, modelInfo); + } + } + } + + return Array.from(uniqueModels.values()); +} diff --git a/src/renderer/utils/pathDisplay.ts b/src/renderer/utils/pathDisplay.ts new file mode 100644 index 00000000..7adf60e3 --- /dev/null +++ b/src/renderer/utils/pathDisplay.ts @@ -0,0 +1,94 @@ +/** + * Path display utilities for shortening file paths in tight UI spaces. + * + * Strategy: + * 1. Strip project root to make relative + * 2. Replace home directory with ~ + * 3. Middle-truncate if still too long, preserving first and last segments + * + * Also provides resolveAbsolutePath() for clipboard copy (~ → real home, relative → absolute). + */ + +/** + * Shorten a file path for display in compact UI elements. + * Full path should still be available via tooltip (title attribute). + * + * Examples: + * - `/Users/name/.claude/projects/-Users-name-project/memory/MEMORY.md` → `~/.claude/…/memory/MEMORY.md` + * - `/Users/name/project/.claude/rules/tailwind.md` (with projectRoot) → `.claude/rules/tailwind.md` + * - `~/.claude/CLAUDE.md` → `~/.claude/CLAUDE.md` (already short) + */ +export function shortenDisplayPath(fullPath: string, projectRoot?: string, maxLength = 40): string { + let p = fullPath; + + // 1. Make relative to project root + if (projectRoot) { + const root = projectRoot.replace(/[/\\]$/, ''); + if (p.startsWith(root + '/') || p.startsWith(root + '\\')) { + p = p.slice(root.length + 1); + } + } + + // 2. Replace home directory with ~ + p = p + .replace(/^\/Users\/[^/]+/, '~') + .replace(/^\/home\/[^/]+/, '~') + .replace(/^[A-Z]:\\Users\\[^\\]+/, '~'); + + // 3. If short enough, return as-is + if (p.length <= maxLength) return p; + + // 4. Middle-truncate: keep first meaningful segments + … + last 2 segments + const sep = p.includes('\\') ? '\\' : '/'; + const segments = p.split(sep); + + // Determine where content starts (skip leading empty segment from absolute paths or ~) + let startIdx = 0; + if (segments[0] === '' || segments[0] === '~') startIdx = 1; + + // Need at least 4 content segments to truncate the middle + if (segments.length - startIdx <= 3) return p; + + const head = segments.slice(0, startIdx + 1).join(sep); + const tail = segments.slice(-2).join(sep); + + return `${head}${sep}\u2026${sep}${tail}`; +} + +/** + * Infer the user's home directory from a known absolute project path. + * Works for macOS (/Users/x), Linux (/home/x), and Windows (C:\Users\x). + */ +function inferHomeDir(projectRoot: string): string | null { + const match = + /^(\/Users\/[^/]+)/.exec(projectRoot) ?? + /^(\/home\/[^/]+)/.exec(projectRoot) ?? + /^([A-Z]:\\Users\\[^\\]+)/.exec(projectRoot); + return match?.[1] ?? null; +} + +/** + * Resolve a possibly-shortened path to its full absolute form for clipboard copy. + * + * - `~/...` → `/Users/username/...` (home dir inferred from projectRoot) + * - `src/foo/bar` → `{projectRoot}/src/foo/bar` + * - Already absolute → returned as-is + */ +export function resolveAbsolutePath(filePath: string, projectRoot?: string): string { + let p = filePath; + + // Resolve ~ using home dir inferred from projectRoot + if (p.startsWith('~/') && projectRoot) { + const homeDir = inferHomeDir(projectRoot); + if (homeDir) { + p = homeDir + p.slice(1); + } + } + + // Make relative paths absolute by prepending projectRoot + if (projectRoot && !p.startsWith('/') && !p.startsWith('~') && !/^[A-Z]:[/\\]/.test(p)) { + p = projectRoot.replace(/[/\\]$/, '') + '/' + p; + } + + return p; +} diff --git a/src/renderer/utils/pathUtils.ts b/src/renderer/utils/pathUtils.ts new file mode 100644 index 00000000..fbbe578d --- /dev/null +++ b/src/renderer/utils/pathUtils.ts @@ -0,0 +1,47 @@ +/** + * Cross-platform path utilities for the renderer process. + * + * The renderer has no access to Node's `path` module, and session data + * may originate from any OS, so all helpers handle both `/` and `\`. + */ + +const SEP_RE = /[\\/]/; + +/** + * Returns the last segment of a path (the file or directory name). + * Equivalent to `path.basename()` but handles both separators. + */ +export function getBaseName(filePath: string): string { + const parts = filePath.split(SEP_RE); + return parts[parts.length - 1] || ''; +} + +/** + * Returns the first meaningful segment of a path. + * Leading empty segments (from absolute paths like `/foo`) are skipped. + */ +export function getFirstSegment(filePath: string): string { + const parts = filePath.split(SEP_RE).filter(Boolean); + return parts[0] ?? ''; +} + +/** + * Splits a path into non-empty segments. + */ +export function splitPathSegments(filePath: string): string[] { + return filePath.split(SEP_RE).filter(Boolean); +} + +/** + * Returns true if the string contains a path separator (`/` or `\`). + */ +export function hasPathSeparator(filePath: string): boolean { + return SEP_RE.test(filePath); +} + +/** + * Returns true if the path starts with `./`, `.\`, `../`, or `..\`. + */ +export function isRelativePath(filePath: string): boolean { + return /^\.\.?[\\/]/.test(filePath); +} diff --git a/src/renderer/utils/slashCommandExtractor.ts b/src/renderer/utils/slashCommandExtractor.ts new file mode 100644 index 00000000..be50c18b --- /dev/null +++ b/src/renderer/utils/slashCommandExtractor.ts @@ -0,0 +1,154 @@ +/** + * Slash Command Extractor - Handle slash command extraction from AI Group responses + * + * Extracts and processes slash command invocations and their follow-up instructions. + */ + +import { extractSlashInfo, isCommandContent } from '@shared/utils/contentSanitizer'; + +import { estimateTokens, toDate } from './aiGroupHelpers'; + +import type { ParsedMessage } from '@renderer/types/data'; +import type { SlashItem } from '@renderer/types/groups'; + +/** + * Info about the preceding user message's slash invocation. + * This is passed from the UserGroup to help link slash outputs to slash names. + */ +export interface PrecedingSlashInfo { + /** Slash name (e.g., "claude-hud:setup", "isolate-context", "model") */ + name: string; + /** Message content from */ + message?: string; + /** Arguments from */ + args?: string; + /** UUID of the slash command message */ + commandMessageUuid: string; + /** Timestamp of the slash command message */ + timestamp: Date; +} + +/** + * Extract slash items from AI group responses. + * + * All slash invocations follow the same format: + * /xxx + * xxx + * optional + * + * Strategy: + * 1. Build a map of follow-up messages (isMeta:true with parentUuid) by their parentUuid + * 2. If precedingSlash is provided, create a SlashItem with its follow-up instructions + * 3. Also check for any slash invocations in responses (fallback) + * + * @param responses - All response messages in the AI group + * @param precedingSlash - Optional slash info from the preceding UserGroup + * @returns Array of SlashItem objects ready for display + */ +export function extractSlashes( + responses: ParsedMessage[], + precedingSlash?: PrecedingSlashInfo +): SlashItem[] { + const slashes: SlashItem[] = []; + + // Build a map of follow-up messages by their parentUuid + // These are isMeta:true messages that contain slash instructions/output + const followUpsByParentUuid = new Map< + string, + { + text: string; + timestamp: Date; + } + >(); + + // Also build a map of potential slash messages from responses (fallback) + const slashMessagesById = new Map< + string, + { + uuid: string; + name: string; + message?: string; + args?: string; + timestamp: Date; + } + >(); + + for (const msg of responses) { + // Look for slash messages (user messages with string content containing ) + // This is a fallback in case the slash invocation is somehow in responses + if (msg.type === 'user' && typeof msg.content === 'string' && isCommandContent(msg.content)) { + const slashInfo = extractSlashInfo(msg.content); + if (slashInfo) { + slashMessagesById.set(msg.uuid, { + uuid: msg.uuid, + name: slashInfo.name, + message: slashInfo.message, + args: slashInfo.args, + timestamp: toDate(msg.timestamp), + }); + } + } + + // Look for follow-up isMeta messages with parentUuid + if ( + msg.type === 'user' && + msg.isMeta === true && + msg.parentUuid && + !msg.sourceToolUseID && // Exclude tool-call related messages + Array.isArray(msg.content) + ) { + // Extract text from the message + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + const text = block.text; + followUpsByParentUuid.set(msg.parentUuid, { + text, + timestamp: toDate(msg.timestamp), + }); + break; // Only need the first text block + } + } + } + } + + // Strategy 1: If we have precedingSlash info, create a SlashItem with its follow-up + if (precedingSlash) { + const followUp = followUpsByParentUuid.get(precedingSlash.commandMessageUuid); + + slashes.push({ + id: `slash-${precedingSlash.commandMessageUuid}`, + name: precedingSlash.name, + message: precedingSlash.message, + args: precedingSlash.args, + commandMessageUuid: precedingSlash.commandMessageUuid, + instructions: followUp?.text, + instructionsTokenCount: followUp ? estimateTokens(followUp.text) : undefined, + // Use follow-up timestamp if available (sorts with other AI items), + // otherwise fall back to slash invocation timestamp + timestamp: followUp?.timestamp ?? precedingSlash.timestamp, + }); + } + + // Strategy 2: Fallback - match slash messages found in responses to their follow-ups + for (const [uuid, slashMsg] of slashMessagesById.entries()) { + // Skip if we already added this slash via precedingSlash + if (uuid === precedingSlash?.commandMessageUuid) { + continue; + } + + const followUp = followUpsByParentUuid.get(uuid); + + slashes.push({ + id: `slash-${uuid}`, + name: slashMsg.name, + message: slashMsg.message, + args: slashMsg.args, + commandMessageUuid: uuid, + instructions: followUp?.text, + instructionsTokenCount: followUp ? estimateTokens(followUp.text) : undefined, + timestamp: slashMsg.timestamp, + }); + } + + return slashes; +} diff --git a/src/renderer/utils/stringUtils.ts b/src/renderer/utils/stringUtils.ts new file mode 100644 index 00000000..6c84441b --- /dev/null +++ b/src/renderer/utils/stringUtils.ts @@ -0,0 +1,29 @@ +/** + * String utilities for display formatting. + */ + +/** + * Truncates a string in the middle to preserve both the beginning and end. + * Useful for branch names where the unique identifier is often at the end. + * + * @example + * truncateMiddle("feature/very-long-branch-name-with-ticket-12345", 25) + * // Returns: "feature/ver...ticket-12345" + * + * @param text - The string to truncate + * @param maxLen - Maximum length of the resulting string (default: 25) + * @returns The truncated string with "..." in the middle, or original if short enough + */ +export function truncateMiddle(text: string, maxLen: number = 25): string { + if (!text || text.length <= maxLen) return text; + + // Account for the 3-character ellipsis + const availableChars = maxLen - 3; + const startLen = Math.ceil(availableChars / 2); + const endLen = Math.floor(availableChars / 2); + + const start = text.slice(0, startLen); + const end = text.slice(-endLen); + + return `${start}...${end}`; +} diff --git a/src/renderer/utils/toolLinkingEngine.ts b/src/renderer/utils/toolLinkingEngine.ts new file mode 100644 index 00000000..dfe1ee7d --- /dev/null +++ b/src/renderer/utils/toolLinkingEngine.ts @@ -0,0 +1,117 @@ +/** + * Tool Linking Engine - Link tool calls to their results + * + * Matches tool_call steps with their corresponding tool_result steps + * and builds LinkedToolItem structures for display. + */ + +import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers'; + +import type { ParsedMessage, SemanticStep } from '../types/data'; +import type { LinkedToolItem } from '../types/groups'; + +/** + * Link tool calls to their results and build a map of LinkedToolItems. + * + * Strategy: + * 1. Iterate through steps to find all tool_call steps + * 2. For each tool call, search for matching tool_result by ID + * - Tool result step IDs are set to the tool_use_id, matching the call's ID + * 3. Build LinkedToolItem with preview text + * 4. Include orphaned calls (calls without results) + * 5. For Skill tool calls, extract skill instructions from responses + * + * @param steps - Semantic steps from the AI Group + * @param responses - Optional raw messages for extracting skill instructions + * @returns Map of tool call ID to LinkedToolItem + */ +export function linkToolCallsToResults( + steps: SemanticStep[], + responses?: ParsedMessage[] +): Map { + const linkedTools = new Map(); + + // First pass: collect all tool calls + const toolCalls = steps.filter((step) => step.type === 'tool_call'); + + // Build a map of result steps by their ID for fast lookup + const resultStepsById = new Map(); + for (const step of steps) { + if (step.type === 'tool_result') { + resultStepsById.set(step.id, step); + } + } + + // Build a map of skill instructions by source tool use ID + // Skill tools have follow-up isMeta:true messages with instructions starting with "Base directory for this skill:" + const skillInstructionsById = new Map(); + + if (responses) { + for (const msg of responses) { + // Extract skill instructions + if (msg.type === 'user' && msg.isMeta && msg.sourceToolUseID && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + const text = block.text; + if (text.startsWith('Base directory for this skill:')) { + skillInstructionsById.set(msg.sourceToolUseID, text); + } + } + } + } + } + } + + for (const callStep of toolCalls) { + const toolCallId = callStep.id; + const toolName = callStep.content.toolName ?? 'Unknown'; + const toolInput = callStep.content.toolInput ?? {}; + + // Search for matching tool result by ID + // Tool result steps have their ID set to the tool_use_id (same as call ID) + const resultStep = resultStepsById.get(toolCallId); + + // Convert timestamps to proper Date objects (handles IPC serialization) + const callStartTime = toDate(callStep.startTime); + const resultStartTime = resultStep ? toDate(resultStep.startTime) : undefined; + + // Get skill instructions for Skill tool calls + const skillInstructions = + toolName === 'Skill' ? skillInstructionsById.get(toolCallId) : undefined; + + // Calculate callTokens directly from tool name + input + // This reflects what actually enters the context window (not proportioned output_tokens) + const callTokens = estimateTokens(toolName + JSON.stringify(toolInput)); + + const linkedItem: LinkedToolItem = { + id: toolCallId, + name: toolName, + input: toolInput as Record, + callTokens, // Token count for tool call (what Claude generated) + result: resultStep + ? { + content: resultStep.content.toolResultContent ?? '', + isError: resultStep.content.isError ?? false, + toolUseResult: resultStep.content.toolUseResult, + tokenCount: resultStep.content.tokenCount, // Pre-computed token count for result + } + : undefined, + inputPreview: formatToolInput(toolInput as Record), + outputPreview: resultStep + ? formatToolResult(resultStep.content.toolResultContent ?? '') + : undefined, + startTime: callStartTime, + endTime: resultStartTime, + durationMs: resultStartTime ? resultStartTime.getTime() - callStartTime.getTime() : undefined, + isOrphaned: !resultStep, + skillInstructions, + skillInstructionsTokenCount: skillInstructions + ? estimateTokens(skillInstructions) + : undefined, + }; + + linkedTools.set(toolCallId, linkedItem); + } + + return linkedTools; +} diff --git a/src/renderer/utils/toolRendering/index.ts b/src/renderer/utils/toolRendering/index.ts new file mode 100644 index 00000000..b2936950 --- /dev/null +++ b/src/renderer/utils/toolRendering/index.ts @@ -0,0 +1,14 @@ +/** + * Tool Rendering Utilities + * + * Exports all tool rendering helper functions. + */ + +export { + hasEditContent, + hasReadContent, + hasSkillInstructions, + hasWriteContent, +} from './toolContentChecks'; +export { getToolSummary } from './toolSummaryHelpers'; +export { getToolContextTokens, getToolStatus } from './toolTokens'; diff --git a/src/renderer/utils/toolRendering/toolContentChecks.ts b/src/renderer/utils/toolRendering/toolContentChecks.ts new file mode 100644 index 00000000..090cf546 --- /dev/null +++ b/src/renderer/utils/toolRendering/toolContentChecks.ts @@ -0,0 +1,58 @@ +/** + * Tool Content Check Helpers + * + * Utilities for checking if tool items have specific types of content. + */ + +import type { LinkedToolItem } from '@renderer/types/groups'; + +/** + * Checks if a Skill tool has skill instructions. + */ +export function hasSkillInstructions(linkedTool: LinkedToolItem): boolean { + return !!linkedTool.skillInstructions; +} + +/** + * Checks if a Read tool has content to display. + */ +export function hasReadContent(linkedTool: LinkedToolItem): boolean { + if (!linkedTool.result) return false; + + const toolUseResult = linkedTool.result.toolUseResult as Record | undefined; + const fileData = toolUseResult?.file as { content?: string } | undefined; + if (fileData?.content) return true; + + if (linkedTool.result.content != null) { + if (typeof linkedTool.result.content === 'string' && linkedTool.result.content.length > 0) + return true; + if (Array.isArray(linkedTool.result.content) && linkedTool.result.content.length > 0) + return true; + } + + return false; +} + +/** + * Checks if an Edit tool has content to display. + */ +export function hasEditContent(linkedTool: LinkedToolItem): boolean { + if (linkedTool.input.old_string != null) return true; + + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; + if (toolUseResult?.oldString != null || toolUseResult?.newString != null) return true; + + return false; +} + +/** + * Checks if a Write tool has content to display. + */ +export function hasWriteContent(linkedTool: LinkedToolItem): boolean { + if (linkedTool.input.content != null || linkedTool.input.file_path != null) return true; + + const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; + if (toolUseResult?.content != null || toolUseResult?.filePath != null) return true; + + return false; +} diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts new file mode 100644 index 00000000..a130e44e --- /dev/null +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -0,0 +1,271 @@ +/** + * Tool Summary Helpers + * + * Utilities for generating human-readable summaries for tool calls. + */ + +import { getBaseName } from '@renderer/utils/pathUtils'; + +/** + * Truncates a string to a maximum length with ellipsis. + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + '...'; +} + +/** + * Generates a human-readable summary for a tool call. + */ +export function getToolSummary(toolName: string, input: Record): string { + switch (toolName) { + case 'Edit': { + const filePath = input.file_path as string | undefined; + const oldString = input.old_string as string | undefined; + const newString = input.new_string as string | undefined; + + if (!filePath) return 'Edit'; + + const fileName = getBaseName(filePath); + + // Count line changes if we have old/new strings + if (oldString && newString) { + const oldLines = oldString.split('\n').length; + const newLines = newString.split('\n').length; + if (oldLines === newLines) { + return `${fileName} - ${oldLines} line${oldLines > 1 ? 's' : ''}`; + } + return `${fileName} - ${oldLines} -> ${newLines} lines`; + } + + return fileName; + } + + case 'Read': { + const filePath = input.file_path as string | undefined; + const limit = input.limit as number | undefined; + const offset = input.offset as number | undefined; + + if (!filePath) return 'Read'; + + const fileName = getBaseName(filePath); + + if (limit) { + const start = offset ?? 1; + return `${fileName} - lines ${start}-${start + limit - 1}`; + } + + return fileName; + } + + case 'Write': { + const filePath = input.file_path as string | undefined; + const content = input.content as string | undefined; + + if (!filePath) return 'Write'; + + const fileName = getBaseName(filePath); + + if (content) { + const lineCount = content.split('\n').length; + return `${fileName} - ${lineCount} lines`; + } + + return fileName; + } + + case 'Bash': { + const command = input.command as string | undefined; + const description = input.description as string | undefined; + + // Prefer description if available + if (description) { + return truncate(description, 50); + } + + if (command) { + return truncate(command, 50); + } + + return 'Bash'; + } + + case 'Grep': { + const pattern = input.pattern as string | undefined; + const path = input.path as string | undefined; + const glob = input.glob as string | undefined; + + if (!pattern) return 'Grep'; + + const patternStr = `"${truncate(pattern, 30)}"`; + + if (glob) { + return `${patternStr} in ${glob}`; + } + if (path) { + return `${patternStr} in ${getBaseName(path)}`; + } + + return patternStr; + } + + case 'Glob': { + const pattern = input.pattern as string | undefined; + const path = input.path as string | undefined; + + if (!pattern) return 'Glob'; + + const patternStr = `"${truncate(pattern, 30)}"`; + + if (path) { + return `${patternStr} in ${getBaseName(path)}`; + } + + return patternStr; + } + + case 'Task': { + const prompt = input.prompt as string | undefined; + const subagentType = input.subagentType as string | undefined; + const description = input.description as string | undefined; + + const desc = description ?? prompt; + const typeStr = subagentType ? `${subagentType} - ` : ''; + + if (desc) { + return `${typeStr}${truncate(desc, 40)}`; + } + + return subagentType ?? 'Task'; + } + + case 'LSP': { + const operation = input.operation as string | undefined; + const filePath = input.filePath as string | undefined; + + if (!operation) return 'LSP'; + + if (filePath) { + return `${operation} - ${getBaseName(filePath)}`; + } + + return operation; + } + + case 'WebFetch': { + const url = input.url as string | undefined; + + if (url) { + try { + const urlObj = new URL(url); + return truncate(urlObj.hostname + urlObj.pathname, 50); + } catch { + return truncate(url, 50); + } + } + + return 'WebFetch'; + } + + case 'WebSearch': { + const query = input.query as string | undefined; + + if (query) { + return `"${truncate(query, 40)}"`; + } + + return 'WebSearch'; + } + + case 'TodoWrite': { + const todos = input.todos as unknown[] | undefined; + + if (todos && Array.isArray(todos)) { + return `${todos.length} item${todos.length !== 1 ? 's' : ''}`; + } + + return 'TodoWrite'; + } + + case 'NotebookEdit': { + const notebookPath = input.notebook_path as string | undefined; + const editMode = input.edit_mode as string | undefined; + + if (notebookPath) { + const fileName = getBaseName(notebookPath); + return editMode ? `${editMode} - ${fileName}` : fileName; + } + + return 'NotebookEdit'; + } + + // ========================================================================= + // Team Tools + // ========================================================================= + + case 'TeamCreate': { + const teamName = input.team_name as string | undefined; + const desc = input.description as string | undefined; + if (teamName) return `${teamName}${desc ? ' - ' + truncate(desc, 30) : ''}`; + return 'Create team'; + } + + case 'TaskCreate': { + const subject = input.subject as string | undefined; + return subject ? truncate(subject, 50) : 'Create task'; + } + + case 'TaskUpdate': { + const taskId = input.taskId as string | undefined; + const status = input.status as string | undefined; + const owner = input.owner as string | undefined; + const parts: string[] = []; + if (taskId) parts.push(`#${taskId}`); + if (status) parts.push(status); + if (owner) parts.push(`-> ${owner}`); + return parts.length > 0 ? parts.join(' ') : 'Update task'; + } + + case 'TaskList': + return 'List tasks'; + + case 'TaskGet': { + const taskId = input.taskId as string | undefined; + return taskId ? `Get task #${taskId}` : 'Get task'; + } + + case 'SendMessage': { + const msgType = input.type as string | undefined; + const recipient = input.recipient as string | undefined; + const summary = input.summary as string | undefined; + if (msgType === 'shutdown_request' && recipient) return `Shutdown ${recipient}`; + if (msgType === 'shutdown_response') return 'Shutdown response'; + if (msgType === 'broadcast') return `Broadcast: ${truncate(summary ?? '', 30)}`; + if (recipient) return `To ${recipient}: ${truncate(summary ?? '', 30)}`; + return 'Send message'; + } + + case 'TeamDelete': + return 'Delete team'; + + default: { + // For unknown tools, try to extract a meaningful summary + const keys = Object.keys(input); + if (keys.length === 0) return toolName; + + // Try common parameter names + const nameField = input.name ?? input.path ?? input.file ?? input.query ?? input.command; + if (typeof nameField === 'string') { + return truncate(nameField, 50); + } + + // Fallback to showing first parameter + const firstValue = input[keys[0]]; + if (typeof firstValue === 'string') { + return truncate(firstValue, 40); + } + + return toolName; + } + } +} diff --git a/src/renderer/utils/toolRendering/toolTokens.ts b/src/renderer/utils/toolRendering/toolTokens.ts new file mode 100644 index 00000000..97b47d8c --- /dev/null +++ b/src/renderer/utils/toolRendering/toolTokens.ts @@ -0,0 +1,57 @@ +/** + * Tool Token Utilities + * + * Functions for estimating and calculating token counts for tool operations. + */ + +import { estimateTokens } from '@shared/utils/tokenFormatting'; + +import type { ItemStatus } from '@renderer/components/chat/items/BaseItem'; +import type { LinkedToolItem } from '@renderer/types/groups'; + +/** + * Calculates total context tokens consumed by a tool operation. + */ +export function getToolContextTokens(linkedTool: LinkedToolItem): number { + let totalTokens = 0; + + // Tool CALL tokens (what Claude generated) + if (linkedTool.callTokens !== undefined) { + totalTokens += linkedTool.callTokens; + } else { + // Fallback: estimate from input + totalTokens += estimateTokens(JSON.stringify(linkedTool.input)); + } + + // Tool RESULT tokens (what Claude reads back) + if (linkedTool.result?.tokenCount !== undefined) { + totalTokens += linkedTool.result.tokenCount; + } else if (linkedTool.result?.content) { + const content = linkedTool.result.content; + if (typeof content === 'string') { + totalTokens += estimateTokens(content); + } else if (Array.isArray(content)) { + totalTokens += estimateTokens(JSON.stringify(content)); + } + } + + // For Skill tools, also add skill instructions tokens + if (linkedTool.name === 'Skill') { + if (linkedTool.skillInstructionsTokenCount !== undefined) { + totalTokens += linkedTool.skillInstructionsTokenCount; + } else if (linkedTool.skillInstructions) { + totalTokens += estimateTokens(linkedTool.skillInstructions); + } + } + + return totalTokens; +} + +/** + * Gets the status of a tool execution. + */ +export function getToolStatus(linkedTool: LinkedToolItem): ItemStatus { + if (linkedTool.isOrphaned) return 'orphaned'; + if (linkedTool.result?.isError) return 'error'; + return 'ok'; +} diff --git a/src/renderer/vite-env.d.ts b/src/renderer/vite-env.d.ts new file mode 100644 index 00000000..827db198 --- /dev/null +++ b/src/renderer/vite-env.d.ts @@ -0,0 +1,19 @@ +/// + +declare module '*.png' { + const src: string; + // eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports + export default src; +} + +declare module '*.jpg' { + const src: string; + // eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports + export default src; +} + +declare module '*.svg' { + const src: string; + // eslint-disable-next-line import/no-default-export -- Vite asset modules require default exports + export default src; +} diff --git a/src/shared/CLAUDE.md b/src/shared/CLAUDE.md new file mode 100644 index 00000000..fdfcc0f6 --- /dev/null +++ b/src/shared/CLAUDE.md @@ -0,0 +1,35 @@ +# Shared + +Cross-process code used by main and renderer. + +## What Goes Here +- Types shared between processes +- Pure utility functions (no Node/DOM APIs) +- Constants used across processes + +## What Doesn't Go Here +- Node.js APIs → main/ +- DOM/React APIs → renderer/ +- Process-specific logic + +## Structure +- `types/` - Shared type definitions (`api.ts`, `notifications.ts`, `visualization.ts`) +- `utils/` - Pure utility functions + - `tokenFormatting.ts` - Token formatting and estimation (`estimateTokens`, `formatTokensCompact`) + - `modelParser.ts` - Model name/family parsing + - `teammateMessageParser.ts` - `` XML parsing + - `markdownTextSearch.ts` - Markdown-aware text search + - `contentSanitizer.ts` - Content sanitization + - `errorHandling.ts` - Error helpers + - `logger.ts` - Logging utility +- `constants/` - Shared constants + - `cache.ts` - Cache configuration + - `trafficLights.ts` - macOS traffic light constants + - `triggerColors.ts` - Trigger color palette + - `window.ts` - Window configuration + +## Import +```typescript +import { SomeType } from '@shared/types'; +import { estimateTokens } from '@shared/utils/tokenFormatting'; +``` diff --git a/src/shared/constants/cache.ts b/src/shared/constants/cache.ts new file mode 100644 index 00000000..a61dc69a --- /dev/null +++ b/src/shared/constants/cache.ts @@ -0,0 +1,12 @@ +/** + * Cache-related constants. + */ + +/** Maximum number of sessions to cache */ +export const MAX_CACHE_SESSIONS = 50; + +/** Cache TTL in minutes */ +export const CACHE_TTL_MINUTES = 10; + +/** Cleanup interval in minutes */ +export const CACHE_CLEANUP_INTERVAL_MINUTES = 5; diff --git a/src/shared/constants/index.ts b/src/shared/constants/index.ts new file mode 100644 index 00000000..85d41c2f --- /dev/null +++ b/src/shared/constants/index.ts @@ -0,0 +1,8 @@ +/** + * Shared constants barrel export. + */ + +export * from './cache'; +export * from './trafficLights'; +export * from './triggerColors'; +export * from './window'; diff --git a/src/shared/constants/trafficLights.ts b/src/shared/constants/trafficLights.ts new file mode 100644 index 00000000..6c523c76 --- /dev/null +++ b/src/shared/constants/trafficLights.ts @@ -0,0 +1,60 @@ +/** + * Shared macOS traffic-light geometry. + * + * Keep this as the single source of truth for both: + * - main process native button positioning + * - renderer process left padding reservation + */ + +/** IPC event channel emitted by main when zoom changes */ +export const WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL = 'window:zoom-factor-changed'; + +/** Base traffic-light origin at 100% zoom (native coordinates) */ +const MACOS_TRAFFIC_LIGHT_BASE_POSITION = { x: 12, y: 12 } as const; + +/** Header row height used by SidebarHeader and TabBar */ +export const HEADER_ROW1_HEIGHT = 40; + +/** Native button-group frame height (used to vertically center in header row) */ +const MACOS_TRAFFIC_LIGHT_GROUP_HEIGHT = 16; + +/** Approximate total width of the 3 traffic lights group in native px */ +const MACOS_TRAFFIC_LIGHT_GROUP_WIDTH = 52; + +/** Visual gap between traffic lights and first left-aligned content */ +const MACOS_TRAFFIC_LIGHT_CONTENT_GAP = 8; + +const MIN_ZOOM_FACTOR = 0.25; + +function sanitizeZoomFactor(zoomFactor: number): number { + if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) { + return 1; + } + return Math.max(zoomFactor, MIN_ZOOM_FACTOR); +} + +/** + * Native traffic-light position for the given zoom. + * Uses linear scaling to keep vertical alignment with zoomed title rows. + */ +export function getTrafficLightPositionForZoom( + zoomFactor: number +): Readonly<{ x: number; y: number }> { + const zoom = sanitizeZoomFactor(zoomFactor); + return { + x: Math.round(MACOS_TRAFFIC_LIGHT_BASE_POSITION.x * zoom), + y: Math.round((HEADER_ROW1_HEIGHT * zoom - MACOS_TRAFFIC_LIGHT_GROUP_HEIGHT) / 2), + }; +} + +/** + * CSS left padding (in CSS px) needed to avoid overlap with native buttons. + * Produces a stable physical gap between traffic lights and content at any zoom. + */ +export function getTrafficLightPaddingForZoom(zoomFactor: number): number { + const zoom = sanitizeZoomFactor(zoomFactor); + return Math.ceil( + MACOS_TRAFFIC_LIGHT_BASE_POSITION.x + + (MACOS_TRAFFIC_LIGHT_GROUP_WIDTH + MACOS_TRAFFIC_LIGHT_CONTENT_GAP) / zoom + ); +} diff --git a/src/shared/constants/triggerColors.ts b/src/shared/constants/triggerColors.ts new file mode 100644 index 00000000..c8706b30 --- /dev/null +++ b/src/shared/constants/triggerColors.ts @@ -0,0 +1,126 @@ +/** + * Preset color palette for notification triggers. + * Shared between main and renderer processes. + * + * Supports both preset color keys and custom hex strings (e.g., '#ff6600'). + */ + +export type TriggerColorKey = + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'cyan'; + +/** Color value: either a preset key or a custom hex string like '#ff6600'. */ +export type TriggerColor = TriggerColorKey | `#${string}`; + +export interface TriggerColorDef { + key: string; + label: string; + hex: string; +} + +export const TRIGGER_COLORS: TriggerColorDef[] = [ + { key: 'red', label: 'Red', hex: '#ef4444' }, + { key: 'orange', label: 'Orange', hex: '#f97316' }, + { key: 'yellow', label: 'Yellow', hex: '#eab308' }, + { key: 'green', label: 'Green', hex: '#22c55e' }, + { key: 'blue', label: 'Blue', hex: '#3b82f6' }, + { key: 'purple', label: 'Purple', hex: '#a855f7' }, + { key: 'pink', label: 'Pink', hex: '#ec4899' }, + { key: 'cyan', label: 'Cyan', hex: '#06b6d4' }, +]; + +const DEFAULT_TRIGGER_COLOR: TriggerColorKey = 'red'; + +const TRIGGER_COLOR_MAP = new Map(TRIGGER_COLORS.map((c) => [c.key, c])); + +const HEX_COLOR_RE = /^#[0-9a-fA-F]{3,8}$/; + +/** Check if value is a preset color key. */ +export function isPresetColorKey(value: string | undefined): value is TriggerColorKey { + return TRIGGER_COLOR_MAP.has(value ?? ''); +} + +/** + * Resolve a color value (preset key or hex string) to a TriggerColorDef. + * Custom hex strings return a synthetic def with key and label set to the hex value. + */ +export function getTriggerColorDef(color: TriggerColor | undefined): TriggerColorDef { + if (!color) return TRIGGER_COLOR_MAP.get(DEFAULT_TRIGGER_COLOR) ?? TRIGGER_COLORS[0]; + const preset = TRIGGER_COLOR_MAP.get(color); + if (preset) return preset; + // Treat as custom hex + if (HEX_COLOR_RE.test(color)) return { key: color, label: color, hex: color }; + return TRIGGER_COLOR_MAP.get(DEFAULT_TRIGGER_COLOR) ?? TRIGGER_COLORS[0]; +} + +/** Resolve any TriggerColor to its hex value. */ +export function resolveColorHex(color: TriggerColor | undefined): string { + return getTriggerColorDef(color).hex; +} + +/** + * Tailwind highlight classes for chat group rings (error navigation). + */ +export const HIGHLIGHT_CLASSES: Record = { + red: 'ring-2 ring-red-500/30 bg-red-500/5', + orange: 'ring-2 ring-orange-500/30 bg-orange-500/5', + yellow: 'ring-2 ring-yellow-500/30 bg-yellow-500/5', + green: 'ring-2 ring-green-500/30 bg-green-500/5', + blue: 'ring-2 ring-blue-500/30 bg-blue-500/5', + purple: 'ring-2 ring-purple-500/30 bg-purple-500/5', + pink: 'ring-2 ring-pink-500/30 bg-pink-500/5', + cyan: 'ring-2 ring-cyan-500/30 bg-cyan-500/5', +}; + +/** + * Get highlight classes for a color, supporting custom hex. + * Returns { className, style } — use className for presets, style for custom hex. + */ +export function getHighlightProps(color: TriggerColor | undefined): { + className: string; + style?: React.CSSProperties; +} { + const key = color ?? DEFAULT_TRIGGER_COLOR; + if (isPresetColorKey(key)) return { className: HIGHLIGHT_CLASSES[key] }; + const hex = resolveColorHex(key); + return { + className: 'ring-2', + style: { boxShadow: `0 0 0 2px ${hex}4D`, backgroundColor: `${hex}0D` }, + }; +} + +/** + * Tailwind highlight classes for tool item rings (pulsing highlight). + */ +export const TOOL_HIGHLIGHT_CLASSES: Record = { + red: 'ring-2 ring-red-500 bg-red-500/10 animate-pulse', + orange: 'ring-2 ring-orange-500 bg-orange-500/10 animate-pulse', + yellow: 'ring-2 ring-yellow-500 bg-yellow-500/10 animate-pulse', + green: 'ring-2 ring-green-500 bg-green-500/10 animate-pulse', + blue: 'ring-2 ring-blue-500 bg-blue-500/10 animate-pulse', + purple: 'ring-2 ring-purple-500 bg-purple-500/10 animate-pulse', + pink: 'ring-2 ring-pink-500 bg-pink-500/10 animate-pulse', + cyan: 'ring-2 ring-cyan-500 bg-cyan-500/10 animate-pulse', +}; + +/** + * Get tool highlight classes for a color, supporting custom hex. + */ +export function getToolHighlightProps(color: TriggerColor | undefined): { + className: string; + style?: React.CSSProperties; +} { + const key = color ?? DEFAULT_TRIGGER_COLOR; + if (isPresetColorKey(key)) return { className: TOOL_HIGHLIGHT_CLASSES[key] }; + const hex = resolveColorHex(key); + return { + className: 'ring-2 animate-pulse', + style: { boxShadow: `0 0 0 2px ${hex}`, backgroundColor: `${hex}1A` }, + }; +} diff --git a/src/shared/constants/window.ts b/src/shared/constants/window.ts new file mode 100644 index 00000000..7de23840 --- /dev/null +++ b/src/shared/constants/window.ts @@ -0,0 +1,12 @@ +/** + * Window-related constants. + */ + +/** Default main window width in pixels */ +export const DEFAULT_WINDOW_WIDTH = 1400; + +/** Default main window height in pixels */ +export const DEFAULT_WINDOW_HEIGHT = 900; + +/** Development server port */ +export const DEV_SERVER_PORT = 5173; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts new file mode 100644 index 00000000..c60727ed --- /dev/null +++ b/src/shared/types/api.ts @@ -0,0 +1,210 @@ +/** + * IPC API type definitions for Electron preload bridge. + * + * These types define the interface exposed to the renderer process + * via contextBridge. The actual implementation lives in src/preload/index.ts. + * + * Shared between preload and renderer processes. + */ + +import type { + AppConfig, + DetectedError, + NotificationTrigger, + TriggerTestResult, +} from './notifications'; +import type { WaterfallData } from './visualization'; +import type { + ConversationGroup, + FileChangeEvent, + PaginatedSessionsResult, + Project, + RepositoryGroup, + SearchSessionsResult, + Session, + SessionDetail, + SessionMetrics, + SessionsPaginationOptions, + SubagentDetail, +} from '@main/types'; + +// ============================================================================= +// Notifications API +// ============================================================================= + +/** + * Result of notifications:get with pagination. + */ +interface NotificationsResult { + notifications: DetectedError[]; + total: number; + totalCount: number; + unreadCount: number; + hasMore: boolean; +} + +/** + * Notifications API exposed via preload. + * Note: Event callbacks use `unknown` types because IPC data cannot be typed at the preload layer. + * Consumers should cast to DetectedError or NotificationClickData as appropriate. + */ +export interface NotificationsAPI { + get: (options?: { limit?: number; offset?: number }) => Promise; + markRead: (id: string) => Promise; + markAllRead: () => Promise; + delete: (id: string) => Promise; + clear: () => Promise; + getUnreadCount: () => Promise; + onNew: (callback: (event: unknown, error: unknown) => void) => () => void; + onUpdated: ( + callback: (event: unknown, payload: { total: number; unreadCount: number }) => void + ) => () => void; + onClicked: (callback: (event: unknown, data: unknown) => void) => () => void; +} + +// ============================================================================= +// Config API +// ============================================================================= + +/** + * Config API exposed via preload. + */ +export interface ConfigAPI { + get: () => Promise; + update: (section: string, data: object) => Promise; + addIgnoreRegex: (pattern: string) => Promise; + removeIgnoreRegex: (pattern: string) => Promise; + addIgnoreRepository: (repositoryId: string) => Promise; + removeIgnoreRepository: (repositoryId: string) => Promise; + snooze: (minutes: number) => Promise; + clearSnooze: () => Promise; + // Trigger management methods + addTrigger: (trigger: Omit) => Promise; + updateTrigger: (triggerId: string, updates: Partial) => Promise; + removeTrigger: (triggerId: string) => Promise; + getTriggers: () => Promise; + testTrigger: (trigger: NotificationTrigger) => Promise; + /** Opens native folder selection dialog and returns selected paths */ + selectFolders: () => Promise; + /** Opens the config JSON file in an external editor */ + openInEditor: () => Promise; + /** Pin a session for a project */ + pinSession: (projectId: string, sessionId: string) => Promise; + /** Unpin a session for a project */ + unpinSession: (projectId: string, sessionId: string) => Promise; +} + +// ============================================================================= +// Session API +// ============================================================================= + +/** + * Session navigation API exposed via preload. + */ +export interface SessionAPI { + scrollToLine: (sessionId: string, lineNumber: number) => Promise; +} + +// ============================================================================= +// CLAUDE.md File Info +// ============================================================================= + +/** + * CLAUDE.md file information returned from reading operations. + */ +export interface ClaudeMdFileInfo { + path: string; + exists: boolean; + charCount: number; + estimatedTokens: number; +} + +// ============================================================================= +// Main Electron API +// ============================================================================= + +/** + * Complete Electron API exposed to the renderer process via preload script. + */ +export interface ElectronAPI { + getAppVersion: () => Promise; + getProjects: () => Promise; + getSessions: (projectId: string) => Promise; + getSessionsPaginated: ( + projectId: string, + cursor: string | null, + limit?: number, + options?: SessionsPaginationOptions + ) => Promise; + searchSessions: ( + projectId: string, + query: string, + maxResults?: number + ) => Promise; + getSessionDetail: (projectId: string, sessionId: string) => Promise; + getSessionMetrics: (projectId: string, sessionId: string) => Promise; + getWaterfallData: (projectId: string, sessionId: string) => Promise; + getSubagentDetail: ( + projectId: string, + sessionId: string, + subagentId: string + ) => Promise; + getSessionGroups: (projectId: string, sessionId: string) => Promise; + + // Repository grouping (worktree support) + getRepositoryGroups: () => Promise; + getWorktreeSessions: (worktreeId: string) => Promise; + + // Validation methods + validatePath: ( + relativePath: string, + projectPath: string + ) => Promise<{ exists: boolean; isDirectory?: boolean }>; + validateMentions: ( + mentions: { type: 'path'; value: string }[], + projectPath: string + ) => Promise>; + + // CLAUDE.md reading methods + readClaudeMdFiles: (projectRoot: string) => Promise>; + readDirectoryClaudeMd: (dirPath: string) => Promise; + readMentionedFile: ( + absolutePath: string, + projectRoot: string, + maxTokens?: number + ) => Promise; + + // Notifications API + notifications: NotificationsAPI; + + // Config API + config: ConfigAPI; + + // Deep link navigation + session: SessionAPI; + + // Window zoom sync (for traffic-light-safe layout) + getZoomFactor: () => Promise; + onZoomFactorChanged: (callback: (zoomFactor: number) => void) => () => void; + + // File change events (real-time updates) + onFileChange: (callback: (event: FileChangeEvent) => void) => () => void; + onTodoChange: (callback: (event: FileChangeEvent) => void) => () => void; + + // Shell operations + openPath: ( + targetPath: string, + projectRoot?: string + ) => Promise<{ success: boolean; error?: string }>; + openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; +} + +// ============================================================================= +// Window Type Extension +// ============================================================================= + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 00000000..41e53ef1 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,22 @@ +/** + * Shared type definitions - re-exports types from main process for use in renderer. + * + * This module provides a stable import path (@shared/types) for types that + * are shared between main and renderer processes, allowing proper boundary + * separation while maintaining type safety. + * + * Usage: + * import type { Session, Chunk, ParsedMessage } from '@shared/types'; + */ + +// Re-export all types from main process types +export * from '@main/types'; + +// Re-export notification and config types +export * from './notifications'; + +// Re-export visualization types (WaterfallData, WaterfallItem) +export type * from './visualization'; + +// Re-export API types (ElectronAPI, ConfigAPI, etc.) +export type * from './api'; diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts new file mode 100644 index 00000000..7f1fb5b4 --- /dev/null +++ b/src/shared/types/notifications.ts @@ -0,0 +1,278 @@ +/** + * Notification and configuration types for Claude Code Context. + * + * These types define: + * - Detected errors from session files + * - Notification triggers (rules for when to notify) + * - Application configuration settings + * + * Shared between preload and renderer processes. + */ + +import type { TriggerColor } from '@shared/constants/triggerColors'; + +// ============================================================================= +// Detected Error Types +// ============================================================================= + +/** + * Detected error from session JSONL files. + * Used for notification display and deep linking to error locations. + */ +export interface DetectedError { + /** UUID for unique identification */ + id: string; + /** Unix timestamp when error occurred */ + timestamp: number; + /** Session ID where error occurred */ + sessionId: string; + /** Project ID (encoded project path) */ + projectId: string; + /** Path to the JSONL file */ + filePath: string; + /** Tool name or 'assistant' */ + source: string; + /** Error message text */ + message: string; + /** Line number in JSONL for deep linking */ + lineNumber?: number; + /** Tool use ID for precise deep linking to the specific tool item */ + toolUseId?: string; + /** Subagent ID when error originates from a subagent session */ + subagentId?: string; + /** Whether the notification has been read */ + isRead: boolean; + /** When the notification was created */ + createdAt: number; + /** Trigger color key for notification dot and highlight */ + triggerColor?: TriggerColor; + /** ID of the trigger that produced this notification */ + triggerId?: string; + /** Human-readable name of the trigger that produced this notification */ + triggerName?: string; + /** Additional context */ + context: { + /** Display name of the project */ + projectName: string; + /** Current working directory when error occurred */ + cwd?: string; + }; +} + +// ============================================================================= +// Notification Trigger Types +// ============================================================================= + +/** + * Content types that can trigger notifications. + */ +export type TriggerContentType = 'tool_result' | 'tool_use' | 'thinking' | 'text'; + +/** + * Known tool names that can be filtered for tool_use triggers. + */ +export const KNOWN_TOOL_NAMES = [ + 'Bash', + 'Task', + 'TodoWrite', + 'Read', + 'Write', + 'Edit', + 'Grep', + 'Glob', + 'WebFetch', + 'WebSearch', + 'LSP', + 'Skill', + 'NotebookEdit', + 'AskUserQuestion', + 'KillShell', + 'TaskOutput', +] as const; + +/** + * Tool names that can be filtered for tool_use triggers. + * Accepts known tool names or any custom tool name. + */ +export type TriggerToolName = (typeof KNOWN_TOOL_NAMES)[number] | (string & Record); + +/** + * Match fields available for different content types and tools. + */ +export type MatchFieldForToolResult = 'content'; +export type MatchFieldForBash = 'command' | 'description'; +export type MatchFieldForTask = 'description' | 'prompt' | 'subagent_type'; +export type MatchFieldForRead = 'file_path'; +export type MatchFieldForWrite = 'file_path' | 'content'; +export type MatchFieldForEdit = 'file_path' | 'old_string' | 'new_string'; +export type MatchFieldForGlob = 'pattern' | 'path'; +export type MatchFieldForGrep = 'pattern' | 'path' | 'glob'; +export type MatchFieldForWebFetch = 'url' | 'prompt'; +export type MatchFieldForWebSearch = 'query'; +export type MatchFieldForSkill = 'skill' | 'args'; +export type MatchFieldForThinking = 'thinking'; +export type MatchFieldForText = 'text'; + +/** + * Combined type for all possible match fields. + */ +export type TriggerMatchField = + | MatchFieldForToolResult + | MatchFieldForBash + | MatchFieldForTask + | MatchFieldForRead + | MatchFieldForWrite + | MatchFieldForEdit + | MatchFieldForGlob + | MatchFieldForGrep + | MatchFieldForWebFetch + | MatchFieldForWebSearch + | MatchFieldForSkill + | MatchFieldForThinking + | MatchFieldForText; + +/** + * Trigger mode determines how the trigger evaluates conditions. + * - 'error_status': Triggers when is_error is true (simple boolean check) + * - 'content_match': Triggers when content matches a regex pattern + * - 'token_threshold': Triggers when token count exceeds threshold + */ +export type TriggerMode = 'error_status' | 'content_match' | 'token_threshold'; + +/** + * Token type for threshold triggers. + */ +export type TriggerTokenType = 'input' | 'output' | 'total'; + +/** + * Notification trigger configuration. + * Defines when notifications should be generated. + */ +export interface NotificationTrigger { + /** Unique identifier for this trigger */ + id: string; + /** Human-readable name for this trigger */ + name: string; + /** Whether this trigger is enabled */ + enabled: boolean; + /** Content type to match */ + contentType: TriggerContentType; + /** For tool_use/tool_result: specific tool name to match */ + toolName?: TriggerToolName; + /** Whether this is a built-in trigger (cannot be deleted) */ + isBuiltin?: boolean; + /** Regex patterns to IGNORE (skip notification if content matches any of these) */ + ignorePatterns?: string[]; + + // === Discriminated Union Mode === + /** Trigger evaluation mode */ + mode: TriggerMode; + + // === Mode: error_status === + /** For error_status mode: always triggers on is_error=true */ + requireError?: boolean; + + // === Mode: content_match === + /** For content_match mode: field to match against */ + matchField?: TriggerMatchField; + /** For content_match mode: regex pattern to match */ + matchPattern?: string; + + // === Mode: token_threshold === + /** For token_threshold mode: minimum token count to trigger */ + tokenThreshold?: number; + /** For token_threshold mode: which token type to check */ + tokenType?: TriggerTokenType; + + // === Repository Scope === + /** If set, this trigger only applies to these repository group IDs */ + repositoryIds?: string[]; + + // === Display === + /** Color for notification dot and navigation highlight (preset key or hex string) */ + color?: TriggerColor; +} + +/** + * Result of testing a trigger against historical data. + */ +export interface TriggerTestResult { + totalCount: number; + errors: { + id: string; + sessionId: string; + projectId: string; + message: string; + timestamp: number; + source: string; + /** Tool use ID for precise deep linking to the specific tool item */ + toolUseId?: string; + /** Subagent ID when error originates from or targets a subagent */ + subagentId?: string; + /** Line number in JSONL for deep linking */ + lineNumber?: number; + context: { projectName: string }; + }[]; + /** + * True if results were truncated due to safety limits: + * - totalCount capped at 10,000 + * - Max 100 sessions scanned + * - 30 second timeout + */ + truncated?: boolean; +} + +// ============================================================================= +// Application Configuration Types +// ============================================================================= + +/** + * Application configuration settings. + * Persisted to disk and loaded on app startup. + */ +export interface AppConfig { + /** Notification-related settings */ + notifications: { + /** Whether notifications are enabled globally */ + enabled: boolean; + /** Whether to play sound with notifications */ + soundEnabled: boolean; + /** Regex patterns for errors to ignore */ + ignoredRegex: string[]; + /** Repository group IDs to ignore for notifications */ + ignoredRepositories: string[]; + /** Unix timestamp until which notifications are snoozed (null if not snoozed) */ + snoozedUntil: number | null; + /** Default snooze duration in minutes */ + snoozeMinutes: number; + /** Whether to include errors from subagent sessions */ + includeSubagentErrors: boolean; + /** Notification triggers - define when to generate notifications */ + triggers: NotificationTrigger[]; + }; + /** General application settings */ + general: { + /** Whether to launch app at system login */ + launchAtLogin: boolean; + /** Whether to show icon in dock (macOS) */ + showDockIcon: boolean; + /** Application theme */ + theme: 'dark' | 'light' | 'system'; + /** Default tab to show on app launch */ + defaultTab: 'dashboard' | 'last-session'; + }; + /** Display and UI settings */ + display: { + /** Whether to show timestamps in message views */ + showTimestamps: boolean; + /** Whether to use compact display mode */ + compactMode: boolean; + /** Whether to enable syntax highlighting in code blocks */ + syntaxHighlighting: boolean; + }; + /** Session-related settings */ + sessions: { + /** Pinned sessions per project. Key is projectId, value is array of pinned sessions */ + pinnedSessions: Record; + }; +} diff --git a/src/shared/types/visualization.ts b/src/shared/types/visualization.ts new file mode 100644 index 00000000..827e3a95 --- /dev/null +++ b/src/shared/types/visualization.ts @@ -0,0 +1,60 @@ +/** + * Visualization-specific types for Claude Code Context. + * + * These types are used for waterfall chart visualization + * and are shared between main and renderer processes. + */ + +import type { TokenUsage } from '@main/types'; + +// ============================================================================= +// Waterfall Chart Types +// ============================================================================= + +/** + * Waterfall item for visualization. + */ +export interface WaterfallItem { + /** Unique item identifier */ + id: string; + /** Display label */ + label: string; + /** Item start time */ + startTime: Date; + /** Item end time */ + endTime: Date; + /** Duration in milliseconds */ + durationMs: number; + /** Token usage for this item */ + tokenUsage: TokenUsage; + /** Hierarchy depth (0 = main session) */ + level: number; + /** Item type */ + type: 'chunk' | 'subagent' | 'tool'; + /** Whether executed in parallel */ + isParallel: boolean; + /** Parent item ID */ + parentId?: string; + /** Group ID for parallel items */ + groupId?: string; + /** Additional metadata for display */ + metadata?: { + subagentType?: string; + toolName?: string; + messageCount?: number; + }; +} + +/** + * Complete waterfall chart data. + */ +export interface WaterfallData { + /** All waterfall items */ + items: WaterfallItem[]; + /** Earliest timestamp in the session */ + minTime: Date; + /** Latest timestamp in the session */ + maxTime: Date; + /** Total session duration in milliseconds */ + totalDurationMs: number; +} diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts new file mode 100644 index 00000000..e85c145d --- /dev/null +++ b/src/shared/utils/contentSanitizer.ts @@ -0,0 +1,151 @@ +/** + * Content sanitization utilities for display. + * + * SHARED MODULE: Used by both main and renderer processes. + * - Main process: Used in jsonl.ts for initial parsing + * - Renderer process: Used in groupTransformer.ts for display formatting + * + * This module handles conversion of raw JSONL content (with XML tags) into + * human-readable format for the UI. + * + * NOTE: This file was previously duplicated in both main/utils and renderer/utils. + * Consolidated to src/shared/utils to maintain DRY principle while serving both processes. + */ + +/** + * Patterns for noise tags that should be completely removed. + * These are system-generated metadata that provide no value in display. + */ +const NOISE_TAG_PATTERNS = [ + /[\s\S]*?<\/local-command-caveat>/gi, + /[\s\S]*?<\/system-reminder>/gi, +]; + +/** + * Extract content from tags. + * Returns the command output without the wrapper tags. + */ +function extractCommandOutput(content: string): string | null { + const match = /([\s\S]*?)<\/local-command-stdout>/i.exec(content); + const matchStderr = /([\s\S]*?)<\/local-command-stderr>/i.exec(content); + if (match) { + return match[1].trim(); + } + if (matchStderr) { + return matchStderr[1].trim(); + } + return null; +} + +/** + * Extract command info from command XML tags. + * Returns the slash command in readable format (e.g., "/model sonnet") + */ +function extractCommandDisplay(content: string): string | null { + const commandNameMatch = /\/([^<]+)<\/command-name>/.exec(content); + const commandArgsMatch = /([^<]*)<\/command-args>/.exec(content); + + if (commandNameMatch) { + const commandName = `/${commandNameMatch[1].trim()}`; + const args = commandArgsMatch?.[1]?.trim(); + return args ? `${commandName} ${args}` : commandName; + } + + return null; +} + +/** + * Check if content is primarily a command message. + * Handles both orderings: + * - Built-in commands: comes first + * - Skill commands: comes first, followed by + */ +export function isCommandContent(content: string): boolean { + return content.startsWith('') || content.startsWith(''); +} + +/** + * Check if content is a command output message. + */ +export function isCommandOutputContent(content: string): boolean { + return ( + content.startsWith('') || content.startsWith('') + ); +} + +/** + * Sanitize content for display. + * + * - Command messages: Converted to readable format (e.g., "/model sonnet") + * - Command output: Extracted from tags + * - Noise tags: Completely removed + * - Regular content: Returned as-is + */ +export function sanitizeDisplayContent(content: string): string { + // If it's a command output message, extract the output content + if (isCommandOutputContent(content)) { + const commandOutput = extractCommandOutput(content); + if (commandOutput) { + return commandOutput; + } + } + + // If it's a command message, extract the command for display + if (isCommandContent(content)) { + const commandDisplay = extractCommandDisplay(content); + if (commandDisplay) { + return commandDisplay; + } + } + + // Remove noise tags + let sanitized = content; + for (const pattern of NOISE_TAG_PATTERNS) { + sanitized = sanitized.replace(pattern, ''); + } + + // Also remove any remaining command tags (in case of mixed content) + sanitized = sanitized + .replace(/[\s\S]*?<\/command-name>/gi, '') + .replace(/[\s\S]*?<\/command-message>/gi, '') + .replace(/[\s\S]*?<\/command-args>/gi, ''); + + return sanitized.trim(); +} + +/** + * Slash info extracted from command XML tags. + * All slash commands have the same format: + * /xxx + * xxx + * optional + */ +export interface SlashInfo { + /** Slash name without the leading slash (e.g., "model", "isolate-context") */ + name: string; + /** Message content from */ + message?: string; + /** Optional arguments from */ + args?: string; +} + +/** + * Extract slash information from command XML tags. + * Works for all slash types: skills, built-in commands, plugins, MCP, user commands. + * Returns null if not a slash command format. + */ +export function extractSlashInfo(content: string): SlashInfo | null { + const nameMatch = /\/([^<]+)<\/command-name>/.exec(content); + if (!nameMatch) return null; + + const name = nameMatch[1].trim(); + + const messageMatch = /([^<]*)<\/command-message>/.exec(content); + const argsMatch = /([^<]*)<\/command-args>/.exec(content); + + return { + name, + message: messageMatch?.[1]?.trim() ?? undefined, + args: argsMatch?.[1]?.trim() ?? undefined, + }; +} diff --git a/src/shared/utils/errorHandling.ts b/src/shared/utils/errorHandling.ts new file mode 100644 index 00000000..da7211c3 --- /dev/null +++ b/src/shared/utils/errorHandling.ts @@ -0,0 +1,26 @@ +/** + * Shared error handling utilities. + * + * Provides type-safe error message extraction and formatting + * for use across both main and renderer processes. + */ + +/** + * Extracts a human-readable error message from an unknown error value. + * Handles Error instances, strings, and other types safely. + * + * @param error - The error value (could be Error, string, or unknown) + * @returns A string error message + */ +export function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (error && typeof error === 'object' && 'message' in error) { + return String((error as { message: unknown }).message); + } + return String(error); +} diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 00000000..a361b20b --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,69 @@ +/** + * Centralized logging utility for the application. + * + * Provides namespace-prefixed logging with environment-based filtering: + * - Development: All log levels (DEBUG, INFO, WARN, ERROR) + * - Production: Only ERROR logs are shown + * + * Usage: + * ```typescript + * import { createLogger } from '@shared/utils/logger'; + * const logger = createLogger('IPC:config'); + * logger.info('Config loaded'); + * logger.error('Failed to load config', error); + * ``` + */ + +enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, + NONE = 4, +} + +class Logger { + private static level: LogLevel = + process.env.NODE_ENV === 'production' ? LogLevel.ERROR : LogLevel.WARN; + + constructor(private namespace: string) {} + + debug(...args: unknown[]): void { + if (Logger.level <= LogLevel.DEBUG) { + console.debug(`[${this.namespace}]`, ...args); + } + } + + info(...args: unknown[]): void { + if (Logger.level <= LogLevel.INFO) { + console.log(`[${this.namespace}]`, ...args); + } + } + + warn(...args: unknown[]): void { + if (Logger.level <= LogLevel.WARN) { + console.warn(`[${this.namespace}]`, ...args); + } + } + + error(...args: unknown[]): void { + if (Logger.level <= LogLevel.ERROR) { + console.error(`[${this.namespace}]`, ...args); + } + } + + /** Allow runtime level changes (for testing/debugging) */ + static setLevel(level: LogLevel): void { + Logger.level = level; + } + + static getLevel(): LogLevel { + return Logger.level; + } +} + +export function createLogger(namespace: string): Logger { + return new Logger(namespace); +} + +export type { Logger }; diff --git a/src/shared/utils/markdownTextSearch.ts b/src/shared/utils/markdownTextSearch.ts new file mode 100644 index 00000000..d33f13f3 --- /dev/null +++ b/src/shared/utils/markdownTextSearch.ts @@ -0,0 +1,204 @@ +/** + * Markdown-aware text search utility. + * + * Converts markdown through the **same pipeline** as react-markdown: + * remark-parse → remarkGfm → mdast-util-to-hast → HAST tree + * + * Then collects text nodes only from HAST elements whose corresponding + * React components call `hl(children)` (highlightSearchInChildren). + * This ensures match counts align exactly with what the renderer produces. + * + * Key design: segments are collected per-text-node, NOT concatenated. + * `highlightSearchText` operates per-React-string-child, so a match + * spanning two elements is not valid in either layer. + */ + +import { toHast } from 'mdast-util-to-hast'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import { unified } from 'unified'; + +import type { Nodes as HastNodes } from 'hast'; +import type { Root as MdastRoot } from 'mdast'; + +// --------------------------------------------------------------------------- +// Parser singleton +// --------------------------------------------------------------------------- + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -- inferred type used by MarkdownParser alias +function createParser() { + return unified().use(remarkParse).use(remarkGfm); +} + +type MarkdownParser = ReturnType; + +let _parser: MarkdownParser | null = null; + +function getParser(): MarkdownParser { + if (!_parser) { + _parser = createParser(); + } + return _parser; +} + +function parseMarkdown(text: string): MdastRoot { + return getParser().parse(text); +} + +// --------------------------------------------------------------------------- +// Segment cache (parse once, search many times per query keystroke) +// --------------------------------------------------------------------------- + +const MAX_CACHE_SIZE = 200; +const segmentCache = new Map(); + +function getCachedSegments(markdown: string): string[] { + const cached = segmentCache.get(markdown); + if (cached) return cached; + + const segments = collectTextSegments(markdown); + + // Evict oldest entries when cache is full + if (segmentCache.size >= MAX_CACHE_SIZE) { + const firstKey = segmentCache.keys().next().value; + if (firstKey !== undefined) segmentCache.delete(firstKey); + } + segmentCache.set(markdown, segments); + return segments; +} + +// --------------------------------------------------------------------------- +// HAST → text segments +// --------------------------------------------------------------------------- + +/** + * HTML element tag names whose React component counterparts call + * `hl(children)` (highlightSearchInChildren). + * + * Block-level elements call hl(): p, h1-h6, blockquote, li, th, td, code (block only) + * Inline elements do NOT call hl(): strong, em, a, del, code (inline) + * The block element's hl() recursively descends into inline children, + * processing text in document order — matching this walker's traversal. + * + * Inline tags are omitted from this set because they are always nested + * inside a block-level HL element in standard markdown, so their text + * is collected via the inherited `inHlElement` flag. + * + * Must stay in sync with createMarkdownComponents() in markdownComponents.tsx, + * createUserMarkdownComponents() in UserChatGroup.tsx, and + * createViewerMarkdownComponents() in MarkdownViewer.tsx. + */ +const HL_TAGS = new Set([ + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'p', + 'code', + 'blockquote', + 'li', + 'th', + 'td', +]); + +/** + * Parse markdown → mdast → HAST, then collect text nodes from elements + * whose React components call `hl()`. This produces the exact same + * text segments that `highlightSearchInChildren` processes at render time. + */ +export function collectTextSegments(markdown: string): string[] { + const mdast = parseMarkdown(markdown); + const hast = toHast(mdast); + if (!hast) return []; + + const segments: string[] = []; + walkHast(hast, segments, false); + return segments; +} + +function walkHast(node: HastNodes, segments: string[], inHlElement: boolean): void { + // Raw HTML nodes (e.g. ...) are dropped by ReactMarkdown + // without rehype-raw, so we must skip them to keep match counts aligned. + if (node.type === 'raw') return; + + if (node.type === 'text') { + if (inHlElement && node.value) { + segments.push(node.value); + } + return; + } + + if (node.type === 'element' || node.type === 'root') { + const isHl = node.type === 'element' && HL_TAGS.has(node.tagName); + for (const child of node.children) { + walkHast(child as HastNodes, segments, inHlElement || isHl); + } + } + // skip comments, doctypes +} + +// --------------------------------------------------------------------------- +// Search functions +// --------------------------------------------------------------------------- + +export interface MarkdownSearchMatch { + matchIndexInItem: number; +} + +/** + * Parse markdown into segments and search each segment individually. + * Returns per-item match indices that align with what the renderer produces. + */ +export function findMarkdownSearchMatches(markdown: string, query: string): MarkdownSearchMatch[] { + if (!query || !markdown) return []; + + const segments = getCachedSegments(markdown); + const lowerQuery = query.toLowerCase(); + const matches: MarkdownSearchMatch[] = []; + let matchIndex = 0; + + for (const segment of segments) { + const lowerSegment = segment.toLowerCase(); + let pos = 0; + while ((pos = lowerSegment.indexOf(lowerQuery, pos)) !== -1) { + matches.push({ matchIndexInItem: matchIndex }); + matchIndex++; + pos += lowerQuery.length; + } + } + + return matches; +} + +/** + * Count matches (cheaper than allocating match objects when only the count is needed). + */ +export function countMarkdownSearchMatches(markdown: string, query: string): number { + if (!query || !markdown) return 0; + + const segments = getCachedSegments(markdown); + const lowerQuery = query.toLowerCase(); + let count = 0; + + for (const segment of segments) { + const lowerSegment = segment.toLowerCase(); + let pos = 0; + while ((pos = lowerSegment.indexOf(lowerQuery, pos)) !== -1) { + count++; + pos += lowerQuery.length; + } + } + + return count; +} + +/** + * Join all visible text segments with spaces for use in context snippets. + */ +export function extractMarkdownPlainText(markdown: string): string { + if (!markdown) return ''; + const segments = getCachedSegments(markdown); + return segments.join(' '); +} diff --git a/src/shared/utils/modelParser.ts b/src/shared/utils/modelParser.ts new file mode 100644 index 00000000..8d9fbf37 --- /dev/null +++ b/src/shared/utils/modelParser.ts @@ -0,0 +1,155 @@ +/** + * Claude model string parser utility. + * Parses model identifiers into friendly display names and metadata. + */ + +/** Known model families with specific styling */ +export type KnownModelFamily = 'sonnet' | 'opus' | 'haiku'; + +/** Model family can be a known family or any arbitrary string for new/unknown models */ +export type ModelFamily = KnownModelFamily | (string & Record); + +export interface ModelInfo { + /** Friendly name like "sonnet4.5" */ + name: string; + /** Model family: sonnet, opus, haiku, or any other string for unknown families */ + family: ModelFamily; + /** Major version like 4 or 3 */ + majorVersion: number; + /** Minor version like 5 or 1 (null if not present) */ + minorVersion: number | null; +} + +const KNOWN_FAMILIES: KnownModelFamily[] = ['sonnet', 'opus', 'haiku']; + +/** + * Parses a Claude model string into friendly display info. + * Returns null if model string is invalid, synthetic, or empty. + * + * Supported formats: + * - New format: claude-{family}-{major}-{minor}-{date} (e.g., "claude-sonnet-4-5-20250929") + * - Old format: claude-{major}-{family}-{date} (e.g., "claude-3-opus-20240229") + * - Old format with minor: claude-{major}-{minor}-{family}-{date} (e.g., "claude-3-5-sonnet-20241022") + */ +export function parseModelString(model: string | undefined): ModelInfo | null { + // Handle null, undefined, empty, or synthetic models + if (!model || model.trim() === '' || model === '') { + return null; + } + + const normalized = model.toLowerCase().trim(); + + // Must start with "claude" + if (!normalized.startsWith('claude')) { + return null; + } + + // Split into parts (e.g., ["claude", "sonnet", "4", "5", "20250929"]) + const parts = normalized.split('-'); + + if (parts.length < 3) { + return null; + } + + // Detect model family - first check known families, then accept any non-numeric string + let family: ModelFamily | null = null; + let familyIndex = -1; + + // First pass: look for known families + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + if (KNOWN_FAMILIES.includes(part as KnownModelFamily)) { + family = part as KnownModelFamily; + familyIndex = i; + break; + } + } + + // Second pass: if no known family found, look for any non-numeric, non-date string as family + if (family === null) { + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + // Skip numeric parts and date-like parts (8 digits) + if (!/^\d+$/.test(part) && !/^\d{8}$/.test(part) && part.length > 1) { + family = part; + familyIndex = i; + break; + } + } + } + + if (family === null || familyIndex === -1) { + return null; + } + + let majorVersion: number; + let minorVersion: number | null = null; + + // Determine format based on family position + if (familyIndex === 1) { + // New format: claude-{family}-{major}-{minor}-{date} + // e.g., claude-sonnet-4-5-20250929 -> ["claude", "sonnet", "4", "5", "20250929"] + if (parts.length < 4) { + return null; + } + + majorVersion = parseInt(parts[2], 10); + if (isNaN(majorVersion)) { + return null; + } + + // Check if there's a minor version (next part is a number and not a date) + if (parts.length >= 4 && parts[3].length <= 2) { + const potentialMinor = parseInt(parts[3], 10); + if (!isNaN(potentialMinor)) { + minorVersion = potentialMinor; + } + } + } else { + // Old format: claude-{major}[-{minor}]-{family}-{date} + // e.g., claude-3-opus-20240229 -> ["claude", "3", "opus", "20240229"] + // e.g., claude-3-5-sonnet-20241022 -> ["claude", "3", "5", "sonnet", "20241022"] + + majorVersion = parseInt(parts[1], 10); + if (isNaN(majorVersion)) { + return null; + } + + // Check if there's a minor version between major and family + if (familyIndex > 2) { + const potentialMinor = parseInt(parts[2], 10); + if (!isNaN(potentialMinor)) { + minorVersion = potentialMinor; + } + } + } + + // Build friendly name + const versionString = + minorVersion !== null ? `${majorVersion}.${minorVersion}` : `${majorVersion}`; + const name = `${family}${versionString}`; + + return { + name, + family, + majorVersion, + minorVersion, + }; +} + +/** + * Gets the color class for a model family (for Tailwind). + * Uses consistent neutral gray styling for a clean, Linear-like design. + * All models use the same muted color for visual consistency. + */ +export function getModelColorClass(family: ModelFamily): string { + // All families use consistent neutral gray for clean design + switch (family) { + case 'opus': + case 'sonnet': + case 'haiku': + return 'text-zinc-400'; + default: + return 'text-zinc-500'; + } +} diff --git a/src/shared/utils/teammateMessageParser.ts b/src/shared/utils/teammateMessageParser.ts new file mode 100644 index 00000000..574b747e --- /dev/null +++ b/src/shared/utils/teammateMessageParser.ts @@ -0,0 +1,52 @@ +/** + * Teammate Message Parser + * + * Parses XML content into structured data. + * Handles single or multiple blocks in one message. + * Pure function for cross-process use (renderer needs it in displayItemBuilder). + */ + +export interface ParsedTeammateContent { + teammateId: string; + color: string; + summary: string; + content: string; +} + +/** + * Regex to match a single block (non-greedy content). + * Captures: [1] teammate_id, [2] remaining attributes string, [3] inner content + */ +const TEAMMATE_BLOCK_RE = + /]*)>([\s\S]*?)<\/teammate-message>/g; + +const COLOR_RE = /color="([^"]*)"/; +const SUMMARY_RE = /summary="([^"]*)"/; + +/** + * Parse all blocks from raw content. + * Returns an array of parsed blocks (may be 0, 1, or many). + */ +export function parseAllTeammateMessages(rawContent: string): ParsedTeammateContent[] { + const results: ParsedTeammateContent[] = []; + const regex = new RegExp(TEAMMATE_BLOCK_RE.source, TEAMMATE_BLOCK_RE.flags); + + let match: RegExpExecArray | null; + while ((match = regex.exec(rawContent)) !== null) { + const teammateId = match[1]; + const attrs = match[2]; + const content = match[3].trim(); + + const colorMatch = COLOR_RE.exec(attrs); + const summaryMatch = SUMMARY_RE.exec(attrs); + + results.push({ + teammateId, + color: colorMatch?.[1] ?? '', + summary: summaryMatch?.[1] ?? '', + content, + }); + } + + return results; +} diff --git a/src/shared/utils/tokenFormatting.ts b/src/shared/utils/tokenFormatting.ts new file mode 100644 index 00000000..aafed8fe --- /dev/null +++ b/src/shared/utils/tokenFormatting.ts @@ -0,0 +1,91 @@ +/** + * Shared token formatting utilities. + * + * This module consolidates all token-related formatting functions across the codebase. + * Use these functions instead of implementing token formatting inline. + */ + +/** + * Formats token count for compact display. + * Shows full number under 1k, uses 'k' suffix for thousands, 'M' suffix for millions. + * + * Examples: + * - 500 -> "500" + * - 1500 -> "1.5k" + * - 50000 -> "50.0k" + * - 1500000 -> "1.5M" + */ +export function formatTokensCompact(tokens: number): string { + if (tokens >= 1000000) { + return `${(tokens / 1000000).toFixed(1)}M`; + } + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return tokens.toString(); +} + +/** + * Formats token count with smart precision. + * Uses one decimal for 1k-10k range, whole numbers above 10k. + * + * Examples: + * - 500 -> "500" + * - 1500 -> "1.5k" + * - 15000 -> "15k" + */ +export function formatTokens(tokens: number): string { + if (tokens < 1000) { + return `${tokens}`; + } + if (tokens < 10000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return `${Math.round(tokens / 1000)}k`; +} + +/** + * Formats token count with locale-aware separators. + * Used for detailed views where exact numbers matter. + * + * Examples: + * - 1500 -> "1,500" (in en-US locale) + * - 1000000 -> "1,000,000" + */ +export function formatTokensDetailed(tokens: number): string { + return tokens.toLocaleString(); +} + +/** + * Estimates token count from text content. + * Uses the rough heuristic of ~4 characters per token, which is a + * reasonable average for English text and code. + * + * This is faster than using a real tokenizer and accurate enough + * for display purposes. + */ +export function estimateTokens(text: string | undefined | null): number { + if (!text || text.length === 0) { + return 0; + } + return Math.ceil(text.length / 4); +} + +/** + * Estimates tokens for content that may be a string, array, or object. + * Arrays and objects are stringified before counting. + */ +export function estimateContentTokens( + content: string | unknown[] | Record | undefined | null +): number { + if (!content) { + return 0; + } + + if (typeof content === 'string') { + return estimateTokens(content); + } + + // For array/object content, stringify and count + return estimateTokens(JSON.stringify(content)); +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 00000000..08e51f8e --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,53 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './src/renderer/index.html', + './src/renderer/**/*.{js,ts,jsx,tsx}', + './src/shared/**/*.{js,ts,jsx,tsx}' + ], + theme: { + extend: { + colors: { + // Theme-aware surface colors (use CSS variables) + surface: { + DEFAULT: 'var(--color-surface)', + raised: 'var(--color-surface-raised)', + overlay: 'var(--color-surface-overlay)', + sidebar: 'var(--color-surface-sidebar)', + code: 'var(--code-bg)', // Deep black for code blocks + }, + // Theme-aware border colors (use CSS variables) + border: { + DEFAULT: 'var(--color-border)', + subtle: 'var(--color-border-subtle)', + emphasis: 'var(--color-border-emphasis)', + }, + // Theme-aware text colors (use CSS variables) + text: { + DEFAULT: 'var(--color-text)', + secondary: 'var(--color-text-secondary)', + muted: 'var(--color-text-muted)', + }, + // Semantic colors (only for status, not containers) + semantic: { + success: '#22c55e', // green-500 + error: '#ef4444', // red-500 + warning: '#f59e0b', // amber-500 + info: '#3b82f6', // blue-500 + }, + // Theme-aware colors using CSS variables + // These aliases enable all existing components to automatically support light/dark mode + 'claude-dark': { + bg: 'var(--color-surface)', + surface: 'var(--color-surface-raised)', + border: 'var(--color-border)', + text: 'var(--color-text)', + 'text-secondary': 'var(--color-text-secondary)' + } + } + } + }, + plugins: [ + require('@tailwindcss/typography') + ] +} diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts new file mode 100644 index 00000000..75cf42f2 --- /dev/null +++ b/test/main/ipc/configValidation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; + +import { validateConfigUpdatePayload } from '../../../src/main/ipc/configValidation'; + +describe('configValidation', () => { + it('accepts valid general updates', () => { + const result = validateConfigUpdatePayload('general', { + theme: 'system', + launchAtLogin: true, + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.section).toBe('general'); + expect(result.data).toEqual({ + theme: 'system', + launchAtLogin: true, + }); + } + }); + + it('rejects invalid section names', () => { + const result = validateConfigUpdatePayload('invalid-section', { theme: 'dark' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('Section must be one of'); + } + }); + + it('rejects unknown notification keys', () => { + const result = validateConfigUpdatePayload('notifications', { unknownField: true }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('not supported'); + } + }); + + it('accepts valid notifications.triggers payload', () => { + const result = validateConfigUpdatePayload('notifications', { + triggers: [ + { + id: 'trigger-1', + name: 'test', + enabled: true, + contentType: 'tool_result', + mode: 'error_status', + requireError: true, + }, + ], + }); + expect(result.valid).toBe(true); + }); + + it('rejects invalid notifications.triggers payload', () => { + const result = validateConfigUpdatePayload('notifications', { + triggers: [{ id: 'missing-required-fields' }], + }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('valid trigger'); + } + }); + + it('rejects out-of-range snoozeMinutes', () => { + const result = validateConfigUpdatePayload('notifications', { snoozeMinutes: 0 }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('between 1 and'); + } + }); + + it('accepts valid display updates', () => { + const result = validateConfigUpdatePayload('display', { + compactMode: true, + syntaxHighlighting: false, + }); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.section).toBe('display'); + expect(result.data).toEqual({ + compactMode: true, + syntaxHighlighting: false, + }); + } + }); +}); diff --git a/test/main/ipc/guards.test.ts b/test/main/ipc/guards.test.ts new file mode 100644 index 00000000..6af43df5 --- /dev/null +++ b/test/main/ipc/guards.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { + coercePageLimit, + coerceSearchMaxResults, + validateProjectId, + validateSearchQuery, + validateSessionId, +} from '../../../src/main/ipc/guards'; + +describe('ipc guards', () => { + it('accepts valid encoded project IDs', () => { + const result = validateProjectId('-Users-test-project'); + expect(result.valid).toBe(true); + expect(result.value).toBe('-Users-test-project'); + }); + + it('accepts valid Windows-style encoded project IDs', () => { + const result = validateProjectId('-C:-Users-test-project'); + expect(result.valid).toBe(true); + expect(result.value).toBe('-C:-Users-test-project'); + }); + + it('rejects invalid project IDs', () => { + const result = validateProjectId('../escape'); + expect(result.valid).toBe(false); + }); + + it('accepts valid session IDs', () => { + const result = validateSessionId('abc123-session_id'); + expect(result.valid).toBe(true); + }); + + it('rejects empty search queries', () => { + const result = validateSearchQuery(' '); + expect(result.valid).toBe(false); + }); + + it('caps search max results', () => { + expect(coerceSearchMaxResults(9999, 50)).toBe(200); + expect(coerceSearchMaxResults(-1, 50)).toBe(50); + }); + + it('caps pagination limits', () => { + expect(coercePageLimit(500, 20)).toBe(200); + expect(coercePageLimit(0, 20)).toBe(20); + }); +}); diff --git a/test/main/services/analysis/ChunkBuilder.test.ts b/test/main/services/analysis/ChunkBuilder.test.ts new file mode 100644 index 00000000..97aa7053 --- /dev/null +++ b/test/main/services/analysis/ChunkBuilder.test.ts @@ -0,0 +1,449 @@ +/** + * Tests for ChunkBuilder service. + * + * Tests chunk building from parsed messages: + * - UserChunk creation from user messages + * - AIChunk creation from assistant messages (with tool grouping) + * - SystemChunk creation from command output + * - Subagent linking to AIChunks + */ + +import { describe, expect, it } from 'vitest'; + +import { ChunkBuilder } from '../../../../src/main/services/analysis/ChunkBuilder'; +import { isAIChunk, isCompactChunk, isSystemChunk, isUserChunk } from '../../../../src/main/types'; +import type { ParsedMessage, Process } from '../../../../src/main/types'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Creates a minimal ParsedMessage for testing. + */ +function createMessage(overrides: Partial): ParsedMessage { + return { + uuid: `msg-${Math.random().toString(36).slice(2, 11)}`, + parentUuid: null, + type: 'user', + timestamp: new Date(), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +/** + * Creates a minimal Process (subagent) for testing. + */ +function createSubagent(overrides: Partial): Process { + return { + id: `agent-${Math.random().toString(36).slice(2, 11)}`, + filePath: '/path/to/agent.jsonl', + parentTaskId: 'task-1', + description: 'Test subagent', + startTime: new Date(), + endTime: new Date(), + durationMs: 1000, + isOngoing: false, + messages: [], + metrics: { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 150, + messageCount: 2, + durationMs: 1000, + }, + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('ChunkBuilder', () => { + const builder = new ChunkBuilder(); + + describe('buildChunks', () => { + it('should return empty array for empty input', () => { + const chunks = builder.buildChunks([]); + expect(chunks).toEqual([]); + }); + + it('should filter out sidechain messages', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Main thread message', + isMeta: false, + isSidechain: false, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Sidechain response' }], + isSidechain: true, + }), + ]; + + const chunks = builder.buildChunks(messages); + // Only the main thread user message should create a chunk + expect(chunks).toHaveLength(1); + expect(isUserChunk(chunks[0])).toBe(true); + }); + + describe('UserChunk creation', () => { + it('should create UserChunk from real user message', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Help me debug this', + isMeta: false, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isUserChunk(chunks[0])).toBe(true); + + if (isUserChunk(chunks[0])) { + expect(chunks[0].userMessage.content).toBe('Help me debug this'); + } + }); + + it('should create UserChunk with array content', () => { + const messages = [ + createMessage({ + type: 'user', + content: [{ type: 'text', text: 'Hello world' }], + isMeta: false, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isUserChunk(chunks[0])).toBe(true); + }); + }); + + describe('AIChunk creation', () => { + it('should create AIChunk from assistant message', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: "Here's how to fix it" }], + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isAIChunk(chunks[0])).toBe(true); + + if (isAIChunk(chunks[0])) { + expect(chunks[0].responses).toHaveLength(1); + } + }); + + it('should group consecutive assistant messages into one AIChunk', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'First response' }], + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Second response' }], + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isAIChunk(chunks[0])).toBe(true); + + if (isAIChunk(chunks[0])) { + expect(chunks[0].responses).toHaveLength(2); + } + }); + + it('should include tool results in AIChunk', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [ + { type: 'text', text: 'Reading file' }, + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'test.ts' } }, + ], + toolCalls: [{ id: 't1', name: 'Read', input: { file_path: 'test.ts' }, isTask: false }], + }), + createMessage({ + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 't1', content: 'file contents' }], + isMeta: true, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Found the issue' }], + }), + ]; + + const chunks = builder.buildChunks(messages); + // All should be in one AIChunk + expect(chunks).toHaveLength(1); + expect(isAIChunk(chunks[0])).toBe(true); + + if (isAIChunk(chunks[0])) { + // 2 assistant messages + 1 tool result + expect(chunks[0].responses.length).toBeGreaterThanOrEqual(2); + } + }); + }); + + describe('SystemChunk creation', () => { + it('should create SystemChunk from command output', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Model set to sonnet', + isMeta: false, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isSystemChunk(chunks[0])).toBe(true); + + if (isSystemChunk(chunks[0])) { + expect(chunks[0].commandOutput).toContain('Model set to sonnet'); + } + }); + }); + + describe('CompactChunk creation', () => { + it('should create CompactChunk from compact summary', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Summary of conversation...', + isCompactSummary: true, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(1); + expect(isCompactChunk(chunks[0])).toBe(true); + }); + }); + + describe('hardNoise filtering', () => { + it('should filter out system messages', () => { + const messages = [ + createMessage({ + type: 'system', + content: 'System prompt', + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(0); + }); + + it('should filter out synthetic assistant messages', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: '', + model: '', + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(0); + }); + + it('should filter out caveat messages', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'This is a caveat', + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(0); + }); + }); + + describe('AIChunk flushing', () => { + it('should flush AIChunk buffer when user message arrives', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Response 1' }], + }), + createMessage({ + type: 'user', + content: 'New question', + isMeta: false, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Response 2' }], + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(3); + expect(isAIChunk(chunks[0])).toBe(true); + expect(isUserChunk(chunks[1])).toBe(true); + expect(isAIChunk(chunks[2])).toBe(true); + }); + + it('should flush AIChunk buffer when system message arrives', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Response' }], + }), + createMessage({ + type: 'user', + content: 'Output', + isMeta: false, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(2); + expect(isAIChunk(chunks[0])).toBe(true); + expect(isSystemChunk(chunks[1])).toBe(true); + }); + }); + + describe('subagent linking', () => { + it('should link subagent to AIChunk containing Task call', () => { + const taskId = 'task-123'; + const messages = [ + createMessage({ + type: 'assistant', + content: [ + { type: 'text', text: 'Spawning agent' }, + { + type: 'tool_use', + id: taskId, + name: 'Task', + input: { prompt: 'Do something', subagent_type: 'explore' }, + }, + ], + toolCalls: [ + { + id: taskId, + name: 'Task', + input: { prompt: 'Do something', subagent_type: 'explore' }, + isTask: true, + taskDescription: 'Do something', + taskSubagentType: 'explore', + }, + ], + }), + ]; + + const subagent = createSubagent({ + parentTaskId: taskId, + }); + + const chunks = builder.buildChunks(messages, [subagent]); + expect(chunks).toHaveLength(1); + expect(isAIChunk(chunks[0])).toBe(true); + + if (isAIChunk(chunks[0])) { + expect(chunks[0].processes).toHaveLength(1); + expect(chunks[0].processes[0].id).toBe(subagent.id); + } + }); + }); + }); + + describe('getTotalChunkMetrics', () => { + it('should return empty metrics for empty chunks', () => { + const metrics = builder.getTotalChunkMetrics([]); + expect(metrics.totalTokens).toBe(0); + expect(metrics.durationMs).toBe(0); + expect(metrics.messageCount).toBe(0); + }); + }); + + describe('buildSessionDetail', () => { + it('should build complete session detail', () => { + const session = { + id: 'session-1', + projectId: 'project-1', + projectPath: '/path/to/project', + filePath: '/path/to/session.jsonl', + timestamp: new Date(), + lastModified: new Date(), + isOngoing: false, + }; + + const messages = [ + createMessage({ + type: 'user', + content: 'Hello', + isMeta: false, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + }), + ]; + + const detail = builder.buildSessionDetail(session, messages, []); + + expect(detail.session).toBe(session); + expect(detail.messages).toBe(messages); + expect(detail.chunks.length).toBeGreaterThan(0); + expect(detail.processes).toEqual([]); + expect(detail.metrics).toBeDefined(); + }); + }); + + describe('buildWaterfallData', () => { + it('should build sorted waterfall items from chunks and subagents', () => { + const start = new Date('2026-01-01T00:00:00.000Z'); + const end = new Date('2026-01-01T00:00:10.000Z'); + + const messages = [ + createMessage({ + type: 'assistant', + timestamp: start, + content: [{ type: 'text', text: 'Running tools' }], + toolCalls: [{ id: 'tool-1', name: 'Read', input: {}, isTask: false }], + }), + createMessage({ + type: 'user', + timestamp: end, + isMeta: true, + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'done' }], + }), + ]; + + const subagent = createSubagent({ + id: 'agent-1', + startTime: new Date('2026-01-01T00:00:03.000Z'), + endTime: new Date('2026-01-01T00:00:08.000Z'), + durationMs: 5000, + }); + + const chunks = builder.buildChunks(messages, [subagent]); + const waterfall = builder.buildWaterfallData(chunks, [subagent]); + + expect(waterfall.items.length).toBeGreaterThan(0); + expect(waterfall.totalDurationMs).toBeGreaterThanOrEqual(0); + expect(waterfall.minTime.getTime()).toBeLessThanOrEqual(waterfall.maxTime.getTime()); + expect(waterfall.items.some((item) => item.type === 'subagent')).toBe(true); + }); + }); +}); diff --git a/test/main/services/discovery/ProjectPathResolver.test.ts b/test/main/services/discovery/ProjectPathResolver.test.ts new file mode 100644 index 00000000..3bdb27b7 --- /dev/null +++ b/test/main/services/discovery/ProjectPathResolver.test.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { ProjectPathResolver } from '../../../../src/main/services/discovery/ProjectPathResolver'; + +function createSessionLine(cwd: string): string { + return JSON.stringify({ + uuid: 'test-uuid', + type: 'user', + cwd, + message: { role: 'user', content: 'hello' }, + timestamp: new Date().toISOString(), + }); +} + +describe('ProjectPathResolver', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + it('prefers absolute cwd hint', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-projects-')); + tempDirs.push(projectsDir); + + const resolver = new ProjectPathResolver(projectsDir); + const resolved = await resolver.resolveProjectPath('-Users-test-proj', { + cwdHint: '/Users/test/proj', + }); + + expect(resolved).toBe('/Users/test/proj'); + }); + + it('extracts cwd from session file when available', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-projects-')); + tempDirs.push(projectsDir); + + const projectId = '-Users-test-my-repo'; + const projectDir = path.join(projectsDir, projectId); + fs.mkdirSync(projectDir, { recursive: true }); + + const sessionPath = path.join(projectDir, 'session-1.jsonl'); + fs.writeFileSync(sessionPath, `${createSessionLine('/Users/test/my-repo')}\n`, 'utf8'); + + const resolver = new ProjectPathResolver(projectsDir); + const resolved = await resolver.resolveProjectPath(projectId); + + expect(resolved).toBe('/Users/test/my-repo'); + }); + + it('falls back to decoded project ID when no cwd is available', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-projects-')); + tempDirs.push(projectsDir); + + const resolver = new ProjectPathResolver(projectsDir); + const resolved = await resolver.resolveProjectPath('-C:-Users-test-my-repo'); + + expect(resolved).toBe('C:/Users/test/my/repo'); + }); + + it('invalidates cached paths by project', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-projects-')); + tempDirs.push(projectsDir); + + const projectId = '-Users-test-my-repo'; + const projectDir = path.join(projectsDir, projectId); + fs.mkdirSync(projectDir, { recursive: true }); + + const sessionPath = path.join(projectDir, 'session-1.jsonl'); + fs.writeFileSync(sessionPath, `${createSessionLine('/Users/test/my-repo-v1')}\n`, 'utf8'); + + const resolver = new ProjectPathResolver(projectsDir); + const firstResolved = await resolver.resolveProjectPath(projectId); + expect(firstResolved).toBe('/Users/test/my-repo-v1'); + + fs.writeFileSync(sessionPath, `${createSessionLine('/Users/test/my-repo-v2')}\n`, 'utf8'); + resolver.invalidateProject(projectId); + + const secondResolved = await resolver.resolveProjectPath(projectId); + expect(secondResolved).toBe('/Users/test/my-repo-v2'); + }); +}); diff --git a/test/main/services/discovery/SessionSearcher.test.ts b/test/main/services/discovery/SessionSearcher.test.ts new file mode 100644 index 00000000..fa3dc845 --- /dev/null +++ b/test/main/services/discovery/SessionSearcher.test.ts @@ -0,0 +1,122 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { SessionSearcher } from '../../../../src/main/services/discovery/SessionSearcher'; + +describe('SessionSearcher', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + tempDirs.length = 0; + }); + + it('searches only user text and AI last text output, returning every match occurrence', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-')); + tempDirs.push(projectsDir); + + const projectId = 'project-1'; + const sessionId = 'session-1'; + const projectPath = path.join(projectsDir, projectId); + fs.mkdirSync(projectPath, { recursive: true }); + + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + uuid: 'user-1', + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'alpha intro alpha' }, + isMeta: false, + }), + JSON.stringify({ + uuid: 'asst-1', + type: 'assistant', + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'older alpha that should be ignored' }], + }, + }), + JSON.stringify({ + uuid: 'asst-2', + type: 'assistant', + timestamp: '2026-01-01T00:00:02.000Z', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'alpha in thinking should not be matched' }, + { type: 'text', text: 'latest alpha alpha output' }, + ], + }, + }), + ]; + fs.writeFileSync(sessionPath, `${lines.join('\n')}\n`, 'utf8'); + + const searcher = new SessionSearcher(projectsDir); + const result = await searcher.searchSessions(projectId, 'alpha', 50); + + expect(result.totalMatches).toBe(4); + expect(result.results).toHaveLength(4); + + const userResults = result.results.filter((entry) => entry.groupId === 'user-user-1'); + const aiResults = result.results.filter((entry) => entry.groupId === 'ai-asst-1'); + + expect(userResults).toHaveLength(2); + expect(aiResults).toHaveLength(2); + expect(userResults.map((entry) => entry.matchIndexInItem)).toEqual([0, 1]); + expect(aiResults.map((entry) => entry.matchIndexInItem)).toEqual([0, 1]); + expect(result.results.some((entry) => entry.context.includes('ignored'))).toBe(false); + expect( + result.results.every((entry) => entry.itemType === 'user' || entry.itemType === 'ai') + ).toBe(true); + }); + + it('does not produce phantom matches for code fence language identifiers', async () => { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-searcher-md-')); + tempDirs.push(projectsDir); + + const projectId = 'project-2'; + const sessionId = 'session-2'; + const projectPath = path.join(projectsDir, projectId); + fs.mkdirSync(projectPath, { recursive: true }); + + const sessionPath = path.join(projectPath, `${sessionId}.jsonl`); + const codeBlock = '```tsx\nconst x = 1;\n```'; + const lines = [ + JSON.stringify({ + uuid: 'user-md-1', + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'Show me tsx code' }, + isMeta: false, + }), + JSON.stringify({ + uuid: 'asst-md-1', + type: 'assistant', + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: `Here is a code block:\n\n${codeBlock}` }], + }, + }), + ]; + fs.writeFileSync(sessionPath, `${lines.join('\n')}\n`, 'utf8'); + + const searcher = new SessionSearcher(projectsDir); + const result = await searcher.searchSessions(projectId, 'tsx', 50); + + // "tsx" should match in user text ("Show me tsx code") but NOT in the + // code fence language identifier (```tsx). It should also not match in + // the code block content since "const x = 1;" doesn't contain "tsx". + const userResults = result.results.filter((r) => r.itemType === 'user'); + const aiResults = result.results.filter((r) => r.itemType === 'ai'); + + expect(userResults).toHaveLength(1); + expect(aiResults).toHaveLength(0); + }); +}); diff --git a/test/main/services/infrastructure/FileWatcher.test.ts b/test/main/services/infrastructure/FileWatcher.test.ts new file mode 100644 index 00000000..3be1d487 --- /dev/null +++ b/test/main/services/infrastructure/FileWatcher.test.ts @@ -0,0 +1,571 @@ +import { EventEmitter } from 'events'; +import type * as FsType from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@shared/utils/logger', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + existsSync: vi.fn(), + watch: vi.fn(), + // Stash the real existsSync so tests can delegate to it for real file I/O + __realExistsSync: actual.existsSync, + }; +}); + +vi.mock('../../../../src/main/services/error/ErrorDetector', () => ({ + errorDetector: { + detectErrors: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock('../../../../src/main/services/infrastructure/ConfigManager', () => ({ + ConfigManager: { + getInstance: () => ({ + getConfig: () => ({ + notifications: { includeSubagentErrors: true, triggers: [] }, + }), + }), + }, +})); + +vi.mock('../../../../src/main/services/discovery/ProjectPathResolver', () => ({ + projectPathResolver: { + invalidateProject: vi.fn(), + }, +})); + +import * as fs from 'fs'; + +import { errorDetector } from '../../../../src/main/services/error/ErrorDetector'; +import { DataCache } from '../../../../src/main/services/infrastructure/DataCache'; +import { FileWatcher } from '../../../../src/main/services/infrastructure/FileWatcher'; + +function createFakeWatcher(): FsType.FSWatcher { + const emitter = new EventEmitter() as EventEmitter & { close: () => void }; + emitter.close = vi.fn(() => { + emitter.emit('close'); + }); + return emitter as unknown as FsType.FSWatcher; +} + +/** Make existsSync delegate to the real implementation (needed for tests with real temp files) */ +function useRealExistsSync() { + const realFn = (fs as unknown as { __realExistsSync: typeof fs.existsSync }).__realExistsSync; + vi.mocked(fs.existsSync).mockImplementation((p) => realFn(p)); +} + +function createMockNotificationManager() { + return { + addError: vi.fn().mockResolvedValue(null), + } as unknown as Parameters[0]; +} + +/** Helper to write a valid JSONL line */ +function jsonlLine(uuid: string, text: string): string { + return ( + JSON.stringify({ + type: 'assistant', + uuid, + timestamp: '2026-01-01T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + }) + '\n' + ); +} + +describe('FileWatcher', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('retries and starts watchers when directories appear later', () => { + const dataCache = new DataCache(50, 10, false); + let dirsAvailable = false; + + const existsSyncMock = vi.mocked(fs.existsSync); + existsSyncMock.mockImplementation((targetPath) => { + if (targetPath === '/tmp/projects' || targetPath === '/tmp/todos') { + return dirsAvailable; + } + return false; + }); + + const watchMock = vi.mocked(fs.watch); + watchMock.mockImplementation(() => createFakeWatcher()); + + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + watcher.start(); + + expect(watchMock).toHaveBeenCalledTimes(0); + + dirsAvailable = true; + vi.advanceTimersByTime(2000); + + expect(watchMock).toHaveBeenCalledTimes(2); + watcher.stop(); + }); + + it('recovers from watcher errors by re-registering affected watcher', () => { + const dataCache = new DataCache(50, 10, false); + const projectWatcher = createFakeWatcher(); + const todoWatcher = createFakeWatcher(); + const replacementProjectWatcher = createFakeWatcher(); + + const existsSyncMock = vi.mocked(fs.existsSync); + existsSyncMock.mockImplementation((targetPath) => { + return targetPath === '/tmp/projects' || targetPath === '/tmp/todos'; + }); + + const watchMock = vi.mocked(fs.watch); + watchMock + .mockImplementationOnce(() => projectWatcher) + .mockImplementationOnce(() => todoWatcher) + .mockImplementationOnce(() => replacementProjectWatcher); + + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + watcher.start(); + expect(watchMock).toHaveBeenCalledTimes(2); + + (projectWatcher as unknown as EventEmitter).emit('error', new Error('watch failed')); + vi.advanceTimersByTime(2000); + + expect(watchMock).toHaveBeenCalledTimes(3); + watcher.stop(); + }); + + it('keeps append offset pinned for partial trailing lines until completed', async () => { + vi.useRealTimers(); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-')); + const filePath = path.join(tempDir, 'session.jsonl'); + const firstLine = jsonlLine('a1', 'hi'); + fs.writeFileSync(filePath, firstLine, 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + + const firstPass = await ( + watcher as unknown as { + parseAppendedMessages: ( + targetPath: string, + startOffset: number + ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; + } + ).parseAppendedMessages(filePath, 0); + expect(firstPass.parsedLineCount).toBe(1); + expect(firstPass.consumedBytes).toBe(Buffer.byteLength(firstLine, 'utf8')); + + const partialSuffix = + '{"type":"assistant","uuid":"a2","timestamp":"2026-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"partial"'; + fs.appendFileSync(filePath, partialSuffix, 'utf8'); + + const partialPass = await ( + watcher as unknown as { + parseAppendedMessages: ( + targetPath: string, + startOffset: number + ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; + } + ).parseAppendedMessages(filePath, firstPass.consumedBytes); + expect(partialPass.parsedLineCount).toBe(0); + expect(partialPass.consumedBytes).toBe(0); + + const completion = '}]}}\n'; + fs.appendFileSync(filePath, completion, 'utf8'); + + const completedPass = await ( + watcher as unknown as { + parseAppendedMessages: ( + targetPath: string, + startOffset: number + ) => Promise<{ parsedLineCount: number; consumedBytes: number }>; + } + ).parseAppendedMessages(filePath, firstPass.consumedBytes); + expect(completedPass.parsedLineCount).toBe(1); + expect(completedPass.consumedBytes).toBeGreaterThan(0); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + // =========================================================================== + // Catch-Up Scan Tests + // =========================================================================== + + describe('catch-up scan', () => { + it('detects file growth missed by fs.watch', async () => { + vi.useRealTimers(); + useRealExistsSync(); + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-catchup-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + const line1 = jsonlLine('u1', 'hello'); + fs.writeFileSync(filePath, line1, 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Simulate having previously processed the file by directly setting tracking state + const watcherAny = watcher as unknown as { + lastProcessedLineCount: Map; + lastProcessedSize: Map; + activeSessionFiles: Map; + runCatchUpScan: () => Promise; + }; + const initialSize = fs.statSync(filePath).size; + watcherAny.lastProcessedLineCount.set(filePath, 1); + watcherAny.lastProcessedSize.set(filePath, initialSize); + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'session-1', + }); + + // Append new data WITHOUT triggering fs.watch (simulating a missed event) + const line2 = jsonlLine('u2', 'world'); + fs.appendFileSync(filePath, line2, 'utf8'); + + // Run catch-up scan manually + await watcherAny.runCatchUpScan(); + + // The error detector should have been called with the new message + expect(errorDetector.detectErrors).toHaveBeenCalled(); + const calls = vi.mocked(errorDetector.detectErrors).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall[1]).toBe('session-1'); + expect(lastCall[2]).toBe('test-project'); + + // Verify tracking state was updated + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(2); + expect(watcherAny.lastProcessedSize.get(filePath)).toBeGreaterThan(initialSize); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('skips files with no size change', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-noop-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + const line1 = jsonlLine('u1', 'hello'); + fs.writeFileSync(filePath, line1, 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + const watcherAny = watcher as unknown as { + lastProcessedLineCount: Map; + lastProcessedSize: Map; + activeSessionFiles: Map; + runCatchUpScan: () => Promise; + }; + const currentSize = fs.statSync(filePath).size; + watcherAny.lastProcessedLineCount.set(filePath, 1); + watcherAny.lastProcessedSize.set(filePath, currentSize); + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'session-1', + }); + + vi.mocked(errorDetector.detectErrors).mockClear(); + + // Run catch-up scan without any file changes + await watcherAny.runCatchUpScan(); + + // Error detector should NOT have been called since file hasn't changed + expect(errorDetector.detectErrors).not.toHaveBeenCalled(); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('removes stale files older than 1 hour from active tracking', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-stale-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'old-session.jsonl'); + fs.writeFileSync(filePath, jsonlLine('u1', 'old'), 'utf8'); + + // Set file mtime to 2 hours ago + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000); + fs.utimesSync(filePath, twoHoursAgo, twoHoursAgo); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + const watcherAny = watcher as unknown as { + activeSessionFiles: Map; + lastProcessedSize: Map; + runCatchUpScan: () => Promise; + }; + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'old-session', + }); + watcherAny.lastProcessedSize.set(filePath, 0); + + await watcherAny.runCatchUpScan(); + + // Stale file should be removed from active tracking + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('handles deleted files gracefully during catch-up scan', async () => { + vi.useRealTimers(); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + watcher.setNotificationManager(notificationManager); + + const filePath = '/tmp/projects/test-project/nonexistent.jsonl'; + + const watcherAny = watcher as unknown as { + activeSessionFiles: Map; + lastProcessedSize: Map; + lastProcessedLineCount: Map; + runCatchUpScan: () => Promise; + }; + watcherAny.activeSessionFiles.set(filePath, { + projectId: 'test-project', + sessionId: 'nonexistent', + }); + watcherAny.lastProcessedSize.set(filePath, 100); + watcherAny.lastProcessedLineCount.set(filePath, 5); + + // Should not throw + await watcherAny.runCatchUpScan(); + + // Deleted file should be cleaned up + expect(watcherAny.activeSessionFiles.has(filePath)).toBe(false); + expect(watcherAny.lastProcessedSize.has(filePath)).toBe(false); + expect(watcherAny.lastProcessedLineCount.has(filePath)).toBe(false); + + watcher.stop(); + }); + }); + + // =========================================================================== + // Concurrency Guard Tests + // =========================================================================== + + describe('concurrency guard', () => { + it('prevents concurrent processing of the same file', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-concurrent-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + fs.writeFileSync(filePath, jsonlLine('u1', 'hello'), 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + // Make detectErrors slow to simulate long processing + let detectResolve: () => void; + const detectPromise = new Promise((resolve) => { + detectResolve = resolve; + }); + vi.mocked(errorDetector.detectErrors).mockImplementation( + () => + new Promise((resolve) => { + detectPromise.then(() => resolve([])); + }) + ); + + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + processingInProgress: Set; + pendingReprocess: Set; + }; + + // Start first call (will block on detectErrors) + const first = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // Wait a tick so the first call enters the processing block and reaches detectErrors + await new Promise((r) => setTimeout(r, 50)); + + // Verify the file is marked as processing + expect(watcherAny.processingInProgress.has(filePath)).toBe(true); + + // Second call should be deferred (returns immediately) + const second = watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + await second; + + // Verify pending reprocess was set + expect(watcherAny.pendingReprocess.has(filePath)).toBe(true); + + // Resolve the slow detectErrors + detectResolve!(); + await first; + + // After first completes, pending reprocess triggers a re-run + // Wait for the re-run to complete + await new Promise((r) => setTimeout(r, 100)); + + // pendingReprocess should be cleared after reprocessing + expect(watcherAny.pendingReprocess.has(filePath)).toBe(false); + expect(watcherAny.processingInProgress.has(filePath)).toBe(false); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + }); + + // =========================================================================== + // Fallback Size Tracking Tests + // =========================================================================== + + describe('lastProcessedSize in fallback path', () => { + it('re-stats file after full parse to capture concurrent writes', async () => { + vi.useRealTimers(); + useRealExistsSync(); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'filewatcher-size-')); + const projectsDir = path.join(tempDir, 'projects'); + const projectDir = path.join(projectsDir, 'test-project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, 'session-1.jsonl'); + const line1 = jsonlLine('u1', 'hello'); + fs.writeFileSync(filePath, line1, 'utf8'); + + const dataCache = new DataCache(50, 10, false); + const notificationManager = createMockNotificationManager(); + const watcher = new FileWatcher(dataCache, projectsDir, path.join(tempDir, 'todos')); + watcher.setNotificationManager(notificationManager); + + vi.mocked(errorDetector.detectErrors).mockResolvedValue([]); + + const watcherAny = watcher as unknown as { + detectErrorsInSessionFile: ( + projectId: string, + sessionId: string, + filePath: string + ) => Promise; + lastProcessedSize: Map; + lastProcessedLineCount: Map; + }; + + // First call - fallback path (no lastProcessedLineCount) + await watcherAny.detectErrorsInSessionFile('test-project', 'session-1', filePath); + + // The lastProcessedSize should match the actual file size on disk + const actualSize = fs.statSync(filePath).size; + expect(watcherAny.lastProcessedSize.get(filePath)).toBe(actualSize); + expect(watcherAny.lastProcessedLineCount.get(filePath)).toBe(1); + + watcher.stop(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + }); + + // =========================================================================== + // Timer Lifecycle Tests + // =========================================================================== + + describe('timer lifecycle', () => { + it('starts catch-up timer on start() and clears on stop()', () => { + const dataCache = new DataCache(50, 10, false); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher()); + + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + + const watcherAny = watcher as unknown as { + catchUpTimer: NodeJS.Timeout | null; + }; + + expect(watcherAny.catchUpTimer).toBeNull(); + + watcher.start(); + expect(watcherAny.catchUpTimer).not.toBeNull(); + + watcher.stop(); + expect(watcherAny.catchUpTimer).toBeNull(); + }); + + it('clears all tracking state on stop()', () => { + const dataCache = new DataCache(50, 10, false); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.watch).mockImplementation(() => createFakeWatcher()); + + const watcher = new FileWatcher(dataCache, '/tmp/projects', '/tmp/todos'); + + const watcherAny = watcher as unknown as { + activeSessionFiles: Map; + processingInProgress: Set; + pendingReprocess: Set; + }; + + watcher.start(); + + // Add some tracking state + watcherAny.activeSessionFiles.set('/tmp/file.jsonl', { + projectId: 'p', + sessionId: 's', + }); + watcherAny.processingInProgress.add('/tmp/file.jsonl'); + watcherAny.pendingReprocess.add('/tmp/file.jsonl'); + + watcher.stop(); + + expect(watcherAny.activeSessionFiles.size).toBe(0); + expect(watcherAny.processingInProgress.size).toBe(0); + expect(watcherAny.pendingReprocess.size).toBe(0); + }); + }); +}); diff --git a/test/main/services/parsing/MessageClassifier.test.ts b/test/main/services/parsing/MessageClassifier.test.ts new file mode 100644 index 00000000..fe071de6 --- /dev/null +++ b/test/main/services/parsing/MessageClassifier.test.ts @@ -0,0 +1,303 @@ +/** + * Tests for MessageClassifier service. + * + * Tests the 5-category message classification: + * - user: Real user input (creates UserChunk) + * - system: Command output (creates SystemChunk) + * - compact: Summary messages from conversation compaction + * - hardNoise: Filtered out (system metadata, caveats, reminders) + * - ai: All other messages (creates AIChunk) + */ + +import { describe, expect, it } from 'vitest'; + +import { classifyMessages } from '../../../../src/main/services/parsing/MessageClassifier'; +import type { ParsedMessage } from '../../../../src/main/types'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Creates a minimal ParsedMessage for testing. + */ +function createMessage(overrides: Partial): ParsedMessage { + return { + uuid: 'test-uuid', + parentUuid: null, + type: 'user', + timestamp: new Date(), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('MessageClassifier', () => { + describe('classifyMessages', () => { + it('should return empty array for empty input', () => { + const result = classifyMessages([]); + expect(result).toEqual([]); + }); + + it('should classify all messages', () => { + const messages = [ + createMessage({ type: 'user', content: 'Hello', isMeta: false }), + createMessage({ type: 'assistant', content: 'Hi there!' }), + ]; + const result = classifyMessages(messages); + expect(result).toHaveLength(2); + expect(result[0].message).toBe(messages[0]); + expect(result[1].message).toBe(messages[1]); + }); + }); + + describe('user category', () => { + it('should classify real user message with string content', () => { + const message = createMessage({ + type: 'user', + content: 'Help me debug this code', + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('user'); + }); + + it('should classify real user message with array content (text block)', () => { + const message = createMessage({ + type: 'user', + content: [{ type: 'text', text: 'Help me debug this code' }], + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('user'); + }); + + it('should classify user message with image as user', () => { + const message = createMessage({ + type: 'user', + content: [ + { type: 'text', text: 'What is in this image?' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc' } }, + ], + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('user'); + }); + + it('should classify slash command as user input', () => { + const message = createMessage({ + type: 'user', + content: '/model Switch to sonnet', + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('user'); + }); + }); + + describe('system category', () => { + it('should classify local-command-stdout as system', () => { + const message = createMessage({ + type: 'user', + content: + 'Set model to claude-sonnet-4-20250514', + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('system'); + }); + + it('should classify local-command-stderr as system', () => { + const message = createMessage({ + type: 'user', + content: 'Error: command failed', + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('system'); + }); + + it('should classify array content with stdout as system', () => { + const message = createMessage({ + type: 'user', + content: [{ type: 'text', text: 'output' }], + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('system'); + }); + }); + + describe('compact category', () => { + it('should classify compact summary message', () => { + const message = createMessage({ + type: 'user', + content: 'Summary of previous conversation...', + isCompactSummary: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('compact'); + }); + }); + + describe('hardNoise category', () => { + it('should classify system type as hardNoise', () => { + const message = createMessage({ + type: 'system', + content: 'System prompt', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify summary type as hardNoise', () => { + const message = createMessage({ + type: 'summary' as ParsedMessage['type'], + content: 'Summary', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify synthetic assistant message as hardNoise', () => { + const message = createMessage({ + type: 'assistant', + content: '', + model: '', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify local-command-caveat as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: 'This is a caveat', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify system-reminder as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: 'Remember to do X', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify empty stdout as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: '', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify file-history-snapshot as hardNoise', () => { + const message = createMessage({ + type: 'file-history-snapshot' as ParsedMessage['type'], + content: '', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + }); + + describe('ai category', () => { + it('should classify assistant message as ai', () => { + const message = createMessage({ + type: 'assistant', + content: [{ type: 'text', text: "Here's how to fix your code..." }], + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + + it('should classify assistant message with tool use as ai', () => { + const message = createMessage({ + type: 'assistant', + content: [ + { type: 'text', text: 'Let me read that file' }, + { type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: '/test.ts' } }, + ], + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: '/test.ts' }, isTask: false }, + ], + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + + it('should classify internal user message (tool result) as ai', () => { + const message = createMessage({ + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents' }], + isMeta: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + + it('should classify user interruption message as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: [{ type: 'text', text: '[Request interrupted by user]' }], + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + }); + + describe('mixed message sequence', () => { + it('should correctly classify a typical conversation flow', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Fix the bug in app.ts', + isMeta: false, + }), + createMessage({ + type: 'assistant', + content: [ + { type: 'text', text: 'Let me read the file' }, + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'app.ts' } }, + ], + }), + createMessage({ + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 't1', content: 'const x = 1;' }], + isMeta: true, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'I found the issue. Let me fix it.' }], + }), + createMessage({ + type: 'system', + content: 'System message', + }), + ]; + + const results = classifyMessages(messages); + + expect(results[0].category).toBe('user'); // User input + expect(results[1].category).toBe('ai'); // Assistant with tool use + expect(results[2].category).toBe('ai'); // Tool result (internal user) + expect(results[3].category).toBe('ai'); // Assistant response + expect(results[4].category).toBe('hardNoise'); // System message + }); + }); +}); diff --git a/test/main/services/parsing/SessionParser.test.ts b/test/main/services/parsing/SessionParser.test.ts new file mode 100644 index 00000000..c80ec8dd --- /dev/null +++ b/test/main/services/parsing/SessionParser.test.ts @@ -0,0 +1,415 @@ +/** + * Tests for SessionParser service. + * + * Tests parsing functionality: + * - Message type grouping + * - Sidechain vs main thread separation + * - Task call extraction + * - Tool result linking + * - Time range calculation + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; + +import { + SessionParser, + type ParsedSession, +} from '../../../../src/main/services/parsing/SessionParser'; +import type { ParsedMessage } from '../../../../src/main/types'; + +// ============================================================================= +// Mock ProjectScanner +// ============================================================================= + +const mockProjectScanner = { + scan: vi.fn(), + getSessionPath: vi.fn(), + listSessionsPaginated: vi.fn(), + listSessions: vi.fn(), + listSubagentFiles: vi.fn(), + getSession: vi.fn(), + listWorktreeSessions: vi.fn(), + scanWithWorktreeGrouping: vi.fn(), +}; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +/** + * Creates a minimal ParsedMessage for testing. + */ +function createMessage(overrides: Partial): ParsedMessage { + return { + uuid: `msg-${Math.random().toString(36).slice(2, 11)}`, + parentUuid: null, + type: 'user', + timestamp: new Date(), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('SessionParser', () => { + let parser: SessionParser; + + beforeEach(() => { + vi.clearAllMocks(); + // @ts-expect-error - Using partial mock + parser = new SessionParser(mockProjectScanner); + }); + + describe('processMessages (via public methods)', () => { + // Since processMessages is private, we test its behavior through the query methods + + describe('message type grouping', () => { + it('should group user messages correctly', () => { + const messages = [ + createMessage({ type: 'user', content: 'User message 1' }), + createMessage({ type: 'assistant', content: [{ type: 'text', text: 'Response' }] }), + createMessage({ type: 'user', content: 'User message 2' }), + ]; + + // Access processMessages result through getUserMessages + const processedResult = { + messages, + metrics: { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: messages.length, + }, + taskCalls: [], + byType: { + user: messages.filter((m) => m.type === 'user'), + realUser: messages.filter((m) => m.type === 'user' && !m.isMeta), + internalUser: messages.filter((m) => m.type === 'user' && m.isMeta), + assistant: messages.filter((m) => m.type === 'assistant'), + system: [], + other: [], + }, + sidechainMessages: [], + mainMessages: messages, + }; + + const userMessages = parser.getUserMessages(processedResult); + expect(userMessages).toHaveLength(2); + }); + + it('should separate real user vs internal user messages', () => { + const messages = [ + createMessage({ type: 'user', content: 'Real user input', isMeta: false }), + createMessage({ + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 't1', content: 'result' }], + isMeta: true, + }), + ]; + + const processedResult: ParsedSession = { + messages, + metrics: { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: messages.length, + }, + taskCalls: [], + byType: { + user: messages.filter((m) => m.type === 'user'), + realUser: messages.filter((m) => m.type === 'user' && !m.isMeta), + internalUser: messages.filter((m) => m.type === 'user' && m.isMeta), + assistant: [], + system: [], + other: [], + }, + sidechainMessages: [], + mainMessages: messages, + }; + + expect(processedResult.byType.realUser).toHaveLength(1); + expect(processedResult.byType.internalUser).toHaveLength(1); + }); + }); + + describe('sidechain separation', () => { + it('should separate sidechain from main thread messages', () => { + const messages = [ + createMessage({ type: 'user', content: 'Main', isSidechain: false }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Sidechain' }], + isSidechain: true, + }), + createMessage({ + type: 'assistant', + content: [{ type: 'text', text: 'Main' }], + isSidechain: false, + }), + ]; + + const sidechainMessages = messages.filter((m) => m.isSidechain); + const mainMessages = messages.filter((m) => !m.isSidechain); + + expect(sidechainMessages).toHaveLength(1); + expect(mainMessages).toHaveLength(2); + }); + }); + }); + + describe('getResponses', () => { + it('should get assistant responses after user message', () => { + const userMsgUuid = 'user-1'; + const messages = [ + createMessage({ uuid: userMsgUuid, type: 'user', content: 'Question' }), + createMessage({ + uuid: 'asst-1', + type: 'assistant', + content: [{ type: 'text', text: 'Answer 1' }], + }), + createMessage({ + uuid: 'asst-2', + type: 'assistant', + content: [{ type: 'text', text: 'Answer 2' }], + }), + createMessage({ uuid: 'user-2', type: 'user', content: 'Next question' }), + ]; + + const responses = parser.getResponses(messages, userMsgUuid); + expect(responses).toHaveLength(2); + expect(responses[0].uuid).toBe('asst-1'); + expect(responses[1].uuid).toBe('asst-2'); + }); + + it('should stop at next user message', () => { + const userMsgUuid = 'user-1'; + const messages = [ + createMessage({ uuid: userMsgUuid, type: 'user', content: 'Q1' }), + createMessage({ + uuid: 'asst-1', + type: 'assistant', + content: [{ type: 'text', text: 'A1' }], + }), + createMessage({ uuid: 'user-2', type: 'user', content: 'Q2' }), + createMessage({ + uuid: 'asst-2', + type: 'assistant', + content: [{ type: 'text', text: 'A2' }], + }), + ]; + + const responses = parser.getResponses(messages, userMsgUuid); + expect(responses).toHaveLength(1); + expect(responses[0].uuid).toBe('asst-1'); + }); + + it('should return empty for non-existent message', () => { + const messages = [createMessage({ uuid: 'user-1', type: 'user', content: 'Q' })]; + + const responses = parser.getResponses(messages, 'non-existent'); + expect(responses).toEqual([]); + }); + }); + + describe('getTaskCalls', () => { + it('should extract Task tool calls from messages', () => { + const messages = [ + createMessage({ + type: 'assistant', + content: [ + { type: 'text', text: 'Spawning agent' }, + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Do something', subagent_type: 'explore' }, + }, + ], + toolCalls: [ + { + id: 'task-1', + name: 'Task', + input: { prompt: 'Do something', subagent_type: 'explore' }, + isTask: true, + taskDescription: 'Do something', + taskSubagentType: 'explore', + }, + ], + }), + createMessage({ + type: 'assistant', + content: [ + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.ts' } }, + ], + toolCalls: [ + { id: 'read-1', name: 'Read', input: { file_path: 'test.ts' }, isTask: false }, + ], + }), + ]; + + const taskCalls = parser.getTaskCalls(messages); + expect(taskCalls).toHaveLength(1); + expect(taskCalls[0].name).toBe('Task'); + expect(taskCalls[0].isTask).toBe(true); + }); + }); + + describe('getToolCallsByName', () => { + it('should get tool calls by name', () => { + const messages = [ + createMessage({ + type: 'assistant', + toolCalls: [ + { id: 'read-1', name: 'Read', input: { file_path: 'a.ts' }, isTask: false }, + { + id: 'write-1', + name: 'Write', + input: { file_path: 'b.ts', content: '' }, + isTask: false, + }, + { id: 'read-2', name: 'Read', input: { file_path: 'c.ts' }, isTask: false }, + ], + }), + ]; + + const readCalls = parser.getToolCallsByName(messages, 'Read'); + expect(readCalls).toHaveLength(2); + expect(readCalls[0].id).toBe('read-1'); + expect(readCalls[1].id).toBe('read-2'); + }); + }); + + describe('findToolResult', () => { + it('should find tool result by tool call ID', () => { + const toolCallId = 'tool-1'; + const messages = [ + createMessage({ + type: 'user', + isMeta: true, + toolResults: [{ toolUseId: toolCallId, content: 'result content', isError: false }], + }), + ]; + + const found = parser.findToolResult(messages, toolCallId); + expect(found).not.toBeNull(); + expect(found?.result.toolUseId).toBe(toolCallId); + expect(found?.result.content).toBe('result content'); + }); + + it('should return null for non-existent tool call', () => { + const messages = [ + createMessage({ + type: 'user', + isMeta: true, + toolResults: [{ toolUseId: 'other-id', content: '', isError: false }], + }), + ]; + + const found = parser.findToolResult(messages, 'non-existent'); + expect(found).toBeNull(); + }); + }); + + describe('getTimeRange', () => { + it('should calculate time range correctly', () => { + const start = new Date('2024-01-01T10:00:00Z'); + const end = new Date('2024-01-01T10:05:00Z'); + const messages = [ + createMessage({ timestamp: start }), + createMessage({ timestamp: new Date('2024-01-01T10:02:00Z') }), + createMessage({ timestamp: end }), + ]; + + const range = parser.getTimeRange(messages); + expect(range.start.getTime()).toBe(start.getTime()); + expect(range.end.getTime()).toBe(end.getTime()); + expect(range.durationMs).toBe(5 * 60 * 1000); // 5 minutes + }); + + it('should handle empty messages', () => { + const range = parser.getTimeRange([]); + expect(range.durationMs).toBe(0); + }); + + it('should handle single message', () => { + const timestamp = new Date('2024-01-01T10:00:00Z'); + const messages = [createMessage({ timestamp })]; + + const range = parser.getTimeRange(messages); + expect(range.start.getTime()).toBe(timestamp.getTime()); + expect(range.end.getTime()).toBe(timestamp.getTime()); + expect(range.durationMs).toBe(0); + }); + }); + + describe('buildMessageTree', () => { + it('should build parent-child tree', () => { + const messages = [ + createMessage({ uuid: 'root', parentUuid: null }), + createMessage({ uuid: 'child1', parentUuid: 'root' }), + createMessage({ uuid: 'child2', parentUuid: 'root' }), + createMessage({ uuid: 'grandchild', parentUuid: 'child1' }), + ]; + + const tree = parser.buildMessageTree(messages); + + expect(tree.get('root')?.map((m) => m.uuid)).toContain('child1'); + expect(tree.get('root')?.map((m) => m.uuid)).toContain('child2'); + expect(tree.get('child1')?.map((m) => m.uuid)).toContain('grandchild'); + }); + }); + + describe('getChildMessages', () => { + it('should get direct children', () => { + const messages = [ + createMessage({ uuid: 'parent', parentUuid: null }), + createMessage({ uuid: 'child1', parentUuid: 'parent' }), + createMessage({ uuid: 'child2', parentUuid: 'parent' }), + createMessage({ uuid: 'other', parentUuid: 'other-parent' }), + ]; + + const children = parser.getChildMessages(messages, 'parent'); + expect(children).toHaveLength(2); + expect(children.map((m) => m.uuid)).toContain('child1'); + expect(children.map((m) => m.uuid)).toContain('child2'); + }); + }); + + describe('extractText', () => { + it('should extract text from string content', () => { + const message = createMessage({ content: 'Hello world' }); + expect(parser.extractText(message)).toBe('Hello world'); + }); + }); + + describe('getMessagePreview', () => { + it('should truncate long messages', () => { + const longText = 'A'.repeat(200); + const message = createMessage({ content: longText }); + + const preview = parser.getMessagePreview(message, 50); + expect(preview.length).toBe(53); // 50 chars + '...' + expect(preview.endsWith('...')).toBe(true); + }); + + it('should not truncate short messages', () => { + const message = createMessage({ content: 'Short' }); + const preview = parser.getMessagePreview(message, 50); + expect(preview).toBe('Short'); + }); + }); +}); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts new file mode 100644 index 00000000..55b35f5e --- /dev/null +++ b/test/main/utils/jsonl.test.ts @@ -0,0 +1,175 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +import { analyzeSessionFileMetadata, calculateMetrics } from '../../../src/main/utils/jsonl'; +import type { ParsedMessage } from '../../../src/main/types'; + +// Helper to create a minimal ParsedMessage +function createMessage(overrides: Partial = {}): ParsedMessage { + return { + uuid: 'test-uuid', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + content: '', + isSidechain: false, + isMeta: false, + isCompactSummary: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +describe('jsonl', () => { + describe('calculateMetrics', () => { + it('should return empty metrics for empty messages array', () => { + const result = calculateMetrics([]); + expect(result.durationMs).toBe(0); + expect(result.totalTokens).toBe(0); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(0); + expect(result.messageCount).toBe(0); + }); + + it('should calculate total tokens from usage', () => { + const messages = [ + createMessage({ + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }), + ]; + + const result = calculateMetrics(messages); + expect(result.inputTokens).toBe(100); + expect(result.outputTokens).toBe(50); + expect(result.totalTokens).toBe(150); + }); + + it('should sum tokens across multiple messages', () => { + const messages = [ + createMessage({ + usage: { input_tokens: 100, output_tokens: 50 }, + }), + createMessage({ + usage: { input_tokens: 200, output_tokens: 100 }, + }), + ]; + + const result = calculateMetrics(messages); + expect(result.inputTokens).toBe(300); + expect(result.outputTokens).toBe(150); + expect(result.totalTokens).toBe(450); + }); + + it('should handle cache tokens', () => { + const messages = [ + createMessage({ + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 25, + cache_creation_input_tokens: 10, + }, + }), + ]; + + const result = calculateMetrics(messages); + expect(result.cacheReadTokens).toBe(25); + expect(result.cacheCreationTokens).toBe(10); + expect(result.totalTokens).toBe(185); // 100 + 50 + 25 + 10 + }); + + it('should calculate duration from timestamps', () => { + const messages = [ + createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') }), + createMessage({ timestamp: new Date('2024-01-01T10:01:00Z') }), + createMessage({ timestamp: new Date('2024-01-01T10:02:00Z') }), + ]; + + const result = calculateMetrics(messages); + expect(result.durationMs).toBe(120000); // 2 minutes in ms + }); + + it('should count messages', () => { + const messages = [createMessage(), createMessage(), createMessage()]; + + const result = calculateMetrics(messages); + expect(result.messageCount).toBe(3); + }); + + it('should handle messages without usage', () => { + const messages = [ + createMessage({ type: 'user', content: 'Hello' }), + createMessage({ type: 'system' }), + ]; + + const result = calculateMetrics(messages); + expect(result.totalTokens).toBe(0); + expect(result.messageCount).toBe(2); + }); + + it('should handle single message duration', () => { + const messages = [createMessage({ timestamp: new Date('2024-01-01T10:00:00Z') })]; + + const result = calculateMetrics(messages); + expect(result.durationMs).toBe(0); // min === max + }); + + it('should handle undefined token values', () => { + const messages = [ + createMessage({ + usage: { + input_tokens: undefined as unknown as number, + output_tokens: 50, + }, + }), + ]; + + const result = calculateMetrics(messages); + expect(result.inputTokens).toBe(0); + expect(result.outputTokens).toBe(50); + }); + }); + + describe('analyzeSessionFileMetadata', () => { + it('should extract first message, count, ongoing state, and git branch in one pass', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:00.000Z', + gitBranch: 'feature/test', + message: { role: 'user', content: 'hello world' }, + isMeta: false, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'a1', + timestamp: '2026-01-01T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'thinking', thinking: 'thinking...' }], + }, + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const result = await analyzeSessionFileMetadata(filePath); + + expect(result.firstUserMessage?.text).toBe('hello world'); + expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z'); + expect(result.messageCount).toBe(1); + expect(result.isOngoing).toBe(true); + expect(result.gitBranch).toBe('feature/test'); + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + }); +}); diff --git a/test/main/utils/pathDecoder.test.ts b/test/main/utils/pathDecoder.test.ts new file mode 100644 index 00000000..d3e4d178 --- /dev/null +++ b/test/main/utils/pathDecoder.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildSessionPath, + buildSubagentsPath, + buildTodoPath, + decodePath, + encodePath, + extractProjectName, + extractSessionId, + getProjectsBasePath, + getTodosBasePath, + isValidEncodedPath, +} from '../../../src/main/utils/pathDecoder'; + +describe('pathDecoder', () => { + describe('encodePath', () => { + it('should encode a macOS-style absolute path', () => { + expect(encodePath('/Users/username/projectname')).toBe('-Users-username-projectname'); + }); + + it('should encode a Windows-style absolute path', () => { + expect(encodePath('C:\\Users\\username\\projectname')).toBe('-C:-Users-username-projectname'); + }); + + it('should handle empty string', () => { + expect(encodePath('')).toBe(''); + }); + + it('should round-trip with decodePath for POSIX paths', () => { + const original = '/Users/username/projectname'; + expect(decodePath(encodePath(original))).toBe(original); + }); + + it('should round-trip with decodePath for Windows paths', () => { + const original = 'C:/Users/username/projectname'; + expect(decodePath(encodePath(original))).toBe(original); + }); + + it('should encode a Linux-style path', () => { + expect(encodePath('/home/user/projects/myapp')).toBe('-home-user-projects-myapp'); + }); + }); + + describe('decodePath', () => { + it('should decode a simple encoded path', () => { + expect(decodePath('-Users-username-projectname')).toBe('/Users/username/projectname'); + }); + + it('should handle empty string', () => { + expect(decodePath('')).toBe(''); + }); + + it('should ensure leading slash for absolute paths', () => { + expect(decodePath('Users-username-projectname')).toBe('/Users/username/projectname'); + }); + + it('should decode path with multiple segments', () => { + expect(decodePath('-home-user-projects-myapp-src')).toBe('/home/user/projects/myapp/src'); + }); + + it('should handle single segment path', () => { + expect(decodePath('-project')).toBe('/project'); + }); + + it('should handle path with underscores', () => { + expect(decodePath('-Users-username-my_projectname')).toBe('/Users/username/my_projectname'); + }); + + it('should handle path with dots', () => { + expect(decodePath('-Users-username-.config')).toBe('/Users/username/.config'); + }); + + it('should decode Windows-style encoded path without adding leading slash', () => { + expect(decodePath('-C:-Users-username-projectname')).toBe('C:/Users/username/projectname'); + }); + }); + + describe('extractProjectName', () => { + it('should extract project name from encoded path', () => { + expect(extractProjectName('-Users-username-projectname')).toBe('projectname'); + }); + + it('should handle deeply nested paths', () => { + expect(extractProjectName('-home-user-dev-projects-appname')).toBe('appname'); + }); + + it('should return encoded name if decoding fails', () => { + expect(extractProjectName('')).toBe(''); + }); + + it('should handle single segment', () => { + expect(extractProjectName('-projectname')).toBe('projectname'); + }); + + it('should handle path with underscore in project name', () => { + expect(extractProjectName('-Users-username-my_cool_projectname')).toBe('my_cool_projectname'); + }); + }); + + describe('isValidEncodedPath', () => { + it('should return true for valid encoded path', () => { + expect(isValidEncodedPath('-Users-username-projectname')).toBe(true); + }); + + it('should return false for empty string', () => { + expect(isValidEncodedPath('')).toBe(false); + }); + + it('should return false for path without leading dash', () => { + expect(isValidEncodedPath('Users-username-projectname')).toBe(false); + }); + + it('should return true for path with underscores', () => { + expect(isValidEncodedPath('-Users-username-my_projectname')).toBe(true); + }); + + it('should return true for path with dots', () => { + expect(isValidEncodedPath('-Users-username-.config')).toBe(true); + }); + + it('should return true for path with numbers', () => { + expect(isValidEncodedPath('-Users-username-projectname123')).toBe(true); + }); + + it('should return true for path with spaces', () => { + expect(isValidEncodedPath('-Users-username-My Projectname')).toBe(true); + }); + + it('should return true for valid Windows-style encoded path', () => { + expect(isValidEncodedPath('-C:-Users-username-projectname')).toBe(true); + }); + + it('should return false for misplaced colons', () => { + expect(isValidEncodedPath('-Users-username:project')).toBe(false); + expect(isValidEncodedPath('-C:-Users-name-project:extra')).toBe(false); + }); + }); + + describe('extractSessionId', () => { + it('should extract session ID from JSONL filename', () => { + expect(extractSessionId('abc123.jsonl')).toBe('abc123'); + }); + + it('should handle UUID-style session IDs', () => { + expect(extractSessionId('550e8400-e29b-41d4-a716-446655440000.jsonl')).toBe( + '550e8400-e29b-41d4-a716-446655440000' + ); + }); + + it('should handle filename without extension', () => { + expect(extractSessionId('session123')).toBe('session123'); + }); + + it('should handle empty string', () => { + expect(extractSessionId('')).toBe(''); + }); + }); + + describe('buildSessionPath', () => { + it('should construct correct session path', () => { + expect(buildSessionPath('/base', 'project-id', 'session-123')).toBe( + '/base/project-id/session-123.jsonl' + ); + }); + + it('should handle paths with special characters', () => { + expect(buildSessionPath('/home/user/.claude/projects', '-Users-name', 'abc123')).toBe( + '/home/user/.claude/projects/-Users-name/abc123.jsonl' + ); + }); + }); + + describe('buildSubagentsPath', () => { + it('should construct correct subagents path', () => { + expect(buildSubagentsPath('/base', 'project-id', 'session-123')).toBe( + '/base/project-id/session-123/subagents' + ); + }); + }); + + describe('buildTodoPath', () => { + it('should construct correct todo path', () => { + expect(buildTodoPath('/home/user/.claude', 'session-123')).toBe( + '/home/user/.claude/todos/session-123.json' + ); + }); + }); + + describe('getProjectsBasePath', () => { + it('should return projects base path', () => { + expect(getProjectsBasePath()).toBe('/home/testuser/.claude/projects'); + }); + }); + + describe('getTodosBasePath', () => { + it('should return todos base path', () => { + expect(getTodosBasePath()).toBe('/home/testuser/.claude/todos'); + }); + }); +}); diff --git a/test/main/utils/pathValidation.test.ts b/test/main/utils/pathValidation.test.ts new file mode 100644 index 00000000..bb3e1ef9 --- /dev/null +++ b/test/main/utils/pathValidation.test.ts @@ -0,0 +1,292 @@ +/** + * Tests for path validation utilities. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +import { + isPathWithinAllowedDirectories, + validateFilePath, + validateOpenPath, +} from '../../../src/main/utils/pathValidation'; + +describe('pathValidation', () => { + const homeDir = os.homedir(); + const claudeDir = path.join(homeDir, '.claude'); + const testProjectPath = '/home/user/my-project'; + + describe('isPathWithinAllowedDirectories', () => { + it('should allow paths within ~/.claude', () => { + expect( + isPathWithinAllowedDirectories(path.join(claudeDir, 'projects', 'test.jsonl'), null) + ).toBe(true); + }); + + it('should allow paths within project directory', () => { + expect( + isPathWithinAllowedDirectories( + path.join(testProjectPath, 'src', 'index.ts'), + testProjectPath + ) + ).toBe(true); + }); + + it('should reject paths outside allowed directories', () => { + expect(isPathWithinAllowedDirectories('/etc/passwd', testProjectPath)).toBe(false); + }); + + it('should reject home directory itself without project context', () => { + expect(isPathWithinAllowedDirectories(homeDir, null)).toBe(false); + }); + + it('should allow exact ~/.claude path', () => { + expect(isPathWithinAllowedDirectories(claudeDir, null)).toBe(true); + }); + + it('should allow exact project path', () => { + expect(isPathWithinAllowedDirectories(testProjectPath, testProjectPath)).toBe(true); + }); + }); + + describe('validateFilePath', () => { + describe('basic validation', () => { + it('should reject empty path', () => { + const result = validateFilePath('', testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid file path'); + }); + + it('should reject relative paths', () => { + const result = validateFilePath('src/index.ts', testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Path must be absolute'); + }); + + it('should accept valid absolute paths within project', () => { + const result = validateFilePath( + path.join(testProjectPath, 'src', 'index.ts'), + testProjectPath + ); + expect(result.valid).toBe(true); + expect(result.normalizedPath).toBeDefined(); + }); + }); + + describe('sensitive file patterns', () => { + it('should reject ~/.ssh paths', () => { + const result = validateFilePath(path.join(homeDir, '.ssh', 'id_rsa'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject ~/.aws paths', () => { + const result = validateFilePath(path.join(homeDir, '.aws', 'credentials'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject .env files in project', () => { + const result = validateFilePath(path.join(testProjectPath, '.env'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject .env.local files', () => { + const result = validateFilePath(path.join(testProjectPath, '.env.local'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject credentials.json files', () => { + const result = validateFilePath( + path.join(testProjectPath, 'credentials.json'), + testProjectPath + ); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject .pem files', () => { + const result = validateFilePath(path.join(testProjectPath, 'server.pem'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject .key files', () => { + const result = validateFilePath(path.join(testProjectPath, 'private.key'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject ~/.kube/config', () => { + const result = validateFilePath(path.join(homeDir, '.kube', 'config'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject ~/.docker/config.json', () => { + const result = validateFilePath( + path.join(homeDir, '.docker', 'config.json'), + testProjectPath + ); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject secrets.json files', () => { + const result = validateFilePath( + path.join(testProjectPath, 'config', 'secrets.json'), + testProjectPath + ); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + }); + + describe('path traversal prevention', () => { + it('should handle normalized paths with ..', () => { + // This path resolves correctly but starts outside project + const result = validateFilePath( + '/home/user/my-project/../other-project/file.ts', + testProjectPath + ); + // Should be rejected because final path is outside project + expect(result.valid).toBe(false); + }); + + it('should reject symlink targets that escape project directory', () => { + if (process.platform === 'win32') { + // Symlink creation may require elevated privileges on Windows CI. + return; + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'path-validation-')); + const projectRoot = path.join(tempRoot, 'project'); + const outsideRoot = path.join(tempRoot, 'outside'); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + + const outsideFile = path.join(outsideRoot, 'secret.txt'); + fs.writeFileSync(outsideFile, 'secret', 'utf8'); + + const linkedPath = path.join(projectRoot, 'linked-secret.txt'); + fs.symlinkSync(outsideFile, linkedPath); + + const result = validateFilePath(linkedPath, projectRoot); + expect(result.valid).toBe(false); + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + }); + + describe('allowed paths', () => { + it('should allow regular source files in project', () => { + const result = validateFilePath( + path.join(testProjectPath, 'src', 'components', 'App.tsx'), + testProjectPath + ); + expect(result.valid).toBe(true); + }); + + it('should allow JSON config files (non-sensitive)', () => { + const result = validateFilePath( + path.join(testProjectPath, 'package.json'), + testProjectPath + ); + expect(result.valid).toBe(true); + }); + + it('should allow JSONL files in ~/.claude', () => { + const result = validateFilePath( + path.join(claudeDir, 'projects', '-home-user-project', 'session.jsonl'), + null + ); + expect(result.valid).toBe(true); + }); + }); + + describe('tilde expansion', () => { + it('should expand ~ to home directory for paths within ~/.claude', () => { + const result = validateFilePath('~/.claude/projects/test.jsonl', null); + expect(result.valid).toBe(true); + expect(result.normalizedPath).toBe(path.join(homeDir, '.claude', 'projects', 'test.jsonl')); + }); + + it('should expand ~ to home directory for project paths', () => { + const projectInHome = path.join(homeDir, 'my-project'); + const result = validateFilePath('~/my-project/src/index.ts', projectInHome); + expect(result.valid).toBe(true); + expect(result.normalizedPath).toBe(path.join(projectInHome, 'src', 'index.ts')); + }); + + it('should reject tilde paths to sensitive files', () => { + const result = validateFilePath('~/.ssh/id_rsa', testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Access to sensitive files is not allowed'); + }); + + it('should reject tilde paths outside allowed directories', () => { + const result = validateFilePath('~/random-dir/file.txt', testProjectPath); + expect(result.valid).toBe(false); + }); + }); + }); + + describe('validateOpenPath', () => { + it('should expand tilde in paths', () => { + const result = validateOpenPath('~/.claude', null); + expect(result.valid).toBe(true); + expect(result.normalizedPath).toBe(path.normalize(claudeDir)); + }); + + it('should reject sensitive files', () => { + const result = validateOpenPath(path.join(homeDir, '.ssh', 'id_rsa'), testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Cannot open sensitive files'); + }); + + it('should reject empty path', () => { + const result = validateOpenPath('', testProjectPath); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid path'); + }); + + it('should allow project directory', () => { + const result = validateOpenPath(testProjectPath, testProjectPath); + expect(result.valid).toBe(true); + }); + + it('should allow ~/.claude directory', () => { + const result = validateOpenPath(claudeDir, null); + expect(result.valid).toBe(true); + }); + + it('should reject paths outside allowed directories', () => { + const result = validateOpenPath('/etc', testProjectPath); + expect(result.valid).toBe(false); + }); + + it('should reject symlink paths that escape project directory', () => { + if (process.platform === 'win32') { + return; + } + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'open-path-validation-')); + const projectRoot = path.join(tempRoot, 'project'); + const outsideRoot = path.join(tempRoot, 'outside'); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(outsideRoot, { recursive: true }); + + const linkedDir = path.join(projectRoot, 'linked-outside'); + fs.symlinkSync(outsideRoot, linkedDir); + + const result = validateOpenPath(linkedDir, projectRoot); + expect(result.valid).toBe(false); + + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + }); +}); diff --git a/test/main/utils/regexValidation.test.ts b/test/main/utils/regexValidation.test.ts new file mode 100644 index 00000000..9778c3e1 --- /dev/null +++ b/test/main/utils/regexValidation.test.ts @@ -0,0 +1,146 @@ +/** + * Tests for regex validation utilities (ReDoS protection). + */ + +import { describe, expect, it } from 'vitest'; + +import { createSafeRegExp, validateRegexPattern } from '../../../src/main/utils/regexValidation'; + +describe('regexValidation', () => { + describe('validateRegexPattern', () => { + describe('basic validation', () => { + it('should reject empty pattern', () => { + const result = validateRegexPattern(''); + expect(result.valid).toBe(false); + expect(result.error).toContain('non-empty string'); + }); + + it('should accept valid simple patterns', () => { + expect(validateRegexPattern('hello')).toEqual({ valid: true }); + expect(validateRegexPattern('error')).toEqual({ valid: true }); + expect(validateRegexPattern('[a-z]+')).toEqual({ valid: true }); + }); + + it('should accept valid patterns with special chars', () => { + expect(validateRegexPattern('foo\\.bar')).toEqual({ valid: true }); + expect(validateRegexPattern('\\d+\\.\\d+')).toEqual({ valid: true }); + expect(validateRegexPattern('^test$')).toEqual({ valid: true }); + }); + }); + + describe('length validation', () => { + it('should reject patterns over 100 chars', () => { + const longPattern = 'a'.repeat(101); + const result = validateRegexPattern(longPattern); + expect(result.valid).toBe(false); + expect(result.error).toContain('too long'); + }); + + it('should accept patterns at 100 chars', () => { + const maxPattern = 'a'.repeat(100); + expect(validateRegexPattern(maxPattern).valid).toBe(true); + }); + }); + + describe('ReDoS protection', () => { + it('should reject nested quantifiers (a+)+', () => { + const result = validateRegexPattern('(a+)+'); + expect(result.valid).toBe(false); + expect(result.error).toContain('performance issues'); + }); + + it('should reject nested quantifiers (a*)+', () => { + const result = validateRegexPattern('(a*)+'); + expect(result.valid).toBe(false); + }); + + it('should reject nested quantifiers (a+)*', () => { + const result = validateRegexPattern('(a+)*'); + expect(result.valid).toBe(false); + }); + + it('should reject overlapping alternation with quantifiers', () => { + const result = validateRegexPattern('(a|a)+'); + expect(result.valid).toBe(false); + }); + + it('should reject backreferences with quantifiers', () => { + const result = validateRegexPattern('(.)\\1+'); + expect(result.valid).toBe(false); + }); + + it('should accept safe quantifier patterns', () => { + expect(validateRegexPattern('a+')).toEqual({ valid: true }); + expect(validateRegexPattern('a*b+')).toEqual({ valid: true }); + expect(validateRegexPattern('[a-z]+')).toEqual({ valid: true }); + expect(validateRegexPattern('\\d{1,3}')).toEqual({ valid: true }); + }); + }); + + describe('bracket balance', () => { + it('should reject unbalanced parentheses', () => { + const result = validateRegexPattern('(abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('unbalanced'); + }); + + it('should reject unbalanced brackets', () => { + const result = validateRegexPattern('[abc'); + expect(result.valid).toBe(false); + expect(result.error).toContain('unbalanced'); + }); + + it('should accept balanced patterns', () => { + expect(validateRegexPattern('(abc)')).toEqual({ valid: true }); + expect(validateRegexPattern('[a-z]')).toEqual({ valid: true }); + expect(validateRegexPattern('((a)(b))')).toEqual({ valid: true }); + }); + + it('should handle escaped brackets', () => { + expect(validateRegexPattern('\\(abc\\)')).toEqual({ valid: true }); + expect(validateRegexPattern('\\[test\\]')).toEqual({ valid: true }); + }); + }); + + describe('syntax validation', () => { + it('should reject invalid regex syntax', () => { + const result = validateRegexPattern('*invalid'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid regex syntax'); + }); + + it('should reject invalid quantifier syntax', () => { + // Note: 'a{abc}' is valid JS regex (matches 'a' followed by literal '{abc}') + // We test actual invalid syntax + const result = validateRegexPattern('a{2,1}'); // min > max is invalid + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid regex syntax'); + }); + }); + }); + + describe('createSafeRegExp', () => { + it('should return RegExp for valid pattern', () => { + const regex = createSafeRegExp('test'); + expect(regex).toBeInstanceOf(RegExp); + expect(regex?.test('test')).toBe(true); + }); + + it('should return null for invalid pattern', () => { + expect(createSafeRegExp('')).toBeNull(); + expect(createSafeRegExp('(a+)+')).toBeNull(); + expect(createSafeRegExp('*invalid')).toBeNull(); + }); + + it('should use default case-insensitive flag', () => { + const regex = createSafeRegExp('test'); + expect(regex?.flags).toContain('i'); + expect(regex?.test('TEST')).toBe(true); + }); + + it('should use provided flags', () => { + const regex = createSafeRegExp('test', 'g'); + expect(regex?.flags).toBe('g'); + }); + }); +}); diff --git a/test/main/utils/tokenizer.test.ts b/test/main/utils/tokenizer.test.ts new file mode 100644 index 00000000..f10c133e --- /dev/null +++ b/test/main/utils/tokenizer.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { countContentTokens, countTokens } from '../../../src/main/utils/tokenizer'; + +describe('tokenizer', () => { + describe('countTokens', () => { + it('should return 0 for empty string', () => { + expect(countTokens('')).toBe(0); + }); + + it('should return 0 for null', () => { + expect(countTokens(null)).toBe(0); + }); + + it('should return 0 for undefined', () => { + expect(countTokens(undefined)).toBe(0); + }); + + it('should estimate tokens by dividing length by 4', () => { + // 12 chars / 4 = 3 tokens + expect(countTokens('Hello World!')).toBe(3); + }); + + it('should ceil the result', () => { + // 5 chars / 4 = 1.25, ceil to 2 + expect(countTokens('Hello')).toBe(2); + }); + + it('should handle long text', () => { + const longText = 'a'.repeat(1000); + expect(countTokens(longText)).toBe(250); // 1000 / 4 + }); + + it('should handle single character', () => { + expect(countTokens('a')).toBe(1); // 1 / 4 = 0.25, ceil to 1 + }); + }); + + describe('countContentTokens', () => { + it('should handle string content', () => { + expect(countContentTokens('Hello World!')).toBe(3); + }); + + it('should handle array content by stringifying', () => { + const content = [{ type: 'text', text: 'Hello' }]; + const stringified = JSON.stringify(content); + expect(countContentTokens(content)).toBe(Math.ceil(stringified.length / 4)); + }); + + it('should return 0 for null', () => { + expect(countContentTokens(null)).toBe(0); + }); + + it('should return 0 for undefined', () => { + expect(countContentTokens(undefined)).toBe(0); + }); + + it('should handle empty array', () => { + const content: unknown[] = []; + expect(countContentTokens(content)).toBe(1); // "[]" is 2 chars, ceil(2/4) = 1 + }); + }); +}); diff --git a/test/mocks/electronAPI.ts b/test/mocks/electronAPI.ts new file mode 100644 index 00000000..f16e9390 --- /dev/null +++ b/test/mocks/electronAPI.ts @@ -0,0 +1,174 @@ +/** + * Mock for window.electronAPI used in tests. + * Provides typed mocks for all IPC methods. + */ + +import { vi } from 'vitest'; + +import type { Project, Session, SessionDetail } from '../../src/renderer/types/data'; + +export interface MockElectronAPI { + getProjects: ReturnType Promise>>; + getSessions: ReturnType Promise>>; + getSessionsPaginated: ReturnType< + typeof vi.fn< + ( + projectId: string, + cursor: string | null, + limit?: number, + options?: { includeTotalCount?: boolean; prefilterAll?: boolean } + ) => Promise<{ + sessions: Session[]; + nextCursor: string | null; + hasMore: boolean; + totalCount: number; + }> + > + >; + getSessionDetail: ReturnType< + typeof vi.fn<(projectId: string, sessionId: string) => Promise> + >; + getRepositoryGroups: ReturnType; + getWorktreeSessions: ReturnType; + getSubagentDetail: ReturnType; + searchSessions: ReturnType; + readClaudeMdFiles: ReturnType; + readDirectoryClaudeMd: ReturnType; + readMentionedFile: ReturnType; + validateMentions: ReturnType; + openPath: ReturnType; + openExternal: ReturnType; + notifications: { + onNew: ReturnType; + onUpdated: ReturnType; + getUnread: ReturnType; + markAsRead: ReturnType; + markAllAsRead: ReturnType; + // Methods used by notificationSlice + get: ReturnType; + markRead: ReturnType; + markAllRead: ReturnType; + delete: ReturnType; + clear: ReturnType; + }; + onFileChange: ReturnType; + onTodoChange: ReturnType; + config: { + get: ReturnType; + update: ReturnType; + addIgnoreRegex: ReturnType; + removeIgnoreRegex: ReturnType; + addIgnoreRepository: ReturnType; + removeIgnoreRepository: ReturnType; + snooze: ReturnType; + clearSnooze: ReturnType; + addTrigger: ReturnType; + updateTrigger: ReturnType; + removeTrigger: ReturnType; + getTriggers: ReturnType; + testTrigger: ReturnType; + selectFolders: ReturnType; + }; +} + +/** + * Create a fresh mock electronAPI instance. + */ +export function createMockElectronAPI(): MockElectronAPI { + return { + getProjects: vi.fn().mockResolvedValue([]), + getSessions: vi.fn().mockResolvedValue([]), + getSessionsPaginated: vi.fn().mockResolvedValue({ + sessions: [], + nextCursor: null, + hasMore: false, + totalCount: 0, + }), + getSessionDetail: vi.fn().mockResolvedValue(null), + getRepositoryGroups: vi.fn().mockResolvedValue([]), + getWorktreeSessions: vi.fn().mockResolvedValue([]), + getSubagentDetail: vi.fn().mockResolvedValue(null), + searchSessions: vi.fn().mockResolvedValue({ + results: [], + totalMatches: 0, + sessionsSearched: 0, + query: '', + }), + readClaudeMdFiles: vi.fn().mockResolvedValue({}), + readDirectoryClaudeMd: vi.fn().mockResolvedValue({ + path: '', + exists: false, + charCount: 0, + estimatedTokens: 0, + }), + readMentionedFile: vi.fn().mockResolvedValue(null), + validateMentions: vi.fn().mockResolvedValue({}), + openPath: vi.fn().mockResolvedValue({ success: true }), + openExternal: vi.fn().mockResolvedValue({ success: true }), + notifications: { + onNew: vi.fn().mockReturnValue(() => {}), + onUpdated: vi.fn().mockReturnValue(() => {}), + getUnread: vi.fn().mockResolvedValue([]), + markAsRead: vi.fn().mockResolvedValue(undefined), + markAllAsRead: vi.fn().mockResolvedValue(undefined), + // Methods used by notificationSlice + get: vi.fn().mockResolvedValue({ notifications: [] }), + markRead: vi.fn().mockResolvedValue(true), + markAllRead: vi.fn().mockResolvedValue(true), + delete: vi.fn().mockResolvedValue(true), + clear: vi.fn().mockResolvedValue(true), + }, + onFileChange: vi.fn().mockReturnValue(() => {}), + onTodoChange: vi.fn().mockReturnValue(() => {}), + config: { + get: vi.fn().mockResolvedValue({ + notifications: { + enabled: true, + soundEnabled: true, + ignoredRegex: [], + ignoredRepositories: [], + snoozedUntil: null, + snoozeMinutes: 30, + triggers: [], + }, + general: { + launchAtLogin: false, + showDockIcon: true, + theme: 'dark', + defaultTab: 'dashboard', + }, + display: { + showTimestamps: true, + compactMode: false, + syntaxHighlighting: true, + }, + }), + update: vi.fn(), + addIgnoreRegex: vi.fn(), + removeIgnoreRegex: vi.fn(), + addIgnoreRepository: vi.fn(), + removeIgnoreRepository: vi.fn(), + snooze: vi.fn(), + clearSnooze: vi.fn(), + addTrigger: vi.fn(), + updateTrigger: vi.fn(), + removeTrigger: vi.fn(), + getTriggers: vi.fn().mockResolvedValue([]), + testTrigger: vi.fn(), + selectFolders: vi.fn().mockResolvedValue([]), + }, + }; +} + +/** + * Install mock electronAPI on window object. + * Returns the mock instance for assertions. + */ +export function installMockElectronAPI(): MockElectronAPI { + const mock = createMockElectronAPI(); + vi.stubGlobal('window', { + ...window, + electronAPI: mock, + }); + return mock; +} diff --git a/test/renderer/hooks/navigationUtils.test.ts b/test/renderer/hooks/navigationUtils.test.ts new file mode 100644 index 00000000..c01505e4 --- /dev/null +++ b/test/renderer/hooks/navigationUtils.test.ts @@ -0,0 +1,68 @@ +/** + * Tests for navigation/utils.ts — specifically findAIGroupBySubagentId. + */ + +import { describe, expect, it } from 'vitest'; + +import { findAIGroupBySubagentId } from '@renderer/hooks/navigation/utils'; + +import type { ChatItem } from '@renderer/types/groups'; +import type { Process } from '@main/types'; + +/** Minimal AI chat item factory for testing. */ +function makeAIChatItem(groupId: string, processes: Partial[] = []): ChatItem { + return { + type: 'ai', + group: { + id: groupId, + startTime: new Date(0), + endTime: new Date(1000), + processes: processes.map((p) => ({ + id: p.id ?? 'unknown', + filePath: p.filePath ?? '', + messages: [], + startTime: new Date(0), + endTime: new Date(1000), + ...p, + })) as Process[], + }, + } as ChatItem; +} + +describe('findAIGroupBySubagentId', () => { + it('returns null for empty items', () => { + expect(findAIGroupBySubagentId([], 'agent-123')).toBeNull(); + }); + + it('returns null when no AI group contains the subagent', () => { + const items: ChatItem[] = [ + makeAIChatItem('ai-1', [{ id: 'agent-aaa' }]), + makeAIChatItem('ai-2', [{ id: 'agent-bbb' }]), + ]; + expect(findAIGroupBySubagentId(items, 'agent-ccc')).toBeNull(); + }); + + it('finds the AI group containing the subagent', () => { + const items: ChatItem[] = [ + makeAIChatItem('ai-1', [{ id: 'agent-aaa' }]), + makeAIChatItem('ai-2', [{ id: 'agent-bbb' }, { id: 'agent-ccc' }]), + ]; + expect(findAIGroupBySubagentId(items, 'agent-ccc')).toBe('ai-2'); + }); + + it('returns first match when subagent appears in multiple groups', () => { + const items: ChatItem[] = [ + makeAIChatItem('ai-1', [{ id: 'agent-same' }]), + makeAIChatItem('ai-2', [{ id: 'agent-same' }]), + ]; + expect(findAIGroupBySubagentId(items, 'agent-same')).toBe('ai-1'); + }); + + it('skips non-AI items', () => { + const items: ChatItem[] = [ + { type: 'user', group: { id: 'user-1' } } as ChatItem, + makeAIChatItem('ai-1', [{ id: 'agent-target' }]), + ]; + expect(findAIGroupBySubagentId(items, 'agent-target')).toBe('ai-1'); + }); +}); diff --git a/test/renderer/hooks/useAutoScrollBottom.test.ts b/test/renderer/hooks/useAutoScrollBottom.test.ts new file mode 100644 index 00000000..c8eae798 --- /dev/null +++ b/test/renderer/hooks/useAutoScrollBottom.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; + +import { isNearBottom } from '../../../src/renderer/hooks/useAutoScrollBottom'; + +describe('useAutoScrollBottom helpers', () => { + it('returns true when distance from bottom is within threshold', () => { + expect(isNearBottom(850, 1000, 100, 50)).toBe(true); + }); + + it('returns false when distance from bottom exceeds threshold', () => { + expect(isNearBottom(700, 1000, 100, 50)).toBe(false); + }); +}); diff --git a/test/renderer/hooks/useSearchContextNavigation.test.ts b/test/renderer/hooks/useSearchContextNavigation.test.ts new file mode 100644 index 00000000..351fe8c4 --- /dev/null +++ b/test/renderer/hooks/useSearchContextNavigation.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { findCurrentSearchResultInContainer } from '../../../src/renderer/hooks/navigation/utils'; + +describe('useSearchContextNavigation helpers', () => { + it('finds current search result only within the provided container', () => { + const activeContainer = document.createElement('div'); + activeContainer.innerHTML = ` +
    + `; + + const inactiveContainer = document.createElement('div'); + inactiveContainer.innerHTML = ` +
    + `; + + document.body.appendChild(inactiveContainer); + document.body.appendChild(activeContainer); + + const result = findCurrentSearchResultInContainer(activeContainer); + expect(result?.id).toBe('active-result'); + }); + + it('returns null when container is missing', () => { + expect(findCurrentSearchResultInContainer(null)).toBeNull(); + }); + + it('finds the exact current result using item identity metadata', () => { + const container = document.createElement('div'); + container.innerHTML = ` + + + `; + + const result = findCurrentSearchResultInContainer(container, 'ai-1', 1); + expect(result?.id).toBe('second'); + }); +}); diff --git a/test/renderer/hooks/useVisibleAIGroup.test.ts b/test/renderer/hooks/useVisibleAIGroup.test.ts new file mode 100644 index 00000000..f40725a9 --- /dev/null +++ b/test/renderer/hooks/useVisibleAIGroup.test.ts @@ -0,0 +1,54 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { act } from 'react-dom/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { useVisibleAIGroup } from '../../../src/renderer/hooks/useVisibleAIGroup'; + +class FakeIntersectionObserver { + constructor(_callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {} + + observe(): void {} + unobserve(): void {} + disconnect(): void {} + takeRecords(): IntersectionObserverEntry[] { + return []; + } +} + +describe('useVisibleAIGroup', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('uses provided rootRef as IntersectionObserver root', async () => { + const observerSpy = vi.fn((cb: IntersectionObserverCallback, opts?: IntersectionObserverInit) => + new FakeIntersectionObserver(cb, opts) + ); + + vi.stubGlobal('IntersectionObserver', observerSpy as unknown as typeof IntersectionObserver); + + const host = document.createElement('div'); + document.body.appendChild(host); + const rootEl = document.createElement('div'); + + function Harness(): React.JSX.Element { + const rootRef = React.useRef(rootEl); + useVisibleAIGroup({ onVisibleChange: () => undefined, rootRef }); + return React.createElement('div'); + } + + const root = createRoot(host); + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + }); + + expect(observerSpy).toHaveBeenCalled(); + const lastCall = observerSpy.mock.calls[observerSpy.mock.calls.length - 1]; + expect(lastCall?.[1]?.root).toBe(rootEl); + + root.unmount(); + }); +}); diff --git a/test/renderer/store/notificationSlice.test.ts b/test/renderer/store/notificationSlice.test.ts new file mode 100644 index 00000000..f96f06e0 --- /dev/null +++ b/test/renderer/store/notificationSlice.test.ts @@ -0,0 +1,450 @@ +/** + * Notification slice unit tests. + * Tests navigateToError behavior for sidebar session highlighting. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +import type { DetectedError } from '../../../src/renderer/types/data'; + +describe('notificationSlice', () => { + let store: TestStore; + let mockAPI: MockElectronAPI; + + beforeEach(() => { + vi.useFakeTimers(); + mockAPI = installMockElectronAPI(); + store = createTestStore(); + + // Mock crypto.randomUUID for predictable tab IDs + let uuidCounter = 0; + vi.stubGlobal('crypto', { + randomUUID: () => `test-uuid-${++uuidCounter}`, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('notification mutation fallbacks', () => { + it('re-fetches notifications when markRead returns false', async () => { + store.setState({ + notifications: [ + { + id: 'n1', + message: 'msg', + isRead: false, + }, + ] as never[], + }); + + mockAPI.notifications.markRead.mockResolvedValue(false); + mockAPI.notifications.get.mockResolvedValue({ + notifications: [{ id: 'n1', message: 'msg', isRead: false }], + }); + + await store.getState().markNotificationRead('n1'); + + expect(mockAPI.notifications.get).toHaveBeenCalled(); + }); + + it('re-fetches notifications when clear returns false', async () => { + store.setState({ + notifications: [{ id: 'n1', message: 'msg', isRead: true }] as never[], + }); + + mockAPI.notifications.clear.mockResolvedValue(false); + mockAPI.notifications.get.mockResolvedValue({ + notifications: [{ id: 'n1', message: 'msg', isRead: true }], + }); + + await store.getState().clearNotifications(); + + expect(mockAPI.notifications.get).toHaveBeenCalled(); + }); + }); + + describe('navigateToError', () => { + const createMockError = (overrides?: Partial): DetectedError => ({ + id: 'error-1', + sessionId: 'session-target', + projectId: 'project-1', + lineNumber: 42, + timestamp: Date.now(), + toolUseId: 'tool-1', + triggerName: 'test-trigger', + severity: 'error', + message: 'Test error message', + isRead: false, + ...overrides, + }); + + describe('flat mode (viewMode !== grouped)', () => { + beforeEach(() => { + store.setState({ + viewMode: 'flat', + projects: [ + { + id: 'project-1', + name: 'Project 1', + path: '/path/1', + sessions: ['session-1', 'session-target'], + }, + ] as never[], + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 1, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-target' }, + chunks: [], + } as never); + }); + + it('should set selectedSessionId when navigating to error', () => { + const error = createMockError(); + + store.getState().navigateToError(error); + + // selectedSessionId should be set to the target session + expect(store.getState().selectedSessionId).toBe('session-target'); + }); + + it('should create new tab with correct sessionId and pendingNavigation', () => { + const error = createMockError(); + + store.getState().navigateToError(error); + + expect(store.getState().openTabs).toHaveLength(1); + expect(store.getState().openTabs[0].sessionId).toBe('session-target'); + expect(store.getState().openTabs[0].projectId).toBe('project-1'); + expect(store.getState().openTabs[0].pendingNavigation?.kind).toBe('error'); + }); + + it('should set selectedSessionId even when switching from different project', () => { + // Start with a different project selected + store.setState({ + selectedProjectId: 'project-other', + selectedSessionId: 'session-other', + }); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // Should update to target session + expect(store.getState().selectedSessionId).toBe('session-target'); + expect(store.getState().selectedProjectId).toBe('project-1'); + }); + + it('should not highlight wrong session from previous tab state', () => { + // Setup: Have an old session selected + store.setState({ + selectedProjectId: 'project-1', + selectedSessionId: 'session-old', + }); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // Should NOT retain old session, should be updated to target + expect(store.getState().selectedSessionId).not.toBe('session-old'); + expect(store.getState().selectedSessionId).toBe('session-target'); + }); + }); + + describe('grouped mode (viewMode === grouped)', () => { + beforeEach(() => { + store.setState({ + viewMode: 'grouped', + repositoryGroups: [ + { + id: 'repo-1', + name: 'Repo 1', + worktrees: [ + { + id: 'project-1', + name: 'Worktree 1', + path: '/path/1', + sessions: ['session-1', 'session-target'], + }, + ], + }, + ] as never[], + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 1, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-target' }, + chunks: [], + } as never); + }); + + it('should set selectedSessionId when navigating to error in grouped mode', () => { + const error = createMockError(); + + store.getState().navigateToError(error); + + // selectedSessionId should be set to the target session + expect(store.getState().selectedSessionId).toBe('session-target'); + }); + + it('should set repository and worktree selection', () => { + const error = createMockError(); + + store.getState().navigateToError(error); + + expect(store.getState().selectedRepositoryId).toBe('repo-1'); + expect(store.getState().selectedWorktreeId).toBe('project-1'); + }); + + it('should not highlight wrong session from previous state in grouped mode', () => { + // Setup: Have an old session selected + store.setState({ + selectedRepositoryId: 'repo-1', + selectedWorktreeId: 'project-1', + selectedSessionId: 'session-old', + }); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // Should NOT retain old session + expect(store.getState().selectedSessionId).not.toBe('session-old'); + expect(store.getState().selectedSessionId).toBe('session-target'); + }); + }); + + describe('existing tab behavior', () => { + it('should focus existing tab if session is already open', () => { + // Open target session tab first + store.getState().openTab({ + type: 'session', + sessionId: 'session-target', + projectId: 'project-1', + label: 'Target Session', + }); + const existingTabId = store.getState().activeTabId; + + // Open another tab + store.getState().openDashboard(); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // Should focus existing tab, not create new + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().activeTabId).toBe(existingTabId); + }); + + it('should enqueue error navigation request on existing tab', () => { + // Open target session tab first + store.getState().openTab({ + type: 'session', + sessionId: 'session-target', + projectId: 'project-1', + label: 'Target Session', + }); + + const error = createMockError({ + lineNumber: 100, + }); + + store.getState().navigateToError(error); + + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation).toBeDefined(); + expect(tab.pendingNavigation?.kind).toBe('error'); + expect(tab.pendingNavigation?.highlight).toBe('red'); + expect(tab.pendingNavigation?.payload).toMatchObject({ + errorId: 'error-1', + lineNumber: 100, + toolUseId: 'tool-1', + }); + }); + + it('should create new nonce on repeated clicks', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-target', + projectId: 'project-1', + label: 'Target Session', + }); + + const error = createMockError(); + + store.getState().navigateToError(error); + const firstId = store.getState().openTabs[0].pendingNavigation?.id; + + store.getState().navigateToError(error); + const secondId = store.getState().openTabs[0].pendingNavigation?.id; + + expect(firstId).toBeDefined(); + expect(secondId).toBeDefined(); + expect(firstId).not.toBe(secondId); + }); + }); + + describe('sidebar highlighting with pagination', () => { + /** + * Test scenario: Session exists but is not in the first page (pagination). + * + * The sidebar only renders sessions that are in the `sessions` array. + * If selectedSessionId is set to a session not in the loaded list, + * nothing will be highlighted (correct behavior). + * + * The fix ensures selectedSessionId is always set to the target session, + * rather than retaining a stale value that might match a loaded session. + */ + it('should set selectedSessionId to target even if not in loaded sessions list', () => { + store.setState({ + viewMode: 'flat', + projects: [ + { + id: 'project-1', + name: 'Project 1', + path: '/path/1', + sessions: ['session-1', 'session-target'], + }, + ] as never[], + // Simulating: first page loaded, target session not included + sessions: [{ id: 'session-1', createdAt: '2024-01-15' }] as never[], + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }] as never[], + nextCursor: 'cursor-1', + hasMore: true, + totalCount: 100, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-target' }, + chunks: [], + } as never); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // selectedSessionId should be set to target, even if not in loaded sessions + expect(store.getState().selectedSessionId).toBe('session-target'); + + // Verify the session is NOT in the current loaded list (simulating pagination) + const loadedSessionIds = store.getState().sessions.map((s) => s.id); + expect(loadedSessionIds).not.toContain('session-target'); + + // Sidebar behavior: isActive = selectedSessionId === item.session.id + // Since 'session-target' is not in sessions array, it won't be rendered + // and therefore won't be highlighted. Only 'session-1' is rendered, + // but selectedSessionId doesn't match it, so nothing is highlighted. + // This is the correct behavior. + }); + + it('should correctly highlight when target session IS in loaded list', async () => { + store.setState({ + viewMode: 'flat', + projects: [ + { + id: 'project-1', + name: 'Project 1', + path: '/path/1', + sessions: ['session-1', 'session-target'], + }, + ] as never[], + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }, { id: 'session-target' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 2, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-target' }, + chunks: [], + } as never); + + const error = createMockError(); + + store.getState().navigateToError(error); + + // selectedSessionId should match target immediately + expect(store.getState().selectedSessionId).toBe('session-target'); + + // Wait for async fetch to complete + await vi.runAllTimersAsync(); + + // Verify the session IS in the loaded list after fetch + const loadedSessionIds = store.getState().sessions.map((s) => s.id); + expect(loadedSessionIds).toContain('session-target'); + + // Sidebar behavior: isActive = selectedSessionId === item.session.id + // Since 'session-target' is in sessions array and selectedSessionId matches, + // it will be highlighted correctly. + }); + + it('should not highlight unrelated session when target is not loaded', () => { + store.setState({ + viewMode: 'flat', + projects: [ + { + id: 'project-1', + name: 'Project 1', + path: '/path/1', + sessions: ['session-1', 'session-target'], + }, + ] as never[], + // Only session-1 is loaded, and it was previously selected + sessions: [{ id: 'session-1', createdAt: '2024-01-15' }] as never[], + selectedSessionId: 'session-1', // Previous selection that might cause wrong highlight + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }] as never[], + nextCursor: 'cursor-1', + hasMore: true, + totalCount: 100, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-target' }, + chunks: [], + } as never); + + const error = createMockError(); + + // Before fix: selectedSessionId would remain 'session-1' (from selectProject reset) + // causing session-1 to be highlighted incorrectly + + store.getState().navigateToError(error); + + // After fix: selectedSessionId is updated to 'session-target' + expect(store.getState().selectedSessionId).toBe('session-target'); + // Since 'session-target' is not in sessions array, nothing will be highlighted + // (session-1 is in the array but doesn't match selectedSessionId anymore) + }); + }); + }); +}); diff --git a/test/renderer/store/paneSlice.test.ts b/test/renderer/store/paneSlice.test.ts new file mode 100644 index 00000000..110a2c0f --- /dev/null +++ b/test/renderer/store/paneSlice.test.ts @@ -0,0 +1,487 @@ +/** + * Tests for the paneSlice - multi-pane split layout state management. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { MAX_PANES } from '../../../src/renderer/types/panes'; + +import { createTestStore } from './storeTestUtils'; + +import type { TestStore } from './storeTestUtils'; + +let store: TestStore; + +beforeEach(() => { + store = createTestStore(); +}); + +describe('paneSlice', () => { + describe('initial state', () => { + it('starts with a single default pane', () => { + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(1); + expect(paneLayout.panes[0].id).toBe('pane-default'); + expect(paneLayout.panes[0].widthFraction).toBe(1); + expect(paneLayout.panes[0].tabs).toEqual([]); + expect(paneLayout.focusedPaneId).toBe('pane-default'); + }); + }); + + describe('focusPane', () => { + it('changes focusedPaneId', () => { + const state = store.getState(); + // Open a tab first and split to create a second pane + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(2); + + // New pane should be focused after split + const newPaneId = paneLayout.focusedPaneId; + expect(newPaneId).not.toBe('pane-default'); + + // Focus back to default pane + store.getState().focusPane('pane-default'); + expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default'); + }); + + it('no-ops when already focused', () => { + const before = store.getState().paneLayout; + store.getState().focusPane('pane-default'); + expect(store.getState().paneLayout).toBe(before); + }); + + it('no-ops for non-existent pane', () => { + const before = store.getState().paneLayout; + store.getState().focusPane('non-existent'); + expect(store.getState().paneLayout).toBe(before); + }); + }); + + describe('splitPane', () => { + it('creates a new pane with the specified tab to the right', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tabs = store.getState().paneLayout.panes[0].tabs; + expect(tabs).toHaveLength(2); + const tab1Id = tabs[0].id; + + state.splitPane('pane-default', tab1Id, 'right'); + + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(2); + + // Source pane should have lost the tab + const sourcePane = paneLayout.panes.find((p) => p.id === 'pane-default'); + expect(sourcePane?.tabs).toHaveLength(1); + expect(sourcePane?.tabs[0].sessionId).toBe('s2'); + + // New pane should have the split tab + const newPane = paneLayout.panes.find((p) => p.id !== 'pane-default'); + expect(newPane?.tabs).toHaveLength(1); + expect(newPane?.tabs[0].sessionId).toBe('s1'); + + // New pane should be focused + expect(paneLayout.focusedPaneId).toBe(newPane?.id); + + // Widths should be equal + expect(sourcePane?.widthFraction).toBeCloseTo(0.5); + expect(newPane?.widthFraction).toBeCloseTo(0.5); + }); + + it('creates a new pane to the left', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab2Id = store.getState().paneLayout.panes[0].tabs[1].id; + state.splitPane('pane-default', tab2Id, 'left'); + + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(2); + + // New pane should be to the left (index 0) + const leftPane = paneLayout.panes[0]; + expect(leftPane.tabs[0].sessionId).toBe('s2'); + expect(leftPane.id).toBe(paneLayout.focusedPaneId); + }); + + it('does not exceed MAX_PANES', () => { + const state = store.getState(); + // Create MAX_PANES tabs + for (let i = 0; i < MAX_PANES + 1; i++) { + state.openTab({ + type: 'session', + sessionId: `s${i}`, + projectId: 'p1', + label: `Session ${i}`, + }); + } + + // Split until we reach MAX_PANES + for (let i = 0; i < MAX_PANES - 1; i++) { + const currentState = store.getState(); + const focusedPane = currentState.paneLayout.panes.find( + (p) => p.id === currentState.paneLayout.focusedPaneId + ); + if (focusedPane && focusedPane.tabs.length > 1) { + currentState.splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right'); + } + } + + const paneCount = store.getState().paneLayout.panes.length; + expect(paneCount).toBeLessThanOrEqual(MAX_PANES); + + // Attempting to split again should be no-op if at MAX_PANES + if (paneCount === MAX_PANES) { + const beforeLayout = store.getState().paneLayout; + const focusedPane = beforeLayout.panes.find((p) => p.id === beforeLayout.focusedPaneId); + if (focusedPane && focusedPane.tabs.length > 0) { + store.getState().splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right'); + expect(store.getState().paneLayout.panes).toHaveLength(MAX_PANES); + } + } + }); + }); + + describe('closePane', () => { + it('removes a pane and redistributes width', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')?.id; + expect(newPaneId).toBeDefined(); + + store.getState().closePane(newPaneId!); + + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(1); + expect(paneLayout.panes[0].widthFraction).toBe(1); + }); + + it('cannot close the last pane', () => { + store.getState().closePane('pane-default'); + expect(store.getState().paneLayout.panes).toHaveLength(1); + }); + + it('shifts focus to neighbor when closing focused pane', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + // New pane is focused + const focusedId = store.getState().paneLayout.focusedPaneId; + expect(focusedId).not.toBe('pane-default'); + + // Close the focused pane + store.getState().closePane(focusedId); + + // Focus should shift to remaining pane + expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default'); + }); + }); + + describe('moveTabToPane', () => { + it('moves a tab from one pane to another', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + state.openTab({ type: 'session', sessionId: 's3', projectId: 'p1', label: 'Session 3' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + const panes = store.getState().paneLayout.panes; + const newPaneId = panes.find((p) => p.id !== 'pane-default')!.id; + + // Move s2 from pane-default to the new pane + const tab2Id = panes.find((p) => p.id === 'pane-default')!.tabs[0].id; + store.getState().moveTabToPane(tab2Id, 'pane-default', newPaneId); + + const updatedPanes = store.getState().paneLayout.panes; + const sourcePane = updatedPanes.find((p) => p.id === 'pane-default')!; + const targetPane = updatedPanes.find((p) => p.id === newPaneId)!; + + expect(sourcePane.tabs).toHaveLength(1); // s3 left + expect(targetPane.tabs).toHaveLength(2); // s1 + s2 + }); + + it('auto-closes source pane when last tab is moved out', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + // Now pane-default has s2, new pane has s1 + const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')!.id; + + // Move s2 (last tab in pane-default) to new pane + const tab2Id = store.getState().paneLayout.panes.find((p) => p.id === 'pane-default')!.tabs[0] + .id; + store.getState().moveTabToPane(tab2Id, 'pane-default', newPaneId); + + // pane-default should be auto-closed + const panes = store.getState().paneLayout.panes; + expect(panes).toHaveLength(1); + expect(panes[0].id).toBe(newPaneId); + expect(panes[0].tabs).toHaveLength(2); + }); + + it('no-ops when source and target are the same', () => { + store.getState().openTab({ + type: 'session', + sessionId: 's1', + projectId: 'p1', + label: 'Session 1', + }); + const tabId = store.getState().paneLayout.panes[0].tabs[0].id; + const before = store.getState().paneLayout; + store.getState().moveTabToPane(tabId, 'pane-default', 'pane-default'); + expect(store.getState().paneLayout).toBe(before); + }); + }); + + describe('reorderTabInPane', () => { + it('reorders tabs within a pane', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + state.openTab({ type: 'session', sessionId: 's3', projectId: 'p1', label: 'Session 3' }); + + const tabs = store.getState().paneLayout.panes[0].tabs; + expect(tabs[0].sessionId).toBe('s1'); + expect(tabs[2].sessionId).toBe('s3'); + + // Move first tab to last position + store.getState().reorderTabInPane('pane-default', 0, 2); + + const reordered = store.getState().paneLayout.panes[0].tabs; + expect(reordered[0].sessionId).toBe('s2'); + expect(reordered[1].sessionId).toBe('s3'); + expect(reordered[2].sessionId).toBe('s1'); + }); + + it('no-ops for same index', () => { + store.getState().openTab({ + type: 'session', + sessionId: 's1', + projectId: 'p1', + label: 'Session 1', + }); + const before = store.getState().paneLayout; + store.getState().reorderTabInPane('pane-default', 0, 0); + expect(store.getState().paneLayout).toBe(before); + }); + + it('no-ops for out-of-bounds index', () => { + store.getState().openTab({ + type: 'session', + sessionId: 's1', + projectId: 'p1', + label: 'Session 1', + }); + const before = store.getState().paneLayout; + store.getState().reorderTabInPane('pane-default', 0, 5); + expect(store.getState().paneLayout).toBe(before); + }); + }); + + describe('resizePanes', () => { + it('adjusts width fractions of adjacent panes', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + // Resize pane-default to 60% + store.getState().resizePanes('pane-default', 0.6); + + const panes = store.getState().paneLayout.panes; + const defaultPane = panes.find((p) => p.id === 'pane-default')!; + const otherPane = panes.find((p) => p.id !== 'pane-default')!; + + expect(defaultPane.widthFraction).toBeCloseTo(0.6); + expect(otherPane.widthFraction).toBeCloseTo(0.4); + }); + + it('clamps to minimum fraction', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + // Try to make pane-default almost 100% (leaving too little for neighbor) + store.getState().resizePanes('pane-default', 0.95); + + const panes = store.getState().paneLayout.panes; + for (const pane of panes) { + expect(pane.widthFraction).toBeGreaterThanOrEqual(0.1); + } + }); + }); + + describe('getPaneForTab', () => { + it('returns the pane ID containing the tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 's1', + projectId: 'p1', + label: 'Session 1', + }); + const tabId = store.getState().paneLayout.panes[0].tabs[0].id; + expect(store.getState().getPaneForTab(tabId)).toBe('pane-default'); + }); + + it('returns null for non-existent tab', () => { + expect(store.getState().getPaneForTab('non-existent')).toBeNull(); + }); + }); + + describe('getAllPaneTabs', () => { + it('returns all tabs across all panes', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + const allTabs = store.getState().getAllPaneTabs(); + expect(allTabs).toHaveLength(2); + const sessionIds = allTabs.map((t) => t.sessionId); + expect(sessionIds).toContain('s1'); + expect(sessionIds).toContain('s2'); + }); + }); + + describe('moveTabToNewPane', () => { + it('creates a new pane and moves the tab there', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.moveTabToNewPane(tab1Id, 'pane-default', 'pane-default', 'right'); + + const { paneLayout } = store.getState(); + expect(paneLayout.panes).toHaveLength(2); + + const sourcePane = paneLayout.panes.find((p) => p.id === 'pane-default')!; + const newPane = paneLayout.panes.find((p) => p.id !== 'pane-default')!; + + expect(sourcePane.tabs).toHaveLength(1); + expect(sourcePane.tabs[0].sessionId).toBe('s2'); + expect(newPane.tabs).toHaveLength(1); + expect(newPane.tabs[0].sessionId).toBe('s1'); + }); + + it('respects MAX_PANES limit', () => { + const state = store.getState(); + for (let i = 0; i < MAX_PANES + 1; i++) { + state.openTab({ + type: 'session', + sessionId: `s${i}`, + projectId: 'p1', + label: `Session ${i}`, + }); + } + + // Split until MAX_PANES + for (let i = 0; i < MAX_PANES - 1; i++) { + const currentState = store.getState(); + const focusedPane = currentState.paneLayout.panes.find( + (p) => p.id === currentState.paneLayout.focusedPaneId + ); + if (focusedPane && focusedPane.tabs.length > 1) { + currentState.splitPane(focusedPane.id, focusedPane.tabs[0].id, 'right'); + } + } + + const paneCountBefore = store.getState().paneLayout.panes.length; + if (paneCountBefore >= MAX_PANES) { + // Attempt should be no-op + const focusedPane = store + .getState() + .paneLayout.panes.find((p) => p.id === store.getState().paneLayout.focusedPaneId); + if (focusedPane && focusedPane.tabs.length > 0) { + store + .getState() + .moveTabToNewPane(focusedPane.tabs[0].id, focusedPane.id, focusedPane.id, 'right'); + expect(store.getState().paneLayout.panes.length).toBe(paneCountBefore); + } + } + }); + }); + + describe('integration with tabSlice', () => { + it('openTab adds to focused pane', () => { + store.getState().openTab({ + type: 'session', + sessionId: 's1', + projectId: 'p1', + label: 'Session 1', + }); + + const pane = store.getState().paneLayout.panes[0]; + expect(pane.tabs).toHaveLength(1); + expect(pane.tabs[0].sessionId).toBe('s1'); + + // Root-level state should be synced + expect(store.getState().openTabs).toHaveLength(1); + expect(store.getState().activeTabId).toBe(pane.tabs[0].id); + }); + + it('closeTab removes from the containing pane', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + const tabToClose = store.getState().paneLayout.panes[0].tabs[0].id; + store.getState().closeTab(tabToClose); + + const pane = store.getState().paneLayout.panes[0]; + expect(pane.tabs).toHaveLength(1); + expect(pane.tabs[0].sessionId).toBe('s2'); + }); + + it('setActiveTab focuses the pane containing the tab', () => { + const state = store.getState(); + state.openTab({ type: 'session', sessionId: 's1', projectId: 'p1', label: 'Session 1' }); + state.openTab({ type: 'session', sessionId: 's2', projectId: 'p1', label: 'Session 2' }); + + // Split to create two panes + const tab1Id = store.getState().paneLayout.panes[0].tabs[0].id; + state.splitPane('pane-default', tab1Id, 'right'); + + // Now s2 is in pane-default, s1 is in new pane + // Focus pane-default + store.getState().focusPane('pane-default'); + expect(store.getState().paneLayout.focusedPaneId).toBe('pane-default'); + + // Set active tab to s1 (in other pane) - should focus that pane + store.getState().setActiveTab(tab1Id); + const newPaneId = store.getState().paneLayout.panes.find((p) => p.id !== 'pane-default')?.id; + expect(store.getState().paneLayout.focusedPaneId).toBe(newPaneId); + }); + }); +}); diff --git a/test/renderer/store/pathResolution.test.ts b/test/renderer/store/pathResolution.test.ts new file mode 100644 index 00000000..19de8fad --- /dev/null +++ b/test/renderer/store/pathResolution.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveFilePath } from '../../../src/renderer/store/utils/pathResolution'; + +describe('resolveFilePath', () => { + it('returns unix absolute paths as-is', () => { + expect(resolveFilePath('/repo', '/repo/src/index.ts')).toBe('/repo/src/index.ts'); + }); + + it('returns windows absolute paths as-is', () => { + expect(resolveFilePath('C:\\repo', 'C:\\repo\\src\\index.ts')).toBe('C:\\repo\\src\\index.ts'); + }); + + it('resolves dot-prefixed relative paths', () => { + expect(resolveFilePath('/repo', './src/app.ts')).toBe('/repo/src/app.ts'); + }); + + it('resolves parent relative paths on unix', () => { + expect(resolveFilePath('/repo/apps/web', '../shared/file.ts')).toBe( + '/repo/apps/shared/file.ts' + ); + }); + + it('resolves parent relative paths on windows', () => { + expect(resolveFilePath('C:\\repo\\apps\\web', '..\\shared\\file.ts')).toBe( + 'C:\\repo\\apps\\shared\\file.ts' + ); + }); + + it('passes through tilde paths as-is', () => { + expect(resolveFilePath('/repo', '~/some/directory')).toBe('~/some/directory'); + }); + + it('passes through tilde paths with @ prefix as-is', () => { + expect(resolveFilePath('/repo', '@~/some/file.ts')).toBe('~/some/file.ts'); + }); + + it('passes through bare tilde as-is', () => { + expect(resolveFilePath('/repo', '~')).toBe('~'); + }); + + it('passes through tilde paths with backslash separator (Windows)', () => { + expect(resolveFilePath('C:\\repo', '~\\.claude\\agents\\file.md')).toBe( + '~\\.claude\\agents\\file.md' + ); + }); + + it('does not treat tilde in the middle as special', () => { + expect(resolveFilePath('/repo', 'foo~/bar')).toBe('/repo/foo~/bar'); + }); +}); diff --git a/test/renderer/store/sessionSlice.test.ts b/test/renderer/store/sessionSlice.test.ts new file mode 100644 index 00000000..3389d947 --- /dev/null +++ b/test/renderer/store/sessionSlice.test.ts @@ -0,0 +1,314 @@ +/** + * Session slice unit tests. + * Tests session state management including fetching, pagination, and selection. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +describe('sessionSlice', () => { + let store: TestStore; + let mockAPI: MockElectronAPI; + + beforeEach(() => { + mockAPI = installMockElectronAPI(); + store = createTestStore(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('fetchSessionsInitial', () => { + it('should fetch first page of sessions', async () => { + const mockSessions = [ + { id: 'session-1', createdAt: '2024-01-15T10:00:00Z' }, + { id: 'session-2', createdAt: '2024-01-14T10:00:00Z' }, + ]; + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: mockSessions as never[], + nextCursor: 'cursor-1', + hasMore: true, + totalCount: 50, + }); + + await store.getState().fetchSessionsInitial('project-1'); + + expect(mockAPI.getSessionsPaginated).toHaveBeenCalledWith('project-1', null, 20, { + includeTotalCount: false, + prefilterAll: false, + }); + expect(store.getState().sessions).toHaveLength(2); + expect(store.getState().sessionsCursor).toBe('cursor-1'); + expect(store.getState().sessionsHasMore).toBe(true); + expect(store.getState().sessionsTotalCount).toBe(50); + expect(store.getState().sessionsLoading).toBe(false); + }); + + it('should set loading state during fetch', async () => { + mockAPI.getSessionsPaginated.mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + sessions: [], + nextCursor: null, + hasMore: false, + totalCount: 0, + }), + 100 + ); + }) + ); + + const fetchPromise = store.getState().fetchSessionsInitial('project-1'); + expect(store.getState().sessionsLoading).toBe(true); + + vi.useFakeTimers(); + vi.advanceTimersByTime(100); + await fetchPromise; + vi.useRealTimers(); + + expect(store.getState().sessionsLoading).toBe(false); + }); + + it('should handle fetch error', async () => { + mockAPI.getSessionsPaginated.mockRejectedValue(new Error('Network error')); + + await store.getState().fetchSessionsInitial('project-1'); + + expect(store.getState().sessionsError).toBe('Network error'); + expect(store.getState().sessionsLoading).toBe(false); + }); + }); + + describe('fetchSessionsMore', () => { + it('should append sessions to existing list', async () => { + // Setup initial state + store.setState({ + selectedProjectId: 'project-1', + sessions: [{ id: 'session-1' }] as never[], + sessionsCursor: 'cursor-1', + sessionsHasMore: true, + sessionsLoadingMore: false, + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-2' }] as never[], + nextCursor: 'cursor-2', + hasMore: true, + totalCount: 50, + }); + + await store.getState().fetchSessionsMore(); + + expect(store.getState().sessions).toHaveLength(2); + expect(store.getState().sessionsCursor).toBe('cursor-2'); + }); + + it('should not fetch if no more pages', async () => { + store.setState({ + selectedProjectId: 'project-1', + sessionsHasMore: false, + sessionsCursor: null, + }); + + await store.getState().fetchSessionsMore(); + + expect(mockAPI.getSessionsPaginated).not.toHaveBeenCalled(); + }); + + it('should not fetch if already loading', async () => { + store.setState({ + selectedProjectId: 'project-1', + sessionsHasMore: true, + sessionsCursor: 'cursor-1', + sessionsLoadingMore: true, + }); + + await store.getState().fetchSessionsMore(); + + expect(mockAPI.getSessionsPaginated).not.toHaveBeenCalled(); + }); + }); + + describe('selectSession', () => { + it('should update selected session ID', () => { + store.setState({ + selectedProjectId: 'project-1', + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-1' }, + chunks: [], + } as never); + + store.getState().selectSession('session-1'); + + expect(store.getState().selectedSessionId).toBe('session-1'); + }); + + it('should clear previous session detail', () => { + store.setState({ + selectedProjectId: 'project-1', + sessionDetail: { session: { id: 'old-session' } } as never, + sessionContextStats: new Map() as never, + }); + + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-2' }, + chunks: [], + } as never); + + store.getState().selectSession('session-2'); + + expect(store.getState().sessionDetail).toBeNull(); + expect(store.getState().sessionContextStats).toBeNull(); + }); + }); + + describe('clearSelection', () => { + it('should clear all selection state', () => { + store.setState({ + selectedProjectId: 'project-1', + selectedSessionId: 'session-1', + sessions: [{ id: 'session-1' }] as never[], + sessionDetail: { session: { id: 'session-1' } } as never, + }); + + store.getState().clearSelection(); + + expect(store.getState().selectedProjectId).toBeNull(); + expect(store.getState().selectedSessionId).toBeNull(); + expect(store.getState().sessions).toHaveLength(0); + expect(store.getState().sessionDetail).toBeNull(); + }); + }); + + describe('refreshSessionsInPlace', () => { + it('should refresh sessions without loading state', async () => { + store.setState({ + selectedProjectId: 'project-1', + sessions: [{ id: 'session-1' }] as never[], + sessionsLoading: false, + }); + + mockAPI.getSessionsPaginated.mockResolvedValue({ + sessions: [{ id: 'session-1' }, { id: 'session-2' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 2, + }); + + await store.getState().refreshSessionsInPlace('project-1'); + + expect(store.getState().sessions).toHaveLength(2); + expect(mockAPI.getSessionsPaginated).toHaveBeenCalledWith('project-1', null, 20, { + includeTotalCount: false, + prefilterAll: false, + }); + // Should not have set loading state + expect(store.getState().sessionsLoading).toBe(false); + }); + + it('should skip refresh if different project selected', async () => { + store.setState({ + selectedProjectId: 'project-1', + }); + + await store.getState().refreshSessionsInPlace('project-2'); + + expect(mockAPI.getSessionsPaginated).not.toHaveBeenCalled(); + }); + + it('should ignore stale refresh responses and keep latest result', async () => { + store.setState({ + selectedProjectId: 'project-1', + sessions: [{ id: 'seed' }] as never[], + }); + + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + + mockAPI.getSessionsPaginated + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + + const first = store.getState().refreshSessionsInPlace('project-1'); + const second = store.getState().refreshSessionsInPlace('project-1'); + + resolveSecond?.({ + sessions: [{ id: 'newest' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 1, + }); + resolveFirst?.({ + sessions: [{ id: 'stale' }] as never[], + nextCursor: null, + hasMore: false, + totalCount: 1, + }); + + await Promise.all([first, second]); + expect(store.getState().sessions[0]?.id).toBe('newest'); + }); + }); + + describe('fetchSessionDetail', () => { + it('should ignore stale responses and keep the latest session detail', async () => { + store.setState({ + selectedSessionId: 'session-2', + }); + + let resolveFirst: ((value: unknown) => void) | undefined; + let resolveSecond: ((value: unknown) => void) | undefined; + + mockAPI.getSessionDetail + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }) + ); + + const first = store.getState().fetchSessionDetail('project-1', 'session-1'); + const second = store.getState().fetchSessionDetail('project-1', 'session-2'); + + resolveSecond?.({ + session: { id: 'session-2' }, + chunks: [], + processes: [], + }); + resolveFirst?.({ + session: { id: 'session-1' }, + chunks: [], + processes: [], + }); + + await Promise.all([first, second]); + expect(store.getState().sessionDetail?.session.id).toBe('session-2'); + }); + }); +}); diff --git a/test/renderer/store/storeTestUtils.ts b/test/renderer/store/storeTestUtils.ts new file mode 100644 index 00000000..747305be --- /dev/null +++ b/test/renderer/store/storeTestUtils.ts @@ -0,0 +1,43 @@ +/** + * Store test utilities for creating isolated test store instances. + */ + +import { create } from 'zustand'; + +import { createConfigSlice } from '../../../src/renderer/store/slices/configSlice'; +import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice'; +import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice'; +import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice'; +import { createProjectSlice } from '../../../src/renderer/store/slices/projectSlice'; +import { createRepositorySlice } from '../../../src/renderer/store/slices/repositorySlice'; +import { createSessionDetailSlice } from '../../../src/renderer/store/slices/sessionDetailSlice'; +import { createSessionSlice } from '../../../src/renderer/store/slices/sessionSlice'; +import { createSubagentSlice } from '../../../src/renderer/store/slices/subagentSlice'; +import { createTabSlice } from '../../../src/renderer/store/slices/tabSlice'; +import { createTabUISlice } from '../../../src/renderer/store/slices/tabUISlice'; +import { createUISlice } from '../../../src/renderer/store/slices/uiSlice'; + +import type { AppState } from '../../../src/renderer/store/types'; + +/** + * Create an isolated store instance for testing. + * Each test gets a fresh store with no shared state. + */ +export function createTestStore() { + return create()((...args) => ({ + ...createProjectSlice(...args), + ...createRepositorySlice(...args), + ...createSessionSlice(...args), + ...createSessionDetailSlice(...args), + ...createSubagentSlice(...args), + ...createConversationSlice(...args), + ...createTabSlice(...args), + ...createTabUISlice(...args), + ...createPaneSlice(...args), + ...createUISlice(...args), + ...createNotificationSlice(...args), + ...createConfigSlice(...args), + })); +} + +export type TestStore = ReturnType; diff --git a/test/renderer/store/tabSlice.test.ts b/test/renderer/store/tabSlice.test.ts new file mode 100644 index 00000000..f3733b7f --- /dev/null +++ b/test/renderer/store/tabSlice.test.ts @@ -0,0 +1,592 @@ +/** + * Tab slice unit tests. + * Tests tab state management including deduplication, forceNewTab, scroll position, + * and the unified navigation request model. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +import type { TabNavigationRequest } from '../../../src/renderer/types/tabs'; + +describe('tabSlice', () => { + let store: TestStore; + let mockAPI: MockElectronAPI; + + beforeEach(() => { + vi.useFakeTimers(); + mockAPI = installMockElectronAPI(); + store = createTestStore(); + + // Mock crypto.randomUUID for predictable tab IDs + let uuidCounter = 0; + vi.stubGlobal('crypto', { + randomUUID: () => `test-uuid-${++uuidCounter}`, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('openTab', () => { + describe('deduplication', () => { + it('should focus existing tab when opening same session', () => { + // Open initial session tab + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'First Session', + }); + + const initialTabId = store.getState().activeTabId; + expect(store.getState().openTabs).toHaveLength(1); + + // Open another tab + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Second Session', + }); + + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().activeTabId).not.toBe(initialTabId); + + // Try to open session-1 again - should deduplicate + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'First Session Again', + }); + + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().activeTabId).toBe(initialTabId); + }); + + it('should bypass deduplication when forceNewTab is true', () => { + // Open initial session tab + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'First Session', + }); + + const initialTabId = store.getState().activeTabId; + expect(store.getState().openTabs).toHaveLength(1); + + // Open same session with forceNewTab + store.getState().openTab( + { + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'First Session (New Tab)', + }, + { forceNewTab: true } + ); + + // Should have 2 tabs now, both for the same session + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().activeTabId).not.toBe(initialTabId); + + // Both tabs should have the same sessionId + const sessionTabs = store.getState().openTabs.filter((t) => t.sessionId === 'session-1'); + expect(sessionTabs).toHaveLength(2); + }); + + it('should not deduplicate dashboard tabs', () => { + store.getState().openDashboard(); + store.getState().openDashboard(); + + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().openTabs.filter((t) => t.type === 'dashboard')).toHaveLength(2); + }); + }); + + describe('dashboard replacement', () => { + it('should replace active dashboard tab when opening session', () => { + store.getState().openDashboard(); + const dashboardTabId = store.getState().activeTabId; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + expect(store.getState().openTabs).toHaveLength(1); + // Tab should keep same ID (position preserved) + expect(store.getState().activeTabId).toBe(dashboardTabId); + // But now it's a session tab + expect(store.getState().openTabs[0].type).toBe('session'); + expect(store.getState().openTabs[0].sessionId).toBe('session-1'); + }); + }); + + describe('label truncation', () => { + it('should truncate labels longer than 50 characters', () => { + const longLabel = 'A'.repeat(60); + + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: longLabel, + }); + + const tab = store.getState().openTabs[0]; + expect(tab.label).toHaveLength(50); + expect(tab.label.endsWith('…')).toBe(true); + }); + }); + }); + + describe('closeTab', () => { + it('should focus adjacent tab when closing active tab', () => { + // Open 3 tabs + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Tab 1', + }); + + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Tab 2', + }); + const tab2Id = store.getState().activeTabId; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-3', + projectId: 'project-1', + label: 'Tab 3', + }); + const tab3Id = store.getState().activeTabId; + + // Close tab 3 (active tab) + store.getState().closeTab(tab3Id!); + + // Should focus tab 2 (previous tab) + expect(store.getState().openTabs).toHaveLength(2); + expect(store.getState().activeTabId).toBe(tab2Id); + }); + + it('should reset state when all tabs closed', () => { + // Setup some state + store.setState({ + selectedProjectId: 'project-1', + selectedSessionId: 'session-1', + }); + + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Tab 1', + }); + const tabId = store.getState().activeTabId; + + store.getState().closeTab(tabId!); + + expect(store.getState().openTabs).toHaveLength(0); + expect(store.getState().activeTabId).toBeNull(); + expect(store.getState().selectedProjectId).toBeNull(); + expect(store.getState().selectedSessionId).toBeNull(); + }); + }); + + describe('setActiveTab', () => { + it('should update activeTabId', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tab1Id = store.getState().activeTabId; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Session 2', + }); + + // Switch back to first tab + store.getState().setActiveTab(tab1Id!); + + expect(store.getState().activeTabId).toBe(tab1Id); + }); + + it('should preserve sidebar state for non-session tabs', () => { + // Setup initial state with projects data so setActiveTab can find the project + store.setState({ + selectedProjectId: 'project-1', + selectedSessionId: 'session-1', + projects: [ + { id: 'project-1', name: 'Project 1', path: '/path/1', sessions: ['session-1'] }, + { id: 'project-2', name: 'Project 2', path: '/path/2', sessions: ['session-2'] }, + ] as never[], + }); + + // Open session-2 tab first (this doesn't call setActiveTab, just sets activeTabId) + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-2', + label: 'Session 2', + }); + const sessionTabId = store.getState().activeTabId; + + // Manually call setActiveTab to sync sidebar state (simulating user click) + store.getState().setActiveTab(sessionTabId!); + expect(store.getState().selectedProjectId).toBe('project-2'); + + // Open dashboard tab + store.getState().openDashboard(); + const dashboardTabId = store.getState().activeTabId; + + // Switch to dashboard (should preserve sidebar state) + store.getState().setActiveTab(dashboardTabId!); + + expect(store.getState().activeTabId).toBe(dashboardTabId); + // Sidebar state should be preserved (not cleared) when switching to dashboard + expect(store.getState().selectedProjectId).toBe('project-2'); + }); + }); + + describe('saveTabScrollPosition', () => { + it('should save scroll position for a tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tabId = store.getState().activeTabId!; + + // Initially undefined + expect(store.getState().openTabs[0].savedScrollTop).toBeUndefined(); + + // Save scroll position + store.getState().saveTabScrollPosition(tabId, 500); + + expect(store.getState().openTabs[0].savedScrollTop).toBe(500); + }); + + it('should only update the specified tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tab1Id = store.getState().activeTabId!; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Session 2', + }); + + // Save scroll position for tab 1 + store.getState().saveTabScrollPosition(tab1Id, 300); + + // Tab 1 should have scroll position, tab 2 should not + const tab1 = store.getState().openTabs.find((t) => t.id === tab1Id); + const tab2 = store.getState().openTabs.find((t) => t.id !== tab1Id); + + expect(tab1?.savedScrollTop).toBe(300); + expect(tab2?.savedScrollTop).toBeUndefined(); + }); + }); + + describe('setTabContextPanelVisible', () => { + it('should set context panel visibility for a tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tabId = store.getState().activeTabId!; + + // Initially undefined + expect(store.getState().openTabs[0].showContextPanel).toBeUndefined(); + + // Set to true + store.getState().setTabContextPanelVisible(tabId, true); + expect(store.getState().openTabs[0].showContextPanel).toBe(true); + + // Set to false + store.getState().setTabContextPanelVisible(tabId, false); + expect(store.getState().openTabs[0].showContextPanel).toBe(false); + }); + + it('should only update the specified tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tab1Id = store.getState().activeTabId!; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Session 2', + }); + + // Set context panel visible for tab 1 + store.getState().setTabContextPanelVisible(tab1Id, true); + + // Tab 1 should have context panel visible, tab 2 should not + const tab1 = store.getState().openTabs.find((t) => t.id === tab1Id); + const tab2 = store.getState().openTabs.find((t) => t.id !== tab1Id); + + expect(tab1?.showContextPanel).toBe(true); + expect(tab2?.showContextPanel).toBeUndefined(); + }); + }); + + describe('enqueueTabNavigation', () => { + it('should set pendingNavigation on the tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + const tabId = store.getState().activeTabId!; + const request: TabNavigationRequest = { + id: 'nav-1', + kind: 'error', + source: 'notification', + highlight: 'red', + payload: { + errorId: 'error-1', + errorTimestamp: 12345, + toolUseId: 'tool-1', + lineNumber: 42, + }, + }; + + store.getState().enqueueTabNavigation(tabId, request); + + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation).toEqual(request); + }); + + it('should replace existing pendingNavigation with new request', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + const tabId = store.getState().activeTabId!; + const request1: TabNavigationRequest = { + id: 'nav-1', + kind: 'error', + source: 'notification', + highlight: 'red', + payload: { errorId: 'e1', errorTimestamp: 100 }, + }; + const request2: TabNavigationRequest = { + id: 'nav-2', + kind: 'error', + source: 'notification', + highlight: 'red', + payload: { errorId: 'e2', errorTimestamp: 200 }, + }; + + store.getState().enqueueTabNavigation(tabId, request1); + store.getState().enqueueTabNavigation(tabId, request2); + + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation?.id).toBe('nav-2'); + }); + + it('should only update the specified tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tab1Id = store.getState().activeTabId!; + + store.getState().openTab({ + type: 'session', + sessionId: 'session-2', + projectId: 'project-1', + label: 'Session 2', + }); + + const request: TabNavigationRequest = { + id: 'nav-1', + kind: 'search', + source: 'commandPalette', + highlight: 'yellow', + payload: { query: 'test', messageTimestamp: 1234, matchedText: 'match' }, + }; + + store.getState().enqueueTabNavigation(tab1Id, request); + + const tab1 = store.getState().openTabs.find((t) => t.id === tab1Id); + const tab2 = store.getState().openTabs.find((t) => t.id !== tab1Id); + expect(tab1?.pendingNavigation).toEqual(request); + expect(tab2?.pendingNavigation).toBeUndefined(); + }); + }); + + describe('consumeTabNavigation', () => { + it('should clear pendingNavigation and set lastConsumedNavigationId', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + const tabId = store.getState().activeTabId!; + const request: TabNavigationRequest = { + id: 'nav-1', + kind: 'error', + source: 'notification', + highlight: 'red', + payload: { errorId: 'error-1', errorTimestamp: 12345 }, + }; + + store.getState().enqueueTabNavigation(tabId, request); + expect(store.getState().openTabs[0].pendingNavigation).toBeDefined(); + + store.getState().consumeTabNavigation(tabId, 'nav-1'); + + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation).toBeUndefined(); + expect(tab.lastConsumedNavigationId).toBe('nav-1'); + }); + + it('should not clear if requestId does not match', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + const tabId = store.getState().activeTabId!; + const request: TabNavigationRequest = { + id: 'nav-1', + kind: 'error', + source: 'notification', + highlight: 'red', + payload: { errorId: 'error-1', errorTimestamp: 12345 }, + }; + + store.getState().enqueueTabNavigation(tabId, request); + store.getState().consumeTabNavigation(tabId, 'wrong-id'); + + // Should still have pendingNavigation since IDs don't match + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation).toEqual(request); + }); + }); + + describe('isSessionOpen', () => { + it('should return true if session is open in any tab', () => { + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + + expect(store.getState().isSessionOpen('session-1')).toBe(true); + expect(store.getState().isSessionOpen('session-2')).toBe(false); + }); + }); + + describe('navigateToSession', () => { + it('should open new tab if session not already open', () => { + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-1' }, + chunks: [], + } as never); + + store.getState().navigateToSession('project-1', 'session-1', false); + + expect(store.getState().openTabs).toHaveLength(1); + expect(store.getState().openTabs[0].sessionId).toBe('session-1'); + }); + + it('should focus existing tab with search navigation request', () => { + // First open the session + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const existingTabId = store.getState().activeTabId; + + // Open another tab to switch away + store.getState().openDashboard(); + + // Navigate to same session with search context + store.getState().navigateToSession('project-1', 'session-1', true, { + query: 'test query', + messageTimestamp: 1234567890, + matchedText: 'matched text', + }); + + // Should focus existing tab + expect(store.getState().activeTabId).toBe(existingTabId); + // Should have a pending search navigation request + const tab = store.getState().openTabs.find((t) => t.id === existingTabId); + expect(tab?.pendingNavigation?.kind).toBe('search'); + expect(tab?.pendingNavigation?.payload).toEqual({ + query: 'test query', + messageTimestamp: 1234567890, + matchedText: 'matched text', + }); + }); + + it('should enqueue search navigation on new tab', () => { + mockAPI.getSessionDetail.mockResolvedValue({ + session: { id: 'session-1' }, + chunks: [], + } as never); + + store.getState().navigateToSession('project-1', 'session-1', false, { + query: 'find me', + messageTimestamp: 9999, + matchedText: 'found', + }); + + const tab = store.getState().openTabs[0]; + expect(tab.pendingNavigation?.kind).toBe('search'); + expect(tab.pendingNavigation?.source).toBe('commandPalette'); + expect(tab.pendingNavigation?.highlight).toBe('yellow'); + }); + }); +}); diff --git a/test/renderer/store/tabUISlice.test.ts b/test/renderer/store/tabUISlice.test.ts new file mode 100644 index 00000000..32c90a42 --- /dev/null +++ b/test/renderer/store/tabUISlice.test.ts @@ -0,0 +1,375 @@ +/** + * TabUI slice unit tests. + * Tests per-tab UI state isolation (expansion states, context panel, scroll position). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { installMockElectronAPI, type MockElectronAPI } from '../../mocks/electronAPI'; + +import { createTestStore, type TestStore } from './storeTestUtils'; + +describe('tabUISlice', () => { + let store: TestStore; + let _mockAPI: MockElectronAPI; + + beforeEach(() => { + vi.useFakeTimers(); + _mockAPI = installMockElectronAPI(); + store = createTestStore(); + + // Mock crypto.randomUUID for predictable tab IDs + let uuidCounter = 0; + vi.stubGlobal('crypto', { + randomUUID: () => `test-uuid-${++uuidCounter}`, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe('initTabUIState', () => { + it('should initialize UI state for a new tab', () => { + expect(store.getState().tabUIStates.size).toBe(0); + + store.getState().initTabUIState('tab-1'); + + expect(store.getState().tabUIStates.size).toBe(1); + expect(store.getState().tabUIStates.has('tab-1')).toBe(true); + + const tabState = store.getState().tabUIStates.get('tab-1'); + expect(tabState?.expandedAIGroupIds.size).toBe(0); + expect(tabState?.expandedDisplayItemIds.size).toBe(0); + expect(tabState?.expandedSubagentTraceIds.size).toBe(0); + expect(tabState?.showContextPanel).toBe(false); + expect(tabState?.savedScrollTop).toBeUndefined(); + }); + + it('should not reinitialize existing tab state', () => { + store.getState().initTabUIState('tab-1'); + store.getState().toggleAIGroupExpansionForTab('tab-1', 'group-1'); + + // Try to reinitialize + store.getState().initTabUIState('tab-1'); + + // Should still have the expanded group + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + }); + }); + + describe('cleanupTabUIState', () => { + it('should remove UI state for a tab', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + expect(store.getState().tabUIStates.size).toBe(2); + + store.getState().cleanupTabUIState('tab-1'); + + expect(store.getState().tabUIStates.size).toBe(1); + expect(store.getState().tabUIStates.has('tab-1')).toBe(false); + expect(store.getState().tabUIStates.has('tab-2')).toBe(true); + }); + + it('should do nothing if tab does not exist', () => { + store.getState().initTabUIState('tab-1'); + + store.getState().cleanupTabUIState('nonexistent'); + + expect(store.getState().tabUIStates.size).toBe(1); + }); + }); + + describe('AI Group expansion - per-tab isolation', () => { + it('should toggle AI group expansion for specific tab', () => { + store.getState().initTabUIState('tab-1'); + + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(false); + + store.getState().toggleAIGroupExpansionForTab('tab-1', 'group-1'); + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + + store.getState().toggleAIGroupExpansionForTab('tab-1', 'group-1'); + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(false); + }); + + it('should isolate AI group expansion between tabs', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + + // Expand group-1 in tab-1 only + store.getState().toggleAIGroupExpansionForTab('tab-1', 'group-1'); + + // tab-1 should have it expanded, tab-2 should not + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + expect(store.getState().isAIGroupExpandedForTab('tab-2', 'group-1')).toBe(false); + + // Expand different group in tab-2 + store.getState().toggleAIGroupExpansionForTab('tab-2', 'group-2'); + + // Each tab has its own expansion state + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-2')).toBe(false); + expect(store.getState().isAIGroupExpandedForTab('tab-2', 'group-1')).toBe(false); + expect(store.getState().isAIGroupExpandedForTab('tab-2', 'group-2')).toBe(true); + }); + + it('should expand AI group programmatically', () => { + store.getState().initTabUIState('tab-1'); + + store.getState().expandAIGroupForTab('tab-1', 'group-1'); + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + + // Calling expand again should not change state (idempotent) + store.getState().expandAIGroupForTab('tab-1', 'group-1'); + expect(store.getState().isAIGroupExpandedForTab('tab-1', 'group-1')).toBe(true); + }); + }); + + describe('Display item expansion - per-tab isolation', () => { + it('should toggle display item expansion within AI group', () => { + store.getState().initTabUIState('tab-1'); + + const items = store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1'); + expect(items.size).toBe(0); + + store.getState().toggleDisplayItemExpansionForTab('tab-1', 'group-1', 'item-1'); + + const updatedItems = store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1'); + expect(updatedItems.has('item-1')).toBe(true); + + store.getState().toggleDisplayItemExpansionForTab('tab-1', 'group-1', 'item-1'); + + const finalItems = store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1'); + expect(finalItems.has('item-1')).toBe(false); + }); + + it('should isolate display item expansion between tabs', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + + // Expand item in tab-1 + store.getState().toggleDisplayItemExpansionForTab('tab-1', 'group-1', 'item-1'); + + // tab-1 should have it, tab-2 should not + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1').has('item-1') + ).toBe(true); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-2', 'group-1').has('item-1') + ).toBe(false); + }); + + it('should isolate display items by AI group within same tab', () => { + store.getState().initTabUIState('tab-1'); + + store.getState().toggleDisplayItemExpansionForTab('tab-1', 'group-1', 'item-1'); + store.getState().toggleDisplayItemExpansionForTab('tab-1', 'group-2', 'item-2'); + + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1').has('item-1') + ).toBe(true); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1').has('item-2') + ).toBe(false); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-2').has('item-1') + ).toBe(false); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-2').has('item-2') + ).toBe(true); + }); + + it('should expand display item programmatically', () => { + store.getState().initTabUIState('tab-1'); + + store.getState().expandDisplayItemForTab('tab-1', 'group-1', 'item-1'); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1').has('item-1') + ).toBe(true); + + // Calling expand again should not change state (idempotent) + store.getState().expandDisplayItemForTab('tab-1', 'group-1', 'item-1'); + expect( + store.getState().getExpandedDisplayItemIdsForTab('tab-1', 'group-1').has('item-1') + ).toBe(true); + }); + }); + + describe('Subagent trace expansion - per-tab isolation', () => { + it('should toggle subagent trace expansion', () => { + store.getState().initTabUIState('tab-1'); + + expect(store.getState().isSubagentTraceExpandedForTab('tab-1', 'subagent-1')).toBe(false); + + store.getState().toggleSubagentTraceExpansionForTab('tab-1', 'subagent-1'); + expect(store.getState().isSubagentTraceExpandedForTab('tab-1', 'subagent-1')).toBe(true); + + store.getState().toggleSubagentTraceExpansionForTab('tab-1', 'subagent-1'); + expect(store.getState().isSubagentTraceExpandedForTab('tab-1', 'subagent-1')).toBe(false); + }); + + it('should isolate subagent trace expansion between tabs', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + + store.getState().toggleSubagentTraceExpansionForTab('tab-1', 'subagent-1'); + + expect(store.getState().isSubagentTraceExpandedForTab('tab-1', 'subagent-1')).toBe(true); + expect(store.getState().isSubagentTraceExpandedForTab('tab-2', 'subagent-1')).toBe(false); + }); + }); + + describe('Context panel visibility - per-tab isolation', () => { + it('should set context panel visibility', () => { + store.getState().initTabUIState('tab-1'); + + expect(store.getState().isContextPanelVisibleForTab('tab-1')).toBe(false); + + store.getState().setContextPanelVisibleForTab('tab-1', true); + expect(store.getState().isContextPanelVisibleForTab('tab-1')).toBe(true); + + store.getState().setContextPanelVisibleForTab('tab-1', false); + expect(store.getState().isContextPanelVisibleForTab('tab-1')).toBe(false); + }); + + it('should isolate context panel visibility between tabs', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + + store.getState().setContextPanelVisibleForTab('tab-1', true); + + expect(store.getState().isContextPanelVisibleForTab('tab-1')).toBe(true); + expect(store.getState().isContextPanelVisibleForTab('tab-2')).toBe(false); + }); + }); + + describe('Scroll position - per-tab isolation', () => { + it('should save and retrieve scroll position', () => { + store.getState().initTabUIState('tab-1'); + + expect(store.getState().getScrollPositionForTab('tab-1')).toBeUndefined(); + + store.getState().saveScrollPositionForTab('tab-1', 500); + expect(store.getState().getScrollPositionForTab('tab-1')).toBe(500); + + store.getState().saveScrollPositionForTab('tab-1', 1000); + expect(store.getState().getScrollPositionForTab('tab-1')).toBe(1000); + }); + + it('should isolate scroll positions between tabs', () => { + store.getState().initTabUIState('tab-1'); + store.getState().initTabUIState('tab-2'); + + store.getState().saveScrollPositionForTab('tab-1', 100); + store.getState().saveScrollPositionForTab('tab-2', 200); + + expect(store.getState().getScrollPositionForTab('tab-1')).toBe(100); + expect(store.getState().getScrollPositionForTab('tab-2')).toBe(200); + }); + }); + + describe('Integration with tab lifecycle', () => { + it('should handle full tab lifecycle', () => { + // Simulate opening a tab + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tabId = store.getState().activeTabId!; + + // Initialize UI state + store.getState().initTabUIState(tabId); + + // Set some UI state + store.getState().toggleAIGroupExpansionForTab(tabId, 'group-1'); + store.getState().setContextPanelVisibleForTab(tabId, true); + store.getState().saveScrollPositionForTab(tabId, 300); + + // Verify state + expect(store.getState().isAIGroupExpandedForTab(tabId, 'group-1')).toBe(true); + expect(store.getState().isContextPanelVisibleForTab(tabId)).toBe(true); + expect(store.getState().getScrollPositionForTab(tabId)).toBe(300); + + // Close tab (should cleanup UI state) + store.getState().closeTab(tabId); + + // UI state should be cleaned up + expect(store.getState().tabUIStates.has(tabId)).toBe(false); + }); + + it('should maintain separate state for two tabs with same session (forceNewTab)', () => { + // Open first tab + store.getState().openTab({ + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1', + }); + const tab1Id = store.getState().activeTabId!; + store.getState().initTabUIState(tab1Id); + + // Open second tab with same session (forceNewTab) + store.getState().openTab( + { + type: 'session', + sessionId: 'session-1', + projectId: 'project-1', + label: 'Session 1 (Copy)', + }, + { forceNewTab: true } + ); + const tab2Id = store.getState().activeTabId!; + store.getState().initTabUIState(tab2Id); + + // Both tabs should have same session + expect(store.getState().openTabs.filter((t) => t.sessionId === 'session-1')).toHaveLength(2); + + // Set different states for each tab + store.getState().toggleAIGroupExpansionForTab(tab1Id, 'group-1'); + store.getState().toggleAIGroupExpansionForTab(tab2Id, 'group-2'); + store.getState().setContextPanelVisibleForTab(tab1Id, true); + store.getState().saveScrollPositionForTab(tab1Id, 100); + store.getState().saveScrollPositionForTab(tab2Id, 500); + + // Verify states are isolated + expect(store.getState().isAIGroupExpandedForTab(tab1Id, 'group-1')).toBe(true); + expect(store.getState().isAIGroupExpandedForTab(tab1Id, 'group-2')).toBe(false); + expect(store.getState().isAIGroupExpandedForTab(tab2Id, 'group-1')).toBe(false); + expect(store.getState().isAIGroupExpandedForTab(tab2Id, 'group-2')).toBe(true); + + expect(store.getState().isContextPanelVisibleForTab(tab1Id)).toBe(true); + expect(store.getState().isContextPanelVisibleForTab(tab2Id)).toBe(false); + + expect(store.getState().getScrollPositionForTab(tab1Id)).toBe(100); + expect(store.getState().getScrollPositionForTab(tab2Id)).toBe(500); + }); + }); + + describe('Edge cases', () => { + it('should return false/empty for uninitialized tab', () => { + // No initialization + expect(store.getState().isAIGroupExpandedForTab('nonexistent', 'group-1')).toBe(false); + expect(store.getState().getExpandedDisplayItemIdsForTab('nonexistent', 'group-1').size).toBe( + 0 + ); + expect(store.getState().isSubagentTraceExpandedForTab('nonexistent', 'subagent-1')).toBe( + false + ); + expect(store.getState().isContextPanelVisibleForTab('nonexistent')).toBe(false); + expect(store.getState().getScrollPositionForTab('nonexistent')).toBeUndefined(); + }); + + it('should auto-create tab state when toggling (lazy initialization)', () => { + // Toggle without explicit init + store.getState().toggleAIGroupExpansionForTab('lazy-tab', 'group-1'); + + // Should have created the state + expect(store.getState().tabUIStates.has('lazy-tab')).toBe(true); + expect(store.getState().isAIGroupExpandedForTab('lazy-tab', 'group-1')).toBe(true); + }); + }); +}); diff --git a/test/renderer/utils/claudeMdTracker.test.ts b/test/renderer/utils/claudeMdTracker.test.ts new file mode 100644 index 00000000..6e23567e --- /dev/null +++ b/test/renderer/utils/claudeMdTracker.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { + detectClaudeMdFromFilePath, + getDirectory, + getParentDirectory, +} from '@renderer/utils/claudeMdTracker'; + +describe('claudeMdTracker path helpers', () => { + describe('getDirectory', () => { + it('returns directory from Unix path', () => { + expect(getDirectory('/a/b/file.ts')).toBe('/a/b'); + }); + + it('returns directory from Windows path', () => { + expect(getDirectory('C:\\a\\b\\file.ts')).toBe('C:\\a\\b'); + }); + + it('returns directory from mixed-separator path', () => { + expect(getDirectory('C:\\a/b\\file.ts')).toBe('C:\\a/b'); + }); + + it('returns empty for bare filename', () => { + expect(getDirectory('file.ts')).toBe(''); + }); + + it('returns root for root-level file', () => { + expect(getDirectory('/file.ts')).toBe(''); + }); + }); + + describe('getParentDirectory', () => { + it('returns parent from Unix path', () => { + expect(getParentDirectory('/a/b/c')).toBe('/a/b'); + }); + + it('returns parent from Windows path', () => { + expect(getParentDirectory('C:\\a\\b\\c')).toBe('C:\\a\\b'); + }); + + it('returns null at root', () => { + expect(getParentDirectory('/a')).toBeNull(); + }); + + it('returns null for single segment', () => { + expect(getParentDirectory('a')).toBeNull(); + }); + + it('returns parent from deeply nested path', () => { + expect(getParentDirectory('/a/b/c/d/e')).toBe('/a/b/c/d'); + }); + }); + + describe('detectClaudeMdFromFilePath', () => { + it('detects CLAUDE.md files walking up Unix paths', () => { + const result = detectClaudeMdFromFilePath('/repo/src/lib/file.ts', '/repo'); + expect(result).toContain('/repo/src/lib/CLAUDE.md'); + expect(result).toContain('/repo/src/CLAUDE.md'); + expect(result).toContain('/repo/CLAUDE.md'); + expect(result).toHaveLength(3); + }); + + it('detects CLAUDE.md files walking up Windows paths', () => { + const result = detectClaudeMdFromFilePath('C:\\repo\\src\\file.ts', 'C:\\repo'); + expect(result).toContain('C:\\repo\\src\\CLAUDE.md'); + expect(result).toContain('C:\\repo\\CLAUDE.md'); + expect(result).toHaveLength(2); + }); + + it('uses correct separator for generated paths', () => { + const unixResult = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo'); + for (const p of unixResult) { + expect(p).not.toContain('\\'); + } + + const winResult = detectClaudeMdFromFilePath('C:\\repo\\src\\file.ts', 'C:\\repo'); + for (const p of winResult) { + expect(p).toContain('\\'); + expect(p).not.toContain('/'); + } + }); + + it('returns empty array when file is at project root', () => { + const result = detectClaudeMdFromFilePath('/repo/file.ts', '/repo'); + expect(result).toEqual(['/repo/CLAUDE.md']); + }); + + it('stops at project root boundary', () => { + const result = detectClaudeMdFromFilePath('/repo/src/file.ts', '/repo'); + // Should not go above /repo + const aboveRoot = result.some((p) => !p.startsWith('/repo')); + expect(aboveRoot).toBe(false); + }); + }); +}); diff --git a/test/renderer/utils/dateGrouping.test.ts b/test/renderer/utils/dateGrouping.test.ts new file mode 100644 index 00000000..381f0656 --- /dev/null +++ b/test/renderer/utils/dateGrouping.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + getNonEmptyCategories, + groupSessionsByDate, +} from '../../../src/renderer/utils/dateGrouping'; +import type { Session } from '../../../src/renderer/types/data'; + +// Helper to create a session with a specific date +function createSession(id: string, createdAt: Date): Session { + return { + id, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + displayName: `Session ${id}`, + triggerCount: 1, + ongoing: false, + lastTriggerPreview: 'Test', + cwd: '/test', + todos: [], + totalTokens: 0, + }; +} + +describe('dateGrouping', () => { + beforeEach(() => { + // Mock current date to 2024-01-15 12:00:00 + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('groupSessionsByDate', () => { + it('should group session from today', () => { + const today = new Date('2024-01-15T10:00:00Z'); + const sessions = [createSession('1', today)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(1); + expect(result.Yesterday).toHaveLength(0); + expect(result['Previous 7 Days']).toHaveLength(0); + expect(result.Older).toHaveLength(0); + }); + + it('should group session from yesterday', () => { + const yesterday = new Date('2024-01-14T10:00:00Z'); + const sessions = [createSession('1', yesterday)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(0); + expect(result.Yesterday).toHaveLength(1); + expect(result['Previous 7 Days']).toHaveLength(0); + expect(result.Older).toHaveLength(0); + }); + + it('should group session from 3 days ago to Previous 7 Days', () => { + const threeDaysAgo = new Date('2024-01-12T10:00:00Z'); + const sessions = [createSession('1', threeDaysAgo)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(0); + expect(result.Yesterday).toHaveLength(0); + expect(result['Previous 7 Days']).toHaveLength(1); + expect(result.Older).toHaveLength(0); + }); + + it('should group session from 10 days ago to Older', () => { + const tenDaysAgo = new Date('2024-01-05T10:00:00Z'); + const sessions = [createSession('1', tenDaysAgo)]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(0); + expect(result.Yesterday).toHaveLength(0); + expect(result['Previous 7 Days']).toHaveLength(0); + expect(result.Older).toHaveLength(1); + }); + + it('should distribute multiple sessions to correct groups', () => { + const sessions = [ + createSession('1', new Date('2024-01-15T10:00:00Z')), // Today + createSession('2', new Date('2024-01-15T08:00:00Z')), // Today + createSession('3', new Date('2024-01-14T10:00:00Z')), // Yesterday + createSession('4', new Date('2024-01-12T10:00:00Z')), // Previous 7 Days + createSession('5', new Date('2024-01-01T10:00:00Z')), // Older + ]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today).toHaveLength(2); + expect(result.Yesterday).toHaveLength(1); + expect(result['Previous 7 Days']).toHaveLength(1); + expect(result.Older).toHaveLength(1); + }); + + it('should handle empty sessions array', () => { + const result = groupSessionsByDate([]); + + expect(result.Today).toHaveLength(0); + expect(result.Yesterday).toHaveLength(0); + expect(result['Previous 7 Days']).toHaveLength(0); + expect(result.Older).toHaveLength(0); + }); + + it('should maintain order within groups', () => { + const sessions = [ + createSession('first', new Date('2024-01-15T08:00:00Z')), + createSession('second', new Date('2024-01-15T10:00:00Z')), + createSession('third', new Date('2024-01-15T12:00:00Z')), + ]; + + const result = groupSessionsByDate(sessions); + + expect(result.Today.map((s) => s.id)).toEqual(['first', 'second', 'third']); + }); + }); + + describe('getNonEmptyCategories', () => { + it('should return only non-empty categories', () => { + const grouped = { + Today: [createSession('1', new Date())], + Yesterday: [], + 'Previous 7 Days': [createSession('2', new Date())], + Older: [], + }; + + const result = getNonEmptyCategories(grouped); + + expect(result).toEqual(['Today', 'Previous 7 Days']); + }); + + it('should return categories in display order', () => { + const grouped = { + Today: [createSession('1', new Date())], + Yesterday: [createSession('2', new Date())], + 'Previous 7 Days': [createSession('3', new Date())], + Older: [createSession('4', new Date())], + }; + + const result = getNonEmptyCategories(grouped); + + expect(result).toEqual(['Today', 'Yesterday', 'Previous 7 Days', 'Older']); + }); + + it('should return empty array when all categories are empty', () => { + const grouped = { + Today: [], + Yesterday: [], + 'Previous 7 Days': [], + Older: [], + }; + + const result = getNonEmptyCategories(grouped); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/test/renderer/utils/formatters.test.ts b/test/renderer/utils/formatters.test.ts new file mode 100644 index 00000000..7a3b6ee3 --- /dev/null +++ b/test/renderer/utils/formatters.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { formatDuration, formatTokensCompact } from '../../../src/renderer/utils/formatters'; + +describe('formatters', () => { + describe('formatDuration', () => { + it('should format milliseconds', () => { + expect(formatDuration(500)).toBe('500ms'); + }); + + it('should format seconds with one decimal', () => { + expect(formatDuration(1500)).toBe('1.5s'); + }); + + it('should format whole seconds', () => { + expect(formatDuration(3000)).toBe('3.0s'); + }); + + it('should format minutes and seconds', () => { + expect(formatDuration(90000)).toBe('1m 30s'); + }); + + it('should format multiple minutes', () => { + expect(formatDuration(180000)).toBe('3m 0s'); + }); + + it('should round milliseconds', () => { + expect(formatDuration(499.7)).toBe('500ms'); + }); + + it('should handle zero', () => { + expect(formatDuration(0)).toBe('0ms'); + }); + + it('should handle exactly 1000ms', () => { + expect(formatDuration(1000)).toBe('1.0s'); + }); + + it('should handle exactly 60000ms', () => { + expect(formatDuration(60000)).toBe('1m 0s'); + }); + + it('should handle large values', () => { + expect(formatDuration(3661000)).toBe('61m 1s'); + }); + + it('should round remaining seconds', () => { + expect(formatDuration(61500)).toBe('1m 2s'); + }); + }); + + describe('formatTokensCompact', () => { + it('should format small numbers as-is', () => { + expect(formatTokensCompact(500)).toBe('500'); + }); + + it('should format thousands with k suffix', () => { + expect(formatTokensCompact(1500)).toBe('1.5k'); + }); + + it('should format exact thousands', () => { + expect(formatTokensCompact(1000)).toBe('1.0k'); + }); + + it('should format large thousands', () => { + expect(formatTokensCompact(50000)).toBe('50.0k'); + }); + + it('should format millions with M suffix', () => { + expect(formatTokensCompact(1500000)).toBe('1.5M'); + }); + + it('should format exact millions', () => { + expect(formatTokensCompact(1000000)).toBe('1.0M'); + }); + + it('should handle zero', () => { + expect(formatTokensCompact(0)).toBe('0'); + }); + + it('should handle just under thousand', () => { + expect(formatTokensCompact(999)).toBe('999'); + }); + }); +}); diff --git a/test/renderer/utils/pathUtils.test.ts b/test/renderer/utils/pathUtils.test.ts new file mode 100644 index 00000000..f2ba8628 --- /dev/null +++ b/test/renderer/utils/pathUtils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { + getBaseName, + getFirstSegment, + hasPathSeparator, + isRelativePath, + splitPathSegments, +} from '@renderer/utils/pathUtils'; + +describe('pathUtils', () => { + describe('getBaseName', () => { + it('extracts filename from Unix path', () => { + expect(getBaseName('/Users/name/project/file.ts')).toBe('file.ts'); + }); + + it('extracts filename from Windows path', () => { + expect(getBaseName('C:\\Users\\name\\project\\file.ts')).toBe('file.ts'); + }); + + it('extracts filename from mixed-separator path', () => { + expect(getBaseName('C:\\Users/name\\project/file.ts')).toBe('file.ts'); + }); + + it('returns bare filename as-is', () => { + expect(getBaseName('file.ts')).toBe('file.ts'); + }); + + it('returns empty for trailing separator', () => { + expect(getBaseName('/path/to/dir/')).toBe(''); + }); + + it('returns empty for empty string', () => { + expect(getBaseName('')).toBe(''); + }); + }); + + describe('getFirstSegment', () => { + it('returns first segment from Unix path', () => { + expect(getFirstSegment('src/components/App.tsx')).toBe('src'); + }); + + it('returns first segment from Windows path', () => { + expect(getFirstSegment('src\\components\\App.tsx')).toBe('src'); + }); + + it('returns drive letter from Windows absolute path', () => { + expect(getFirstSegment('C:\\Users\\name')).toBe('C:'); + }); + + it('skips leading separator in absolute path', () => { + expect(getFirstSegment('/Users/name')).toBe('Users'); + }); + + it('returns bare filename', () => { + expect(getFirstSegment('file.ts')).toBe('file.ts'); + }); + + it('returns empty for empty string', () => { + expect(getFirstSegment('')).toBe(''); + }); + }); + + describe('splitPathSegments', () => { + it('splits Unix path', () => { + expect(splitPathSegments('/a/b/c')).toEqual(['a', 'b', 'c']); + }); + + it('splits Windows path', () => { + expect(splitPathSegments('C:\\a\\b\\c')).toEqual(['C:', 'a', 'b', 'c']); + }); + + it('splits mixed-separator path', () => { + expect(splitPathSegments('a/b\\c')).toEqual(['a', 'b', 'c']); + }); + + it('filters empty segments', () => { + expect(splitPathSegments('//a///b//')).toEqual(['a', 'b']); + }); + + it('returns single segment for bare name', () => { + expect(splitPathSegments('file.ts')).toEqual(['file.ts']); + }); + }); + + describe('hasPathSeparator', () => { + it('detects forward slash', () => { + expect(hasPathSeparator('a/b')).toBe(true); + }); + + it('detects backslash', () => { + expect(hasPathSeparator('a\\b')).toBe(true); + }); + + it('returns false for bare name', () => { + expect(hasPathSeparator('file.ts')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(hasPathSeparator('')).toBe(false); + }); + }); + + describe('isRelativePath', () => { + it('detects ./ prefix', () => { + expect(isRelativePath('./src')).toBe(true); + }); + + it('detects .\\ prefix', () => { + expect(isRelativePath('.\\src')).toBe(true); + }); + + it('detects ../ prefix', () => { + expect(isRelativePath('../lib')).toBe(true); + }); + + it('detects ..\\ prefix', () => { + expect(isRelativePath('..\\lib')).toBe(true); + }); + + it('rejects absolute Unix path', () => { + expect(isRelativePath('/abs')).toBe(false); + }); + + it('rejects absolute Windows path', () => { + expect(isRelativePath('C:\\abs')).toBe(false); + }); + + it('rejects bare name', () => { + expect(isRelativePath('name')).toBe(false); + }); + + it('rejects single dot without separator', () => { + expect(isRelativePath('.hidden')).toBe(false); + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 00000000..aca6a909 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,50 @@ +/** + * Vitest setup file. + * Runs before each test file. + */ + +import { afterEach, beforeEach, expect, vi } from 'vitest'; + +// Mock process.env for tests that need home directory +vi.stubGlobal('process', { + ...process, + env: { + ...process.env, + HOME: '/home/testuser', + }, +}); + +let errorSpy: ReturnType; +let warnSpy: ReturnType; + +function formatConsoleCall(args: unknown[]): string { + return args + .map((arg) => { + if (arg instanceof Error) { + return arg.message; + } + return String(arg); + }) + .join(' '); +} + +beforeEach(() => { + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + const unexpectedErrors = errorSpy.mock.calls.map(formatConsoleCall); + const unexpectedWarnings = warnSpy.mock.calls.map(formatConsoleCall); + + errorSpy.mockRestore(); + warnSpy.mockRestore(); + + expect(unexpectedErrors, `Unexpected console.error calls:\n${unexpectedErrors.join('\n')}`).toEqual( + [] + ); + expect( + unexpectedWarnings, + `Unexpected console.warn calls:\n${unexpectedWarnings.join('\n')}` + ).toEqual([]); +}); diff --git a/test/shared/utils/markdownSearchRendererAlignment.test.ts b/test/shared/utils/markdownSearchRendererAlignment.test.ts new file mode 100644 index 00000000..8565565e --- /dev/null +++ b/test/shared/utils/markdownSearchRendererAlignment.test.ts @@ -0,0 +1,56 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { describe, expect, it } from 'vitest'; + +import { createMarkdownComponents } from '../../../src/renderer/components/chat/markdownComponents'; +import { createSearchContext } from '../../../src/renderer/components/chat/searchHighlightUtils'; +import { findMarkdownSearchMatches } from '../../../src/shared/utils/markdownTextSearch'; + +function extractRenderedMatchIndexes(markdown: string, query: string): number[] { + const parsedMatches = findMarkdownSearchMatches(markdown, query); + const searchMatches = parsedMatches.map((m, i) => ({ + itemId: 'item-1', + itemType: 'user' as const, + matchIndexInItem: m.matchIndexInItem, + globalIndex: i, + })); + const searchCtx = createSearchContext(query, 'item-1', searchMatches, 0); + const components = createMarkdownComponents(searchCtx); + + const html = renderToStaticMarkup( + React.createElement( + ReactMarkdown, + { remarkPlugins: [remarkGfm], components }, + markdown + ) + ); + + return Array.from(html.matchAll(/data-search-match-index="(\d+)"/g), (m) => Number(m[1])); +} + +describe('markdown search renderer alignment', () => { + const query = 'the'; + const cases = [ + 'the plain the', + 'Use `the` and then **the**.', + '- the one\n- `the` two\n- **the** three', + '| col | val |\n| - | - |\n| the | then |\n| other | the |', + '```ts\nconst theValue = "the";\n```\nthen the', + 'line one \nline two with the', + 'the and the', + '/cmd the and the', + '[the docs](https://example.com/the) and https://example.com/the', + 'This is ~~the~~ test with the', + ]; + + it.each(cases)('matches parser indexes for: %s', (markdown) => { + const parsedIndexes = findMarkdownSearchMatches(markdown, query).map( + (m) => m.matchIndexInItem + ); + const renderedIndexes = extractRenderedMatchIndexes(markdown, query); + expect(renderedIndexes).toEqual(parsedIndexes); + }); +}); + diff --git a/test/shared/utils/markdownTextSearch.test.ts b/test/shared/utils/markdownTextSearch.test.ts new file mode 100644 index 00000000..47a33d6b --- /dev/null +++ b/test/shared/utils/markdownTextSearch.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectTextSegments, + countMarkdownSearchMatches, + extractMarkdownPlainText, + findMarkdownSearchMatches, +} from '../../../src/shared/utils/markdownTextSearch'; + +describe('markdownTextSearch', () => { + // --------------------------------------------------------------------------- + // collectTextSegments (now takes markdown string, uses HAST internally) + // --------------------------------------------------------------------------- + + describe('collectTextSegments', () => { + it('extracts plain text from a paragraph', () => { + const segments = collectTextSegments('Hello world'); + expect(segments).toEqual(['Hello world']); + }); + + it('extracts text from bold/italic nodes', () => { + const segments = collectTextSegments('Hello **bold** and *italic*'); + expect(segments).toEqual(['Hello ', 'bold', ' and ', 'italic']); + }); + + it('keeps code block content as a single segment with trailing newline', () => { + // HAST adds trailing \n to code block text — matches what ReactMarkdown + // passes to its component as children + const segments = collectTextSegments('```js\nconst x = 1;\nconst y = 2;\n```'); + expect(segments).toEqual(['const x = 1;\nconst y = 2;\n']); + }); + + it('extracts inline code text', () => { + const segments = collectTextSegments('Use `findMatches` here'); + expect(segments).toEqual(['Use ', 'findMatches', ' here']); + }); + + it('extracts link text but not URL', () => { + const segments = collectTextSegments('[docs](https://example.com)'); + expect(segments).toEqual(['docs']); + }); + + it('does NOT include image alt text', () => { + const segments = collectTextSegments('![screenshot](./img.png)'); + expect(segments).toEqual([]); + }); + + it('extracts list item text', () => { + const segments = collectTextSegments('- item one\n- item two'); + expect(segments).toContain('item one'); + expect(segments).toContain('item two'); + }); + + it('extracts heading text', () => { + const segments = collectTextSegments('## Important Section'); + expect(segments).toContain('Important Section'); + }); + + it('extracts table cell text', () => { + const segments = collectTextSegments( + '| Header | Value |\n|--------|-------|\n| Cell | Data |' + ); + expect(segments).toContain('Header'); + expect(segments).toContain('Cell'); + expect(segments).toContain('Data'); + }); + + it('extracts blockquote text', () => { + const segments = collectTextSegments('> quoted text'); + expect(segments).toContain('quoted text'); + }); + + it('extracts h5 heading text', () => { + const segments = collectTextSegments('##### Sub-heading'); + expect(segments).toContain('Sub-heading'); + }); + + it('extracts h6 heading text', () => { + const segments = collectTextSegments('###### Tiny heading'); + expect(segments).toContain('Tiny heading'); + }); + + it('extracts strikethrough (del) text', () => { + const segments = collectTextSegments('This is ~~removed~~ text'); + expect(segments).toContain('removed'); + }); + + it('collects nested inline text in document order', () => { + const segments = collectTextSegments('first **bold** last'); + // Segments must be in document order: "first " before "bold" before " last" + expect(segments).toEqual(['first ', 'bold', ' last']); + }); + + it('does NOT include inter-block whitespace', () => { + // Whitespace text nodes at root level (between blocks) should NOT be collected + const segments = collectTextSegments('Paragraph one\n\nParagraph two'); + const newlineOnlySegments = segments.filter((s) => s.trim() === ''); + // Any whitespace segments should only be inside hl elements (like li), not at root level + expect(segments).toContain('Paragraph one'); + expect(segments).toContain('Paragraph two'); + // Root-level "\n" nodes should be excluded + expect(newlineOnlySegments.length).toBeLessThanOrEqual(0); + }); + }); + + // --------------------------------------------------------------------------- + // findMarkdownSearchMatches + // --------------------------------------------------------------------------- + + describe('findMarkdownSearchMatches', () => { + it('finds matches in plain text', () => { + const matches = findMarkdownSearchMatches('hello world hello', 'hello'); + expect(matches).toHaveLength(2); + expect(matches[0].matchIndexInItem).toBe(0); + expect(matches[1].matchIndexInItem).toBe(1); + }); + + it('is case-insensitive', () => { + const matches = findMarkdownSearchMatches('Hello HELLO', 'hello'); + expect(matches).toHaveLength(2); + }); + + it('finds matches in bold text (strips ** markers)', () => { + const matches = findMarkdownSearchMatches('This is **important** text', 'important'); + expect(matches).toHaveLength(1); + }); + + it('does NOT match markdown syntax characters like **', () => { + const matches = findMarkdownSearchMatches('This is **bold** text', '**'); + expect(matches).toHaveLength(0); + }); + + it('does NOT match code fence language identifiers', () => { + const md = '```tsx\nconst x = 1;\n```'; + const matches = findMarkdownSearchMatches(md, 'tsx'); + expect(matches).toHaveLength(0); + }); + + it('finds matches inside fenced code block content', () => { + const md = '```ts\nconst tsx = "value";\n```'; + const matches = findMarkdownSearchMatches(md, 'tsx'); + expect(matches).toHaveLength(1); + }); + + it('finds matches in inline code', () => { + const matches = findMarkdownSearchMatches('Use `findMatches` here', 'findmatches'); + expect(matches).toHaveLength(1); + }); + + it('does NOT match link URLs', () => { + const md = 'Check [docs](https://example.com/docs) here'; + const matches = findMarkdownSearchMatches(md, 'example.com'); + expect(matches).toHaveLength(0); + }); + + it('matches link text but not URL', () => { + const md = 'Check [the docs](https://example.com) here'; + const matches = findMarkdownSearchMatches(md, 'the docs'); + expect(matches).toHaveLength(1); + }); + + it('does NOT match image alt text', () => { + const md = 'An image: ![screenshot](./img.png)'; + const matches = findMarkdownSearchMatches(md, 'screenshot'); + expect(matches).toHaveLength(0); + }); + + it('does NOT match heading markers (#)', () => { + const md = '# Title\n\nSome text'; + const matches = findMarkdownSearchMatches(md, '#'); + expect(matches).toHaveLength(0); + }); + + it('finds matches in heading text', () => { + const md = '## Important Section\n\nBody text'; + const matches = findMarkdownSearchMatches(md, 'important'); + expect(matches).toHaveLength(1); + }); + + it('does NOT match list markers', () => { + const md = '- item one\n- item two'; + const matches = findMarkdownSearchMatches(md, '-'); + expect(matches).toHaveLength(0); + }); + + it('does NOT match across text segments (no cross-node matches)', () => { + // "**th**eory" renders as two text nodes: "th" and "eory" + // A search for "theory" should NOT match because it spans nodes + const md = '**th**eory'; + const matches = findMarkdownSearchMatches(md, 'theory'); + expect(matches).toHaveLength(0); + }); + + it('handles strikethrough text', () => { + const md = 'This is ~~deleted~~ text'; + const matches = findMarkdownSearchMatches(md, 'deleted'); + expect(matches).toHaveLength(1); + const tildeMatches = findMarkdownSearchMatches(md, '~~'); + expect(tildeMatches).toHaveLength(0); + }); + + it('handles tables', () => { + const md = '| Header | Value |\n|--------|-------|\n| Cell | Data |'; + const matches = findMarkdownSearchMatches(md, 'cell'); + expect(matches).toHaveLength(1); + }); + + it('returns empty for empty input', () => { + expect(findMarkdownSearchMatches('', 'test')).toEqual([]); + expect(findMarkdownSearchMatches('test', '')).toEqual([]); + }); + + it('handles blockquotes', () => { + const md = '> quoted text here'; + const matches = findMarkdownSearchMatches(md, 'quoted'); + expect(matches).toHaveLength(1); + }); + + it('finds matches in h5 headings', () => { + const md = '##### Sub-heading\n\nBody text'; + const matches = findMarkdownSearchMatches(md, 'sub-heading'); + expect(matches).toHaveLength(1); + }); + + it('finds matches in h6 headings', () => { + const md = '###### Tiny heading\n\nBody text'; + const matches = findMarkdownSearchMatches(md, 'tiny'); + expect(matches).toHaveLength(1); + }); + + it('finds matches in strikethrough (del) text', () => { + const md = 'This is ~~deleted content~~ here'; + const matches = findMarkdownSearchMatches(md, 'deleted'); + expect(matches).toHaveLength(1); + }); + + it('does not match reference-style link definitions', () => { + const md = '[link text][ref]\n\n[ref]: https://example.com'; + const matches = findMarkdownSearchMatches(md, 'example.com'); + expect(matches).toHaveLength(0); + }); + + it('treats code block content as single segment (allows cross-line match)', () => { + // Code block is a single text node in HAST, matching what ReactMarkdown's + // component receives as children. Cross-line matches ARE valid + // because highlightSearchText operates on the full string. + const md = '```js\nconst x = 1;\nconst y = 2;\n```'; + const matches = findMarkdownSearchMatches(md, '1;\nconst'); + expect(matches).toHaveLength(1); + }); + + it('finds per-line matches inside code blocks', () => { + const md = '```js\nconst x = 1;\nconst y = 2;\n```'; + const matches = findMarkdownSearchMatches(md, 'const'); + expect(matches).toHaveLength(2); + expect(matches[0].matchIndexInItem).toBe(0); + expect(matches[1].matchIndexInItem).toBe(1); + }); + }); + + // --------------------------------------------------------------------------- + // countMarkdownSearchMatches + // --------------------------------------------------------------------------- + + describe('countMarkdownSearchMatches', () => { + it('returns correct count', () => { + const count = countMarkdownSearchMatches('hello **world** hello', 'hello'); + expect(count).toBe(2); + }); + + it('returns 0 for no matches', () => { + expect(countMarkdownSearchMatches('hello world', 'xyz')).toBe(0); + }); + + it('returns 0 for empty inputs', () => { + expect(countMarkdownSearchMatches('', 'test')).toBe(0); + expect(countMarkdownSearchMatches('test', '')).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // extractMarkdownPlainText + // --------------------------------------------------------------------------- + + describe('extractMarkdownPlainText', () => { + it('extracts plain text from markdown', () => { + const text = extractMarkdownPlainText('**bold** and `code`'); + expect(text).toContain('bold'); + expect(text).toContain('code'); + expect(text).not.toContain('**'); + expect(text).not.toContain('`'); + }); + + it('strips code fence language', () => { + const text = extractMarkdownPlainText('```tsx\nconst x = 1;\n```'); + expect(text).toContain('const x = 1;'); + expect(text).not.toMatch(/(?:^|\s)tsx(?:\s|$)/); + }); + + it('returns empty string for empty input', () => { + expect(extractMarkdownPlainText('')).toBe(''); + }); + }); +}); diff --git a/test/shared/utils/modelParser.test.ts b/test/shared/utils/modelParser.test.ts new file mode 100644 index 00000000..fae2532c --- /dev/null +++ b/test/shared/utils/modelParser.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { getModelColorClass, parseModelString } from '../../../src/shared/utils/modelParser'; + +describe('modelParser', () => { + describe('parseModelString', () => { + it('should return null for empty string', () => { + expect(parseModelString('')).toBeNull(); + }); + + it('should return null for undefined', () => { + expect(parseModelString(undefined)).toBeNull(); + }); + + it('should return null for synthetic model', () => { + expect(parseModelString('')).toBeNull(); + }); + + it('should return null for non-claude model', () => { + expect(parseModelString('gpt-4')).toBeNull(); + }); + + // New format tests: claude-{family}-{major}-{minor}-{date} + it('should parse new format: claude-sonnet-4-5-20250929', () => { + const result = parseModelString('claude-sonnet-4-5-20250929'); + expect(result).toEqual({ + name: 'sonnet4.5', + family: 'sonnet', + majorVersion: 4, + minorVersion: 5, + }); + }); + + it('should parse new format without minor version', () => { + const result = parseModelString('claude-opus-5-20260101'); + expect(result).toEqual({ + name: 'opus5', + family: 'opus', + majorVersion: 5, + minorVersion: null, + }); + }); + + it('should parse new format without date: claude-opus-4-6', () => { + const result = parseModelString('claude-opus-4-6'); + expect(result).toEqual({ + name: 'opus4.6', + family: 'opus', + majorVersion: 4, + minorVersion: 6, + }); + }); + + it('should parse new format: claude-haiku-3-20240307', () => { + const result = parseModelString('claude-haiku-3-20240307'); + expect(result).toEqual({ + name: 'haiku3', + family: 'haiku', + majorVersion: 3, + minorVersion: null, + }); + }); + + // Old format tests: claude-{major}[-{minor}]-{family}-{date} + it('should parse old format: claude-3-opus-20240229', () => { + const result = parseModelString('claude-3-opus-20240229'); + expect(result).toEqual({ + name: 'opus3', + family: 'opus', + majorVersion: 3, + minorVersion: null, + }); + }); + + it('should parse old format with minor: claude-3-5-sonnet-20241022', () => { + const result = parseModelString('claude-3-5-sonnet-20241022'); + expect(result).toEqual({ + name: 'sonnet3.5', + family: 'sonnet', + majorVersion: 3, + minorVersion: 5, + }); + }); + + it('should handle case insensitivity', () => { + const result = parseModelString('CLAUDE-SONNET-4-5-20250929'); + expect(result).toEqual({ + name: 'sonnet4.5', + family: 'sonnet', + majorVersion: 4, + minorVersion: 5, + }); + }); + + it('should handle whitespace', () => { + const result = parseModelString(' claude-sonnet-4-5-20250929 '); + expect(result).toEqual({ + name: 'sonnet4.5', + family: 'sonnet', + majorVersion: 4, + minorVersion: 5, + }); + }); + + it('should return null for invalid format with only two parts', () => { + expect(parseModelString('claude-sonnet')).toBeNull(); + }); + + it('should handle unknown model families', () => { + const result = parseModelString('claude-newmodel-4-5-20250929'); + expect(result).toEqual({ + name: 'newmodel4.5', + family: 'newmodel', + majorVersion: 4, + minorVersion: 5, + }); + }); + }); + + describe('getModelColorClass', () => { + it('should return color for opus', () => { + expect(getModelColorClass('opus')).toBe('text-zinc-400'); + }); + + it('should return color for sonnet', () => { + expect(getModelColorClass('sonnet')).toBe('text-zinc-400'); + }); + + it('should return color for haiku', () => { + expect(getModelColorClass('haiku')).toBe('text-zinc-400'); + }); + + it('should return default color for unknown family', () => { + expect(getModelColorClass('unknown')).toBe('text-zinc-500'); + }); + + it('should return default color for arbitrary string', () => { + expect(getModelColorClass('newmodel')).toBe('text-zinc-500'); + }); + }); +}); diff --git a/test/shared/utils/tokenFormatting.test.ts b/test/shared/utils/tokenFormatting.test.ts new file mode 100644 index 00000000..73cd88ef --- /dev/null +++ b/test/shared/utils/tokenFormatting.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + estimateContentTokens, + estimateTokens, + formatTokens, + formatTokensCompact, + formatTokensDetailed, +} from '../../../src/shared/utils/tokenFormatting'; + +describe('tokenFormatting', () => { + describe('formatTokensCompact', () => { + it('should format small numbers as-is', () => { + expect(formatTokensCompact(500)).toBe('500'); + }); + + it('should format thousands with k suffix', () => { + expect(formatTokensCompact(1500)).toBe('1.5k'); + }); + + it('should format exact thousands', () => { + expect(formatTokensCompact(1000)).toBe('1.0k'); + }); + + it('should format millions with M suffix', () => { + expect(formatTokensCompact(1500000)).toBe('1.5M'); + }); + + it('should format exact millions', () => { + expect(formatTokensCompact(1000000)).toBe('1.0M'); + }); + + it('should handle zero', () => { + expect(formatTokensCompact(0)).toBe('0'); + }); + }); + + describe('formatTokens', () => { + it('should format small numbers as-is', () => { + expect(formatTokens(500)).toBe('500'); + }); + + it('should format 1k-10k with one decimal', () => { + expect(formatTokens(1500)).toBe('1.5k'); + expect(formatTokens(9999)).toBe('10.0k'); + }); + + it('should format 10k+ as whole numbers', () => { + expect(formatTokens(15000)).toBe('15k'); + expect(formatTokens(50000)).toBe('50k'); + }); + + it('should handle exact thousands', () => { + expect(formatTokens(1000)).toBe('1.0k'); + expect(formatTokens(10000)).toBe('10k'); + }); + }); + + describe('formatTokensDetailed', () => { + it('should format with locale separators', () => { + // Note: This test may vary by locale + const result = formatTokensDetailed(1000); + expect(result).toContain('1'); + expect(result.length).toBeGreaterThan(3); + }); + + it('should format large numbers', () => { + const result = formatTokensDetailed(1000000); + expect(result).toContain('1'); + expect(result.length).toBeGreaterThan(6); + }); + }); + + describe('estimateTokens', () => { + it('should return 0 for empty string', () => { + expect(estimateTokens('')).toBe(0); + }); + + it('should return 0 for null', () => { + expect(estimateTokens(null)).toBe(0); + }); + + it('should return 0 for undefined', () => { + expect(estimateTokens(undefined)).toBe(0); + }); + + it('should estimate tokens by dividing length by 4', () => { + // 12 chars / 4 = 3 tokens + expect(estimateTokens('Hello World!')).toBe(3); + }); + + it('should ceil the result', () => { + // 5 chars / 4 = 1.25, ceil to 2 + expect(estimateTokens('Hello')).toBe(2); + }); + }); + + describe('estimateContentTokens', () => { + it('should handle string content', () => { + expect(estimateContentTokens('Hello World!')).toBe(3); + }); + + it('should handle array content by stringifying', () => { + const content = [{ type: 'text', text: 'Hello' }]; + const stringified = JSON.stringify(content); + expect(estimateContentTokens(content)).toBe(Math.ceil(stringified.length / 4)); + }); + + it('should handle object content by stringifying', () => { + const content = { type: 'text', text: 'Hello' }; + const stringified = JSON.stringify(content); + expect(estimateContentTokens(content)).toBe(Math.ceil(stringified.length / 4)); + }); + + it('should return 0 for null', () => { + expect(estimateContentTokens(null)).toBe(0); + }); + + it('should return 0 for undefined', () => { + expect(estimateContentTokens(undefined)).toBe(0); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..60884143 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@main/*": ["./src/main/*"], + "@renderer/*": ["./src/renderer/*"], + "@preload/*": ["./src/preload/*"], + "@shared/*": ["./src/shared/*"] + }, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "dist-electron"] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 00000000..65b03beb --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@main/*": ["./src/main/*"], + "@preload/*": ["./src/preload/*"], + "@shared/*": ["./src/shared/*"] + }, + "types": ["node"] + }, + "include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..5576680c --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "vitest/globals"] + }, + "include": ["test/**/*", "src/**/*"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..50338e85 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + setupFiles: ['./test/setup.ts'], + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts', 'src/**/*.tsx'], + exclude: ['src/**/*.d.ts', 'src/main/index.ts', 'src/preload/index.ts'], + }, + }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main'), + '@renderer': resolve(__dirname, 'src/renderer'), + }, + }, +}); diff --git a/vitest.critical.config.ts b/vitest.critical.config.ts new file mode 100644 index 00000000..c32070bf --- /dev/null +++ b/vitest.critical.config.ts @@ -0,0 +1,34 @@ +import { resolve } from 'path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'happy-dom', + setupFiles: ['./test/setup.ts'], + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: [ + 'src/main/ipc/guards.ts', + 'src/main/ipc/configValidation.ts', + 'src/main/utils/pathDecoder.ts', + 'src/main/services/discovery/ProjectPathResolver.ts', + ], + thresholds: { + lines: 65, + functions: 75, + branches: 60, + statements: 65, + }, + }, + }, + resolve: { + alias: { + '@shared': resolve(__dirname, 'src/shared'), + '@main': resolve(__dirname, 'src/main'), + '@renderer': resolve(__dirname, 'src/renderer'), + }, + }, +});