diff --git a/.github/CHROMATIC_SETUP.md b/.github/CHROMATIC_SETUP.md new file mode 100644 index 0000000000..df057b0df5 --- /dev/null +++ b/.github/CHROMATIC_SETUP.md @@ -0,0 +1,72 @@ +# Chromatic Visual Regression Testing + +This repository uses Chromatic for automated visual regression testing of Storybook components. + +## Setup Complete ✅ + +The following has been configured: + +- ✅ Chromatic CLI installed (`chromatic@13.3.0`) +- ✅ GitHub Actions workflow (`.github/workflows/chromatic.yml`) +- ✅ Configuration file (`chromatic.config.json`) +- ✅ Project token added to GitHub Secrets +- ✅ Documentation updated + +## How It Works + +### On Pull Requests +1. Chromatic captures snapshots of all Storybook stories +2. Compares snapshots to the baseline from `main` +3. Posts visual diffs as PR comments +4. Build passes (won't block merge even with visual changes) +5. Developers review changes in Chromatic UI + +### On Main Branch +- Changes are automatically accepted as the new baseline +- All future PRs compare against this baseline + +## TurboSnap Optimization + +**TurboSnap** is enabled to speed up builds: +- Only captures snapshots for changed stories +- Typical build time: ~30 seconds +- Requires full git history (`fetch-depth: 0`) + +## Commands + +```bash +# Run Chromatic locally (requires CHROMATIC_PROJECT_TOKEN env var) +bun run chromatic + +# Build Storybook +bun run build-storybook + +# Run Storybook dev server +bun run storybook +``` + +## Configuration Files + +- `.github/workflows/chromatic.yml` - CI workflow +- `chromatic.config.json` - Chromatic settings +- `STORYBOOK.md` - Full Storybook documentation + +## Accessing Chromatic + +Visit https://www.chromatic.com/ and log in with your GitHub account to: +- View detailed visual diffs +- Approve/reject changes +- See build history +- Manage baselines + +## Complements E2E Tests + +Chromatic works alongside your existing e2e tests: +- **Chromatic**: Tests visual appearance of isolated components +- **E2E tests**: Test behavior and user interactions +- **No overlap**: Different concerns with minimal maintenance burden + +## Free for Open Source + +This project qualifies for Chromatic's free open source plan with unlimited builds. + diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 0000000000..d2430b7bd1 --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,33 @@ +name: Chromatic + +on: + push: + branches: [main] + pull_request: + branches: ["**"] + +jobs: + chromatic: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Required for Chromatic TurboSnap + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Publish to Chromatic + uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + buildScriptName: build-storybook + onlyChanged: true # Only test changed stories (TurboSnap) + exitZeroOnChanges: true # Don't fail on visual changes + autoAcceptChanges: main # Auto-accept changes on main branch diff --git a/.gitignore b/.gitignore index e9d46f791f..1cbc329397 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,8 @@ docs/mermaid.min.js # Profiling **.cpuprofile profile.txt + +*storybook.log +storybook-static +chromatic-*.log +build-storybook.log diff --git a/.storybook/main.ts b/.storybook/main.ts new file mode 100644 index 0000000000..0e60551ab1 --- /dev/null +++ b/.storybook/main.ts @@ -0,0 +1,54 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +import type { UserConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +const config: StorybookConfig = { + "stories": [ + "../src/**/*.mdx", + "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" + ], + "addons": [ + "@chromatic-com/storybook", + "@storybook/addon-docs", + "@storybook/addon-onboarding", + "@storybook/addon-a11y", + "@storybook/addon-vitest" + ], + "framework": { + "name": "@storybook/react-vite", + "options": {} + }, + async viteFinal(config: UserConfig) { + // Remove any existing Vite React plugins that Storybook registers + config.plugins = (config.plugins || []).filter((plugin) => { + if (!plugin) return true; + const pluginName = Array.isArray(plugin) ? plugin[0]?.name : plugin.name; + return !pluginName?.includes('vite:react'); + }); + + // Re-register the React plugin with Emotion configuration + config.plugins.push( + react({ + exclude: [/\.stories\.(t|j)sx?$/, /node_modules/], + jsxImportSource: '@emotion/react', + babel: { + plugins: ['@emotion/babel-plugin'], + }, + }) + ); + + // Pre-bundle Emotion packages to reduce cold start time + config.optimizeDeps = { + ...config.optimizeDeps, + include: [ + ...(config.optimizeDeps?.include || []), + '@emotion/react', + '@emotion/styled', + '@emotion/cache', + ], + }; + + return config; + }, +}; +export default config; diff --git a/.storybook/manager.ts b/.storybook/manager.ts new file mode 100644 index 0000000000..9d5347529a --- /dev/null +++ b/.storybook/manager.ts @@ -0,0 +1,6 @@ +import { addons } from '@storybook/manager-api'; +import { themes } from '@storybook/theming'; + +addons.setConfig({ + theme: themes.dark, +}); diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx new file mode 100644 index 0000000000..5fa9cc3c96 --- /dev/null +++ b/.storybook/preview.tsx @@ -0,0 +1,72 @@ +import type { Preview } from '@storybook/react-vite' +import { themes } from '@storybook/theming'; +import { Global, css } from '@emotion/react'; +import { CacheProvider } from '@emotion/react'; +import createCache from '@emotion/cache'; +import { GlobalColors } from '../src/styles/colors'; +import { GlobalFonts } from '../src/styles/fonts'; +import React from 'react'; + +// Create emotion cache for consistent styling +const emotionCache = createCache({ key: 'cmux' }); + +// Base styles matching the app +const globalStyles = css` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: var(--font-primary); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: var(--color-text); + } + + code { + font-family: var(--font-monospace); + } +`; + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + backgrounds: { + default: 'dark', + values: [ + { + name: 'dark', + value: 'hsl(0 0% 12%)', + }, + { + name: 'light', + value: '#ffffff', + }, + ], + }, + docs: { + theme: themes.dark, + }, + }, + decorators: [ + (Story) => ( + + + + + + + ), + ], +}; + +export default preview; \ No newline at end of file diff --git a/STORYBOOK.md b/STORYBOOK.md new file mode 100644 index 0000000000..cb154d5feb --- /dev/null +++ b/STORYBOOK.md @@ -0,0 +1,109 @@ +# Storybook + +This project uses [Storybook](https://storybook.js.org/) for component development and documentation. + +## Running Storybook + +To start Storybook in development mode: + +```bash +bun run storybook +``` + +This will start Storybook on http://localhost:6006 + +## Building Storybook + +To build a static version of Storybook: + +```bash +bun run build-storybook +``` + +The output will be in the `storybook-static` directory. + +## Creating Stories + +Stories are located next to their components with the `.stories.tsx` extension. + +Example structure: + +``` +src/components/Messages/ +├── AssistantMessage.tsx +└── AssistantMessage.stories.tsx +``` + +### Example Story + +```typescript +import type { Meta, StoryObj } from "@storybook/react"; +import { MyComponent } from "./MyComponent"; + +const meta = { + title: "Category/MyComponent", + component: MyComponent, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + // component props + }, +}; +``` + +## Current Stories + +- **Messages/AssistantMessage**: Various states of assistant messages including streaming, partial, with models, etc. + +## Configuration + +- `.storybook/main.ts` - Main Storybook configuration +- `.storybook/preview.tsx` - Preview configuration with global styles and dark theme for docs +- `.storybook/manager.ts` - Manager (UI chrome) configuration with dark theme + +## Theme + +Storybook is configured with a dark theme to match the application: + +- Dark UI chrome (sidebar, toolbar, etc.) +- Dark documentation pages +- Dark canvas background (`hsl(0 0% 12%)`) matching the app's `--color-background` + +## Visual Regression Testing with Chromatic + +This project uses [Chromatic](https://www.chromatic.com/) for automated visual regression testing. + +### How it works + +- **On PRs**: Chromatic captures snapshots of all stories and compares them to the baseline +- **On main**: Changes are automatically accepted as the new baseline +- **TurboSnap**: Only changed stories are tested, making builds fast (~30s typical) + +### Running Chromatic locally + +```bash +bun run chromatic +``` + +You'll need a `CHROMATIC_PROJECT_TOKEN` environment variable set. + +### CI Integration + +Chromatic runs automatically in CI via `.github/workflows/chromatic.yml`: + +- Runs on all PRs and pushes to main +- Visual diffs are shown inline in PR comments +- Won't fail the build on visual changes (for review) + +### Configuration + +- `chromatic.config.json` - Chromatic settings (TurboSnap, skip patterns, etc.) +- See [Chromatic docs](https://www.chromatic.com/docs/) for more options diff --git a/bun.lock b/bun.lock index 8f01e835aa..babe1b851e 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@ai-sdk/openai": "^2.0.40", "@anthropic-ai/sdk": "^0.63.1", "@dqbd/tiktoken": "^1.0.21", + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "ai": "^5.0.56", @@ -1959,7 +1960,7 @@ "style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="], - "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], "sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="], @@ -2167,10 +2168,6 @@ "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], - "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], - - "@emotion/cache/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -2295,6 +2292,8 @@ "mdast-util-mdx-jsx/parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + "mermaid/stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/chromatic.config.json b/chromatic.config.json new file mode 100644 index 0000000000..2107b6c2bc --- /dev/null +++ b/chromatic.config.json @@ -0,0 +1,6 @@ +{ + "buildScriptName": "build-storybook", + "onlyChanged": true, + "skip": "dependabot/**", + "externals": ["**/.storybook/public/**"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index ea016c27f2..e36999574b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,6 @@ +// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format +import storybook from "eslint-plugin-storybook"; + import js from "@eslint/js"; import { defineConfig } from "eslint/config"; import react from "eslint-plugin-react"; diff --git a/package.json b/package.json index c669ddc662..32fd1f4b46 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ "dist:linux": "bun run build && electron-builder --linux --publish never", "docs": "./scripts/docs.sh", "docs:build": "./scripts/docs_build.sh", - "docs:watch": "cd docs && mdbook watch" + "docs:watch": "cd docs && mdbook watch", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "chromatic": "chromatic --exit-zero-on-changes" }, "dependencies": { "@ai-sdk/anthropic": "^2.0.20", @@ -37,6 +40,7 @@ "@ai-sdk/openai": "^2.0.40", "@anthropic-ai/sdk": "^0.63.1", "@dqbd/tiktoken": "^1.0.21", + "@emotion/cache": "^11.14.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "ai": "^5.0.56", @@ -59,7 +63,16 @@ "zod-to-json-schema": "^3.24.6" }, "devDependencies": { + "@chromatic-com/storybook": "^4.1.1", + "@emotion/babel-plugin": "^11.13.5", "@eslint/js": "^9.36.0", + "@storybook/addon-a11y": "^9.1.10", + "@storybook/addon-docs": "^9.1.10", + "@storybook/addon-onboarding": "^9.1.10", + "@storybook/addon-vitest": "^9.1.10", + "@storybook/manager-api": "^8.6.14", + "@storybook/react-vite": "^9.1.10", + "@storybook/theming": "^8.6.14", "@testing-library/react": "^16.3.0", "@types/bun": "^1.2.23", "@types/diff": "^8.0.0", @@ -72,6 +85,7 @@ "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", "@vitejs/plugin-react": "^4.0.0", + "chromatic": "^13.3.0", "concurrently": "^8.2.0", "dotenv": "^17.2.3", "electron": "^38.2.1", @@ -80,8 +94,10 @@ "eslint": "^9.36.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-storybook": "^9.1.10", "jest": "^30.1.3", "prettier": "^3.6.2", + "storybook": "^9.1.10", "ts-jest": "^29.4.4", "tsc-alias": "^1.8.16", "typescript": "^5.1.3", diff --git a/scripts/wait_pr_checks.sh b/scripts/wait_pr_checks.sh index f25ff24ed0..43cfd9a110 100755 --- a/scripts/wait_pr_checks.sh +++ b/scripts/wait_pr_checks.sh @@ -15,35 +15,27 @@ echo "" while true; do # Get PR status - STATUS=$(gh pr view "$PR_NUMBER" --json mergeable,mergeStateStatus,state 2>/dev/null || echo "error") - + STATUS=$(gh pr view "$PR_NUMBER" --json mergeable,mergeStateStatus 2>/dev/null || echo "error") + if [ "$STATUS" = "error" ]; then echo "❌ Failed to get PR status. Does PR #$PR_NUMBER exist?" exit 1 fi - - PR_STATE=$(echo "$STATUS" | jq -r '.state') - - # Check if PR is already merged - if [ "$PR_STATE" = "MERGED" ]; then - echo "✅ PR #$PR_NUMBER has been merged!" - exit 0 - fi - + MERGEABLE=$(echo "$STATUS" | jq -r '.mergeable') MERGE_STATE=$(echo "$STATUS" | jq -r '.mergeStateStatus') - + # Check for bad merge status if [ "$MERGEABLE" = "CONFLICTING" ]; then echo "❌ PR has merge conflicts!" exit 1 fi - + if [ "$MERGE_STATE" = "DIRTY" ]; then echo "❌ PR has merge conflicts!" exit 1 fi - + if [ "$MERGE_STATE" = "BEHIND" ]; then echo "❌ PR is behind base branch. Rebase needed." echo "" @@ -53,10 +45,10 @@ while true; do echo " git push --force-with-lease" exit 1 fi - + # Get check status CHECKS=$(gh pr checks "$PR_NUMBER" 2>&1 || echo "pending") - + # Check for failures if echo "$CHECKS" | grep -q "fail"; then echo "❌ Some checks failed:" @@ -64,7 +56,7 @@ while true; do gh pr checks "$PR_NUMBER" exit 1 fi - + # Check if all checks passed and merge state is clean if echo "$CHECKS" | grep -q "pass" && ! echo "$CHECKS" | grep -qE "pending|fail"; then if [ "$MERGE_STATE" = "CLEAN" ]; then @@ -90,6 +82,6 @@ while true; do # Show current status echo -ne "\r⏳ Checks in progress... (${MERGE_STATE}) " fi - + sleep 5 done diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index 5c3aef98b6..c5e01c5217 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -1,5 +1,13 @@ +<<<<<<< HEAD import React, { useState, useCallback, useEffect } from "react"; import styled from "@emotion/styled"; +||||||| parent of 16a6df0 (Fix Chromatic compatibility by removing styled shim) +import React, { useState, useEffect, useCallback } from "react"; +import styled from "@/styles/styled"; +======= +import React, { useState, useEffect, useCallback } from "react"; +import styled from "@emotion/styled"; +>>>>>>> 16a6df0 (Fix Chromatic compatibility by removing styled shim) import { MessageRenderer } from "./Messages/MessageRenderer"; import { InterruptedBarrier } from "./Messages/ChatBarrier/InterruptedBarrier"; import { StreamingBarrier } from "./Messages/ChatBarrier/StreamingBarrier"; diff --git a/src/components/Messages/AssistantMessage.stories.tsx b/src/components/Messages/AssistantMessage.stories.tsx new file mode 100644 index 0000000000..ade5bae054 --- /dev/null +++ b/src/components/Messages/AssistantMessage.stories.tsx @@ -0,0 +1,117 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { AssistantMessage } from "./AssistantMessage"; +import type { DisplayedMessage } from "@/types/message"; + +const meta = { + title: "Messages/AssistantMessage", + component: AssistantMessage, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper to create assistant message data +const createAssistantMessage = ( + overrides?: Partial +): DisplayedMessage & { type: "assistant" } => ({ + type: "assistant", + id: "msg-1", + historyId: "hist-1", + content: "This is a sample assistant message with **markdown** support.", + historySequence: 1, + streamSequence: 0, + isStreaming: false, + isPartial: false, + timestamp: Date.now(), + ...overrides, +}); + +export const Default: Story = { + args: { + message: createAssistantMessage(), + }, +}; + +export const WithModel: Story = { + args: { + message: createAssistantMessage({ + model: "Claude-3.5-Sonnet", + content: "I'm a message from Claude 3.5 Sonnet with the model name displayed.", + }), + }, +}; + +export const LongContent: Story = { + args: { + message: createAssistantMessage({ + content: `This is a longer assistant message that demonstrates how the component handles multiple paragraphs and more complex markdown formatting. + +## Features +- **Bold text** for emphasis +- *Italic text* for subtle emphasis +- \`inline code\` for technical terms + +### Code Blocks +Here's an example: + +\`\`\`typescript +function greet(name: string): string { + return \`Hello, \${name}!\`; +} +\`\`\` + +The component should handle all of this gracefully with proper formatting.`, + }), + }, +}; + +export const Streaming: Story = { + args: { + message: createAssistantMessage({ + content: "This message is currently streaming...", + isStreaming: true, + }), + }, +}; + +export const StreamingEmpty: Story = { + args: { + message: createAssistantMessage({ + content: "", + isStreaming: true, + }), + }, +}; + +export const PartialMessage: Story = { + args: { + message: createAssistantMessage({ + content: "This message was interrupted and is incomplete", + isPartial: true, + model: "gpt-4", + }), + }, +}; + +export const WithTokenCount: Story = { + args: { + message: createAssistantMessage({ + content: "This message includes token usage information.", + tokens: 1250, + model: "claude-3-opus", + }), + }, +}; + +export const EmptyContent: Story = { + args: { + message: createAssistantMessage({ + content: "", + isStreaming: false, + }), + }, +}; diff --git a/src/components/Messages/MessageWindow.tsx b/src/components/Messages/MessageWindow.tsx index dcb5506376..2d79738302 100644 --- a/src/components/Messages/MessageWindow.tsx +++ b/src/components/Messages/MessageWindow.tsx @@ -1,6 +1,14 @@ import type { ReactNode } from "react"; +<<<<<<< HEAD import React, { useState, useMemo } from "react"; import styled from "@emotion/styled"; +||||||| parent of 16a6df0 (Fix Chromatic compatibility by removing styled shim) +import React, { useState } from "react"; +import styled from "@/styles/styled"; +======= +import React, { useState } from "react"; +import styled from "@emotion/styled"; +>>>>>>> 16a6df0 (Fix Chromatic compatibility by removing styled shim) import type { CmuxMessage, DisplayedMessage } from "@/types/message"; import { HeaderButton } from "../tools/shared/ToolPrimitives"; import { formatTimestamp } from "@/utils/ui/dateTime"; diff --git a/src/components/Messages/ToolMessage.stories.tsx b/src/components/Messages/ToolMessage.stories.tsx new file mode 100644 index 0000000000..ed483b3fdc --- /dev/null +++ b/src/components/Messages/ToolMessage.stories.tsx @@ -0,0 +1,449 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ToolMessage } from "./ToolMessage"; +import type { DisplayedMessage } from "@/types/message"; +import type { BashToolResult, FileEditReplaceToolResult } from "@/types/tools"; + +const meta = { + title: "Messages/ToolMessage", + component: ToolMessage, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Helper to create tool message data +const createToolMessage = ( + overrides?: Partial +): DisplayedMessage & { type: "tool" } => ({ + type: "tool", + id: "tool-1", + historyId: "hist-1", + toolCallId: "call-1", + toolName: "bash", + args: { + script: "echo 'Hello World'", + timeout_secs: 10, + max_lines: 1000, + }, + status: "completed", + isPartial: false, + historySequence: 1, + timestamp: Date.now(), + ...overrides, +}); + +// Bash Tool Stories +export const BashSuccess: Story = { + args: { + message: createToolMessage({ + toolName: "bash", + args: { + script: "ls -la src/components", + timeout_secs: 10, + max_lines: 1000, + }, + result: { + success: true, + exitCode: 0, + output: `total 64 +drwxr-xr-x 16 user staff 512 Oct 5 18:57 . +drwxr-xr-x 8 user staff 256 Oct 5 18:57 .. +-rw-r--r-- 1 user staff 1234 Oct 5 18:57 AIView.tsx +-rw-r--r-- 1 user staff 2345 Oct 5 18:57 ChatInput.tsx +-rw-r--r-- 1 user staff 3456 Oct 5 18:57 ErrorBoundary.tsx`, + wall_duration_ms: 234, + } satisfies BashToolResult, + status: "completed", + }), + }, +}; + +export const BashFailure: Story = { + args: { + message: createToolMessage({ + toolName: "bash", + args: { + script: "cat nonexistent-file.txt", + timeout_secs: 10, + max_lines: 1000, + }, + result: { + success: false, + exitCode: 1, + output: "cat: nonexistent-file.txt: No such file or directory", + error: "Command exited with code 1", + wall_duration_ms: 45, + } satisfies BashToolResult, + status: "failed", + }), + }, +}; + +export const BashExecuting: Story = { + args: { + message: createToolMessage({ + toolName: "bash", + args: { + script: "bun run build && bun run test", + timeout_secs: 120, + max_lines: 1000, + }, + status: "executing", + }), + }, +}; + +export const BashLongOutput: Story = { + args: { + message: createToolMessage({ + toolName: "bash", + args: { + script: "find . -name '*.tsx' | head -20", + timeout_secs: 30, + max_lines: 1000, + }, + result: { + success: true, + exitCode: 0, + output: `./src/App.tsx +./src/components/AIView.tsx +./src/components/ChatInput.tsx +./src/components/ChatInputToast.tsx +./src/components/ErrorBoundary.tsx +./src/components/Messages/AssistantMessage.tsx +./src/components/Messages/MarkdownRenderer.tsx +./src/components/Messages/ToolMessage.tsx +./src/components/Messages/UserMessage.tsx +./src/components/NewWorkspaceModal.tsx +./src/components/ProjectSidebar.tsx +./src/components/tools/BashToolCall.tsx +./src/components/tools/FileEditToolCall.tsx +./src/components/tools/GenericToolCall.tsx +./src/main.tsx +./src/styles/colors.tsx +./src/styles/fonts.tsx +./.storybook/preview.tsx +./tests/setup.tsx +./tests/integration/basic.test.tsx`, + wall_duration_ms: 1234, + } satisfies BashToolResult, + status: "completed", + }), + }, +}; + +// File Edit Tool Stories +export const FileEditReplaceSuccess: Story = { + args: { + message: createToolMessage({ + toolName: "file_edit_replace", + args: { + file_path: "src/components/Messages/AssistantMessage.tsx", + edits: [ + { + old_string: "const [showRaw, setShowRaw] = useState(false);", + new_string: "const [showRaw, setShowRaw] = useState(true);", + }, + ], + lease: "abc123", + }, + result: { + success: true, + edits_applied: 1, + lease: "def456", + diff: `Index: src/components/Messages/AssistantMessage.tsx +=================================================================== +--- src/components/Messages/AssistantMessage.tsx ++++ src/components/Messages/AssistantMessage.tsx +@@ -46,7 +46,7 @@ + + export const AssistantMessage: React.FC = ({ message, className }) => { +- const [showRaw, setShowRaw] = useState(false); ++ const [showRaw, setShowRaw] = useState(true); + const [copied, setCopied] = useState(false); + + const content = message.content;`, + } satisfies FileEditReplaceToolResult, + status: "completed", + }), + }, +}; + +export const FileEditInsertSuccess: Story = { + args: { + message: createToolMessage({ + toolName: "file_edit_insert", + args: { + file_path: "src/types/message.ts", + line_offset: 10, + content: " // New comment added\n export type NewType = string;\n", + lease: "xyz789", + }, + result: { + success: true, + edits_applied: 1, + lease: "uvw012", + diff: `Index: src/types/message.ts +=================================================================== +--- src/types/message.ts ++++ src/types/message.ts +@@ -8,6 +8,8 @@ + export interface CmuxMetadata { + historySequence?: number; + duration?: number; ++ // New comment added ++ export type NewType = string; + timestamp?: number; + model?: string; + }`, + } satisfies FileEditReplaceToolResult, + status: "completed", + }), + }, +}; + +export const FileEditFailure: Story = { + args: { + message: createToolMessage({ + toolName: "file_edit_replace", + args: { + file_path: "src/nonexistent.tsx", + edits: [ + { + old_string: "old code", + new_string: "new code", + }, + ], + lease: "bad123", + }, + result: { + success: false, + error: "File not found: src/nonexistent.tsx", + } satisfies FileEditReplaceToolResult, + status: "failed", + }), + }, +}; + +export const FileEditExecuting: Story = { + args: { + message: createToolMessage({ + toolName: "file_edit_replace", + args: { + file_path: "src/components/AIView.tsx", + edits: [ + { + old_string: "old implementation", + new_string: "new implementation", + }, + ], + lease: "pending123", + }, + status: "executing", + }), + }, +}; + +export const FileEditMultipleEdits: Story = { + args: { + message: createToolMessage({ + toolName: "file_edit_replace", + args: { + file_path: "src/config.ts", + edits: [ + { + old_string: "export const API_TIMEOUT = 30000;", + new_string: "export const API_TIMEOUT = 60000;", + }, + { + old_string: "export const MAX_RETRIES = 3;", + new_string: "export const MAX_RETRIES = 5;", + }, + ], + lease: "multi123", + }, + result: { + success: true, + edits_applied: 2, + lease: "multi456", + diff: `Index: src/config.ts +=================================================================== +--- src/config.ts ++++ src/config.ts +@@ -1,5 +1,5 @@ +-export const API_TIMEOUT = 30000; +-export const MAX_RETRIES = 3; ++export const API_TIMEOUT = 60000; ++export const MAX_RETRIES = 5; + export const DEBUG = false;`, + } satisfies FileEditReplaceToolResult, + status: "completed", + }), + }, +}; + +// File Read Tool Stories +export const FileReadSuccess: Story = { + args: { + message: createToolMessage({ + toolName: "file_read", + args: { + filePath: "src/components/Messages/AssistantMessage.tsx", + }, + result: { + success: true, + file_size: 3199, + modifiedTime: "2025-10-05T23:57:48.206Z", + lines_read: 126, + content: `import React, { useState } from "react"; +import styled from "@emotion/styled"; +import type { DisplayedMessage } from "@/types/message"; +import { MarkdownRenderer } from "./MarkdownRenderer"; +import { TypewriterMarkdown } from "./TypewriterMarkdown"; +import type { ButtonConfig } from "./MessageWindow"; +import { MessageWindow } from "./MessageWindow"; + +const RawContent = styled.pre\` + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.6; + color: var(--color-text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +\`; + +const WaitingMessage = styled.div\` + font-family: var(--font-primary); + font-size: 13px; + color: var(--color-text-secondary); + font-style: italic; +\`;`, + lease: "abc123", + }, + status: "completed", + }), + }, +}; + +export const FileReadPartial: Story = { + args: { + message: createToolMessage({ + toolName: "file_read", + args: { + filePath: "src/types/message.ts", + offset: 1, + limit: 20, + }, + result: { + success: true, + file_size: 5344, + modifiedTime: "2025-10-05T23:57:48.222Z", + lines_read: 20, + content: `import type { UIMessage } from "ai"; +import type { LanguageModelV2Usage } from "@ai-sdk/provider"; +import type { StreamErrorType } from "./errors"; + +// Our custom metadata type +export interface CmuxMetadata { + historySequence?: number; + duration?: number; + timestamp?: number; + model?: string; + usage?: LanguageModelV2Usage; + providerMetadata?: Record; + systemMessageTokens?: number; + partial?: boolean; + synthetic?: boolean; + error?: string; + errorType?: StreamErrorType; +} + +// Extended tool part type`, + lease: "def456", + }, + status: "completed", + }), + }, +}; + +export const FileReadFailure: Story = { + args: { + message: createToolMessage({ + toolName: "file_read", + args: { + filePath: "src/nonexistent/file.tsx", + }, + result: { + success: false, + error: "ENOENT: no such file or directory, open 'src/nonexistent/file.tsx'", + }, + status: "failed", + }), + }, +}; + +export const FileReadExecuting: Story = { + args: { + message: createToolMessage({ + toolName: "file_read", + args: { + filePath: "src/components/AIView.tsx", + offset: 50, + limit: 100, + }, + status: "executing", + }), + }, +}; + +export const FileReadLargeFile: Story = { + args: { + message: createToolMessage({ + toolName: "file_read", + args: { + filePath: "package-lock.json", + }, + result: { + success: true, + file_size: 524288, // 512KB + modifiedTime: "2025-10-05T23:57:48.222Z", + lines_read: 15234, + content: `{ + "name": "cmux", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cmux", + "version": "0.0.1", + "dependencies": { + "@ai-sdk/anthropic": "^2.0.20", + "@ai-sdk/google": "^2.0.17", + "@ai-sdk/openai": "^2.0.40", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "typescript": "^5.1.3", + "vite": "^4.4.0" + } + } + } +}`, + lease: "large123", + }, + status: "completed", + }), + }, +}; diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index 2dc99208ff..607a1e3977 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -3,10 +3,13 @@ import type { DisplayedMessage } from "@/types/message"; import { GenericToolCall } from "../tools/GenericToolCall"; import { BashToolCall } from "../tools/BashToolCall"; import { FileEditToolCall } from "../tools/FileEditToolCall"; +import { FileReadToolCall } from "../tools/FileReadToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import type { BashToolArgs, BashToolResult, + FileReadToolArgs, + FileReadToolResult, FileEditReplaceToolArgs, FileEditInsertToolArgs, FileEditReplaceToolResult, @@ -31,6 +34,13 @@ function isBashTool(toolName: string, args: unknown): args is BashToolArgs { ); } +// Type guard for file_read tool +function isFileReadTool(toolName: string, args: unknown): args is FileReadToolArgs { + return ( + toolName === "file_read" && typeof args === "object" && args !== null && "filePath" in args + ); +} + // Type guard for file_edit_replace tool function isFileEditReplaceTool(toolName: string, args: unknown): args is FileEditReplaceToolArgs { return ( @@ -75,6 +85,18 @@ export const ToolMessage: React.FC = ({ message, className }) ); } + if (isFileReadTool(message.toolName, message.args)) { + return ( +
+ +
+ ); + } + if (isFileEditReplaceTool(message.toolName, message.args)) { return (
diff --git a/src/components/tools/BashToolCall.module.css b/src/components/tools/BashToolCall.module.css new file mode 100644 index 0000000000..21aa67d860 --- /dev/null +++ b/src/components/tools/BashToolCall.module.css @@ -0,0 +1,59 @@ +.scriptPreview { + color: var(--color-text); + font-family: var(--font-monospace); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +} + +.outputBlock { + margin: 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + border-left: 2px solid #4caf50; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +.exitCodeBadge { + display: inline-block; + padding: 2px 6px; + color: white; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + margin-left: 8px; +} + +.exitCodeBadge.success { + background: #4caf50; +} + +.exitCodeBadge.failure { + background: #f44336; +} + +.timeoutInfo { + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 8px; +} + +.timeoutInfo.active { + color: var(--color-pending); +} + +.errorMessage { + color: #f44336; + font-size: 11px; + padding: 6px 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 3px; + border-left: 2px solid #f44336; +} diff --git a/src/components/tools/BashToolCall.tsx b/src/components/tools/BashToolCall.tsx index c4815fa4ec..fbf8e641cc 100644 --- a/src/components/tools/BashToolCall.tsx +++ b/src/components/tools/BashToolCall.tsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useRef } from "react"; -import styled from "@emotion/styled"; import type { BashToolArgs, BashToolResult } from "@/types/tools"; import { ToolContainer, @@ -14,65 +13,7 @@ import { LoadingDots, } from "./shared/ToolPrimitives"; import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; - -// Bash-specific styled components - -const ScriptPreview = styled.span` - color: var(--color-text); - font-family: var(--font-monospace); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 400px; -`; - -const OutputBlock = styled.pre` - margin: 0; - padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 3px; - border-left: 2px solid #4caf50; - font-size: 11px; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; - max-height: 200px; - overflow-y: auto; -`; - -const ExitCodeBadge = styled.span<{ exitCode: number }>` - display: inline-block; - padding: 2px 6px; - background: ${(props) => (props.exitCode === 0 ? "#4caf50" : "#f44336")}; - color: white; - border-radius: 3px; - font-size: 10px; - font-weight: 500; - margin-left: 8px; -`; - -const TimeoutInfo = styled.span<{ status?: ToolStatus }>` - color: ${({ status }) => { - switch (status) { - case "executing": - case "pending": - return "var(--color-pending)"; - default: - return "var(--color-text-secondary)"; - } - }}; - font-size: 10px; - margin-left: 8px; -`; - -const ErrorMessage = styled.div` - color: #f44336; - font-size: 11px; - padding: 6px 8px; - background: rgba(244, 67, 54, 0.1); - border-radius: 3px; - border-left: 2px solid #f44336; -`; +import styles from "./BashToolCall.module.css"; interface BashToolCallProps { args: BashToolArgs; @@ -113,13 +54,19 @@ export const BashToolCall: React.FC = ({ args, result, status bash - {args.script} - + {args.script} + timeout: {args.timeout_secs}s {result && ` • took ${formatDuration(result.wall_duration_ms)}`} {!result && isPending && elapsedTime > 0 && ` • ${formatDuration(elapsedTime)}`} - - {result && {result.exitCode}} + + {result && ( + + {result.exitCode} + + )} {getStatusDisplay(status)} @@ -135,14 +82,14 @@ export const BashToolCall: React.FC = ({ args, result, status {result.success === false && result.error && ( Error - {result.error} +
{result.error}
)} {result.output && ( Output - {result.output} +
{result.output}
)} diff --git a/src/components/tools/FileEditToolCall.module.css b/src/components/tools/FileEditToolCall.module.css new file mode 100644 index 0000000000..9eee16a0fb --- /dev/null +++ b/src/components/tools/FileEditToolCall.module.css @@ -0,0 +1,82 @@ +.compactHeader { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + font-size: 11px; + color: var(--color-text); +} + +.editIcon { + font-size: 14px; +} + +.filePath { + font-family: var(--font-monospace); + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +} + +.editCount { + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 4px; +} + +.tokenCount { + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 4px; +} + +.diffBlock { + margin: 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + border-left: 2px solid #ff9800; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; +} + +.errorMessage { + color: #f44336; + font-size: 11px; + padding: 6px 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 3px; + border-left: 2px solid #f44336; +} + +.styledToolHeader { + cursor: default; +} + +.styledToolHeader:hover { + color: var(--color-text-secondary); +} + +.leftContent { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + cursor: pointer; +} + +.leftContent:hover { + color: var(--color-text); +} + +.buttonGroup { + display: flex; + gap: 6px; + margin-right: 8px; +} diff --git a/src/components/tools/FileEditToolCall.tsx b/src/components/tools/FileEditToolCall.tsx index 919cb20bed..6e9ac6f91c 100644 --- a/src/components/tools/FileEditToolCall.tsx +++ b/src/components/tools/FileEditToolCall.tsx @@ -195,7 +195,7 @@ interface FileEditToolCallProps { function renderDiff(diff: string): React.ReactNode { try { const patches = parsePatch(diff); - if (patches.length === 0) { + if (!patches || patches.length === 0) { return ( No changes @@ -203,57 +203,78 @@ function renderDiff(diff: string): React.ReactNode { ); } - return patches.map((patch, patchIdx) => ( - - {patch.hunks.map((hunk, hunkIdx) => { - let oldLineNum = hunk.oldStart; - let newLineNum = hunk.newStart; - - return ( - - - {/* Empty for alignment */} - {hunkIdx > 0 ? "⋮" : ""} - - @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ - - - {hunk.lines.map((line, lineIdx) => { - const firstChar = line[0]; - const content = line.slice(1); // Remove the +/- prefix - let type: DiffLineType = "context"; - let lineNumDisplay = ""; - - if (firstChar === "+") { - type = "add"; - lineNumDisplay = `${newLineNum}`; - newLineNum++; - } else if (firstChar === "-") { - type = "remove"; - lineNumDisplay = `${oldLineNum}`; - oldLineNum++; - } else { - // Context line - lineNumDisplay = `${oldLineNum}`; - oldLineNum++; - newLineNum++; - } - - return ( - - {firstChar} - {lineNumDisplay} - {content} - - ); - })} - - ); - })} - - )); + return patches.map((patch, patchIdx) => { + if (!patch?.hunks) { + return null; + } + + return ( + + {patch.hunks.map((hunk, hunkIdx) => { + if (!hunk?.lines) { + return null; + } + + let oldLineNum = hunk.oldStart ?? 0; + let newLineNum = hunk.newStart ?? 0; + + return ( + + + {/* Empty for alignment */} + {hunkIdx > 0 ? "⋮" : ""} + + @@ -{hunk.oldStart ?? 0},{hunk.oldLines ?? 0} +{hunk.newStart ?? 0}, + {hunk.newLines ?? 0} @@ + + + {hunk.lines.map((line, lineIdx) => { + if (!line || typeof line !== "string") { + return null; + } + + const firstChar = line[0]; + const content = line.slice(1); // Remove the +/- prefix + let type: DiffLineType = "context"; + let lineNumDisplay = ""; + + if (firstChar === "+") { + type = "add"; + lineNumDisplay = `${newLineNum}`; + newLineNum++; + } else if (firstChar === "-") { + type = "remove"; + lineNumDisplay = `${oldLineNum}`; + oldLineNum++; + } else { + // Context line + lineNumDisplay = `${oldLineNum}`; + oldLineNum++; + newLineNum++; + } + + return ( + + {firstChar} + {lineNumDisplay} + {content} + + ); + })} + + ); + })} + + ); + }); } catch (error) { - return Failed to parse diff: {String(error)}; + console.error("Failed to parse diff:", error); + // Fallback to raw diff display + return ( +
+        {diff}
+      
+ ); } } diff --git a/src/components/tools/FileReadToolCall.module.css b/src/components/tools/FileReadToolCall.module.css new file mode 100644 index 0000000000..c56c14c391 --- /dev/null +++ b/src/components/tools/FileReadToolCall.module.css @@ -0,0 +1,84 @@ +.compactHeader { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + font-size: 11px; + color: var(--color-text); +} + +.searchIcon { + font-size: 14px; +} + +.filePath { + font-family: var(--font-monospace); + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 400px; +} + +.tokenCount { + color: var(--color-text-secondary); + font-size: 10px; + margin-left: 4px; +} + +.contentBlock { + margin: 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + border-left: 2px solid #2196f3; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + max-height: 400px; + overflow-y: auto; +} + +.metadataRow { + display: flex; + gap: 16px; + font-size: 10px; + color: var(--color-text-secondary); + padding: 4px 0; +} + +.errorMessage { + color: #f44336; + font-size: 11px; + padding: 6px 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 3px; + border-left: 2px solid #f44336; +} + +.styledToolHeader { + cursor: default; +} + +.styledToolHeader:hover { + color: var(--color-text-secondary); +} + +.leftContent { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + cursor: pointer; +} + +.leftContent:hover { + color: var(--color-text); +} + +.buttonGroup { + display: flex; + gap: 6px; + margin-right: 8px; +} diff --git a/src/components/tools/FileReadToolCall.tsx b/src/components/tools/FileReadToolCall.tsx new file mode 100644 index 0000000000..8e11bc1166 --- /dev/null +++ b/src/components/tools/FileReadToolCall.tsx @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import type { FileReadToolArgs, FileReadToolResult } from "@/types/tools"; +import { + ToolContainer, + ToolHeader, + ToolDetails, + DetailSection, + DetailLabel, + DetailContent, + LoadingDots, + HeaderButton, +} from "./shared/ToolPrimitives"; +import { useToolExpansion, type ToolStatus } from "./shared/toolUtils"; +import styles from "./FileReadToolCall.module.css"; + +interface FileReadToolCallProps { + args: FileReadToolArgs; + result?: FileReadToolResult; + status?: ToolStatus; +} + +// Estimate token count (rough approximation: ~4 chars per token) +function estimateTokens(content: string): number { + return Math.ceil(content.length / 4); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +export const FileReadToolCall: React.FC = ({ + args, + result, + status = "pending", +}) => { + const { expanded, toggleExpanded } = useToolExpansion(false); + const [copied, setCopied] = useState(false); + + const filePath = args.filePath; + const tokenCount = result?.success ? estimateTokens(result.content) : null; + + const handleCopyContent = async () => { + if (result?.success) { + try { + await navigator.clipboard.writeText(result.content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + } + }; + + // Compact display when collapsed + if (!expanded) { + return ( + +
+
+ 🔍 + {filePath} + {tokenCount !== null && ~{tokenCount} tokens} +
+
+
+ ); + } + + // Full display when expanded + return ( + +
+
+ 🔍 + {filePath} + {tokenCount !== null && ~{tokenCount} tokens} +
+ {result && result.success && ( +
+ { + e.stopPropagation(); + void handleCopyContent(); + }} + > + {copied ? "✓ Copied" : "Copy Content"} + +
+ )} +
+ + {expanded && ( + + {result && ( + <> + {result.success === false && result.error && ( + + Error +
{result.error}
+
+ )} + + {result.success && ( + <> +
+ Lines: {result.lines_read} + Size: {formatFileSize(result.file_size)} + Modified: {new Date(result.modifiedTime).toLocaleString()} +
+ + + Content +
{result.content}
+
+ + )} + + )} + + {status === "executing" && !result && ( + + + Reading file + + + + )} +
+ )} +
+ ); +}; diff --git a/src/components/tools/ProposePlanToolCall.module.css b/src/components/tools/ProposePlanToolCall.module.css new file mode 100644 index 0000000000..45692a0325 --- /dev/null +++ b/src/components/tools/ProposePlanToolCall.module.css @@ -0,0 +1,120 @@ +.planContainer { + padding: 12px; + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--color-plan-mode), transparent 92%) 0%, + color-mix(in srgb, var(--color-plan-mode), transparent 95%) 100% + ); + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--color-plan-mode), transparent 70%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.planHeader { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid color-mix(in srgb, var(--color-plan-mode), transparent 80%); +} + +.planHeaderLeft { + display: flex; + align-items: center; + gap: 8px; + flex: 1; +} + +.planHeaderRight { + display: flex; + align-items: center; + gap: 6px; +} + +.planIcon { + font-size: 16px; +} + +.planTitle { + font-size: 13px; + font-weight: 600; + color: var(--color-plan-mode); + font-family: var(--font-monospace); +} + +.planButton { + padding: 4px 8px; + font-size: 10px; + font-family: var(--font-monospace); + color: #888; + background: transparent; + border: 1px solid rgba(136, 136, 136, 0.3); + border-radius: 3px; + cursor: pointer; + transition: all 0.15s ease; +} + +.planButton.active { + color: var(--color-plan-mode); + background: color-mix(in srgb, var(--color-plan-mode), transparent 90%); + border-color: color-mix(in srgb, var(--color-plan-mode), transparent 70%); +} + +.planButton:hover { + background: color-mix(in srgb, var(--color-plan-mode), transparent 85%); + color: var(--color-plan-mode); + border-color: color-mix(in srgb, var(--color-plan-mode), transparent 60%); +} + +.rawContent { + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.6; + color: var(--color-text); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.planContent { + font-family: var(--font-primary); + font-size: 12px; + line-height: 1.6; +} + +.guidanceText { + color: var(--color-text-secondary); + font-size: 11px; + font-style: italic; + margin: 8px 0; +} + +.keybindDisplay { + font-family: var(--font-monospace); + font-weight: 600; +} + +.planBlock { + margin: 0; + padding: 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + border-left: 2px solid #9c27b0; + font-size: 11px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; +} + +.errorMessage { + color: #f44336; + font-size: 11px; + padding: 6px 8px; + background: rgba(244, 67, 54, 0.1); + border-radius: 3px; + border-left: 2px solid #f44336; +} diff --git a/src/components/tools/shared/ToolPrimitives.module.css b/src/components/tools/shared/ToolPrimitives.module.css new file mode 100644 index 0000000000..cd408be213 --- /dev/null +++ b/src/components/tools/shared/ToolPrimitives.module.css @@ -0,0 +1,142 @@ +/** + * Shared CSS for tool UI primitives + * These styles provide consistent styling across all tool components + */ + +.toolContainer { + margin: 8px 0; + padding: 4px 12px; + background: rgba(100, 100, 100, 0.05); + border-radius: 4px; + font-family: var(--font-monospace); + font-size: 11px; + transition: all 0.2s ease; +} + +.toolContainer.expanded { + padding: 8px 12px; +} + +.toolHeader { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + color: var(--color-text-secondary); +} + +.toolHeader:hover { + color: var(--color-text); +} + +.expandIcon { + display: inline-block; + transition: transform 0.2s ease; + transform: rotate(0deg); + font-size: 10px; +} + +.expandIcon.expanded { + transform: rotate(90deg); +} + +.toolName { + font-weight: 500; +} + +.statusIndicator { + font-size: 10px; + margin-left: auto; + opacity: 0.8; +} + +.statusIndicator.executing { + color: var(--color-pending); +} + +.statusIndicator.completed { + color: #4caf50; +} + +.statusIndicator.failed { + color: #f44336; +} + +.statusIndicator.interrupted { + color: var(--color-interrupted); +} + +.statusIndicator.pending { + color: var(--color-text-secondary); +} + +.toolDetails { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.05); + color: var(--color-text); +} + +.detailSection { + margin: 6px 0; +} + +.detailLabel { + font-size: 10px; + color: var(--color-text-secondary); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detailContent { + margin: 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + font-size: 11px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +.loadingDots::after { + content: "..."; + animation: dots 1.5s infinite; +} + +@keyframes dots { + 0%, 20% { + content: "."; + } + 40% { + content: ".."; + } + 60%, 100% { + content: "..."; + } +} + +.headerButton { + background: none; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #cccccc; + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: 10px; + transition: all 0.2s ease; + white-space: nowrap; +} + +.headerButton.active { + background: rgba(255, 255, 255, 0.1); +} + +.headerButton:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.3); +} diff --git a/src/components/tools/shared/ToolPrimitives.tsx b/src/components/tools/shared/ToolPrimitives.tsx index 118add963a..7852c25da5 100644 --- a/src/components/tools/shared/ToolPrimitives.tsx +++ b/src/components/tools/shared/ToolPrimitives.tsx @@ -1,130 +1,99 @@ -import styled from "@emotion/styled"; +import React from "react"; +import styles from "./ToolPrimitives.module.css"; /** - * Shared styled components for tool UI + * Shared components for tool UI * These primitives provide consistent styling across all tool components */ -export const ToolContainer = styled.div<{ expanded: boolean }>` - margin: 8px 0; - padding: ${(props) => (props.expanded ? "8px 12px" : "4px 12px")}; - background: rgba(100, 100, 100, 0.05); - border-radius: 4px; - font-family: var(--font-monospace); - font-size: 11px; - transition: all 0.2s ease; -`; - -export const ToolHeader = styled.div` - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - user-select: none; - color: var(--color-text-secondary); - - &:hover { - color: var(--color-text); - } -`; - -export const ExpandIcon = styled.span<{ expanded: boolean }>` - display: inline-block; - transition: transform 0.2s ease; - transform: ${(props) => (props.expanded ? "rotate(90deg)" : "rotate(0deg)")}; - font-size: 10px; -`; - -export const ToolName = styled.span` - font-weight: 500; -`; - -export const StatusIndicator = styled.span<{ status: string }>` - font-size: 10px; - margin-left: auto; - opacity: 0.8; - color: ${({ status }) => { - switch (status) { - case "executing": - return "var(--color-pending)"; - case "completed": - return "#4caf50"; - case "failed": - return "#f44336"; - case "interrupted": - return "var(--color-interrupted)"; - default: - return "var(--color-text-secondary)"; - } - }}; -`; - -export const ToolDetails = styled.div` - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.05); - color: var(--color-text); -`; - -export const DetailSection = styled.div` - margin: 6px 0; -`; - -export const DetailLabel = styled.div` - font-size: 10px; - color: var(--color-text-secondary); - margin-bottom: 4px; - text-transform: uppercase; - letter-spacing: 0.5px; -`; - -export const DetailContent = styled.pre` - margin: 0; - padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); - border-radius: 3px; - font-size: 11px; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-word; - max-height: 200px; - overflow-y: auto; -`; - -export const LoadingDots = styled.span` - &::after { - content: "..."; - animation: dots 1.5s infinite; - } - - @keyframes dots { - 0%, - 20% { - content: "."; - } - 40% { - content: ".."; - } - 60%, - 100% { - content: "..."; - } - } -`; - -export const HeaderButton = styled.button<{ active?: boolean }>` - background: ${(props) => (props.active ? "rgba(255, 255, 255, 0.1)" : "none")}; - border: 1px solid rgba(255, 255, 255, 0.2); - color: #cccccc; - padding: 2px 8px; - border-radius: 3px; - cursor: pointer; - font-size: 10px; - transition: all 0.2s ease; - white-space: nowrap; - - &:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.3); - } -`; +interface ToolContainerProps { + expanded: boolean; + children: React.ReactNode; +} + +export const ToolContainer: React.FC = ({ expanded, children }) => ( +
{children}
+); + +interface ToolHeaderProps { + onClick: () => void; + children: React.ReactNode; +} + +export const ToolHeader: React.FC = ({ onClick, children }) => ( +
+ {children} +
+); + +interface ExpandIconProps { + expanded: boolean; + children: React.ReactNode; +} + +export const ExpandIcon: React.FC = ({ expanded, children }) => ( + {children} +); + +interface ToolNameProps { + children: React.ReactNode; +} + +export const ToolName: React.FC = ({ children }) => ( + {children} +); + +interface StatusIndicatorProps { + status: string; + children: React.ReactNode; +} + +export const StatusIndicator: React.FC = ({ status, children }) => ( + {children} +); + +interface ToolDetailsProps { + children: React.ReactNode; +} + +export const ToolDetails: React.FC = ({ children }) => ( +
{children}
+); + +interface DetailSectionProps { + children: React.ReactNode; +} + +export const DetailSection: React.FC = ({ children }) => ( +
{children}
+); + +interface DetailLabelProps { + children: React.ReactNode; +} + +export const DetailLabel: React.FC = ({ children }) => ( +
{children}
+); + +interface DetailContentProps { + children: React.ReactNode; +} + +export const DetailContent: React.FC = ({ children }) => ( +
{children}
+); + +export const LoadingDots: React.FC = () => ; + +interface HeaderButtonProps { + active?: boolean; + onClick?: () => void; + children: React.ReactNode; +} + +export const HeaderButton: React.FC = ({ active, onClick, children }) => ( + +); diff --git a/tsconfig.json b/tsconfig.json index 1a86bada21..d5d3064391 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "module": "ESNext", "moduleResolution": "node", "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/vite.config.ts b/vite.config.ts index cb463253aa..7a71f755d1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,7 +8,16 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [react(), wasm(), topLevelAwait()], + plugins: [ + react({ + jsxImportSource: '@emotion/react', + babel: { + plugins: ['@emotion/babel-plugin'], + }, + }), + wasm(), + topLevelAwait(), + ], resolve: { alias: { "@": path.resolve(__dirname, "./src"),