diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e782a7e3f..c6810637a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,7 @@ jobs: with: fetch-depth: 1 submodules: true + token: ${{ secrets.XGH_TOKEN_READ }} - name: Setup Node.js 24.x uses: actions/setup-node@v4 @@ -70,8 +71,8 @@ jobs: if: steps.yarn-cache.outputs.cache-hit != 'true' run: yarn install --frozen-lockfile - - name: Build frontend - run: yarn build + - name: Install skills dependencies + run: cd skills && yarn install --frozen-lockfile - name: Build Tauri app run: yarn tauri build -- --target x86_64-unknown-linux-gnu diff --git a/.github/workflows/package-and-publish.yml b/.github/workflows/package-and-publish.yml index 92e2a7601..dffeab0a0 100644 --- a/.github/workflows/package-and-publish.yml +++ b/.github/workflows/package-and-publish.yml @@ -14,7 +14,7 @@ on: # branches: # - main workflow_run: - workflows: ["Version Bump"] + workflows: ['Version Bump'] types: - completed branches: @@ -171,15 +171,15 @@ jobs: fail-fast: false matrix: settings: - - platform: "macos-latest" - args: "--target aarch64-apple-darwin" - target: "aarch64-apple-darwin" - - platform: "macos-latest" - args: "--target x86_64-apple-darwin" - target: "x86_64-apple-darwin" - - platform: "ubuntu-22.04" - args: "" - target: "" + - platform: 'macos-latest' + args: '--target aarch64-apple-darwin' + target: 'aarch64-apple-darwin' + - platform: 'macos-latest' + args: '--target x86_64-apple-darwin' + target: 'x86_64-apple-darwin' + - platform: 'ubuntu-22.04' + args: '' + target: '' runs-on: ${{ matrix.settings.platform }} steps: - name: Checkout @@ -200,7 +200,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: "yarn" + cache: 'yarn' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -323,7 +323,7 @@ jobs: # macOS 10.15+ required for std::filesystem used by llama.cpp MACOSX_DEPLOYMENT_TARGET: ${{ matrix.settings.platform == 'macos-latest' && '10.15' || '' }} with: - args: "-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}" + args: '-c ${{ steps.config-overrides.outputs.json }} ${{ matrix.settings.args }}' includeDebug: ${{ needs.get-version.outputs.should-publish == '' && inputs.forceRelease != 'true' }} includeRelease: ${{ needs.get-version.outputs.should-publish != '' || inputs.forceRelease == 'true' }} # TDLib dylibs are now bundled natively via build.rs + tauri.conf.json macOS.frameworks diff --git a/.github/workflows/package-android.yml b/.github/workflows/package-android.yml index d91a00e1b..2be3dedc7 100644 --- a/.github/workflows/package-android.yml +++ b/.github/workflows/package-android.yml @@ -7,11 +7,11 @@ on: workflow_dispatch: inputs: publish: - description: "Publish to release (requires main branch)" + description: 'Publish to release (requires main branch)' type: boolean default: false workflow_run: - workflows: ["Version Bump"] + workflows: ['Version Bump'] types: - completed branches: @@ -119,13 +119,14 @@ jobs: with: fetch-depth: 1 submodules: true + token: ${{ secrets.XGH_TOKEN_READ }} ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }} - name: Setup Node.js 24.x uses: actions/setup-node@v4 with: node-version: 24.x - cache: "yarn" + cache: 'yarn' - name: Install Rust stable uses: dtolnay/rust-toolchain@stable @@ -135,8 +136,8 @@ jobs: - name: Setup Java 17 uses: actions/setup-java@v4 with: - distribution: "temurin" - java-version: "17" + distribution: 'temurin' + java-version: '17' - name: Setup Android SDK uses: android-actions/setup-android@v3 diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 53b3a1869..92064cab7 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -26,7 +26,7 @@ jobs: uses: actions/setup-node@v4 with: node-version: 24.x - cache: "yarn" + cache: 'yarn' - name: Cache node modules id: yarn-cache diff --git a/README.md b/README.md index 102e36689..61d17872d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,108 @@ -# Tauri + React + Typescript +

AlphaHuman

-This template should help get you started developing with Tauri, React and Typescript in Vite. +

+ Your most productive co-worker +

-## Recommended IDE Setup +

+ Early Beta + Platforms + Latest Release +

-- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +![The Tet](./docs/the-tet.png) + +

+ "The Tet. What a brilliant machine" Morgan Freeman as he reminisces about the Tet in the movie Oblivion +

+ +## What is AlphaHuman? + +AlphaHuman is a personal AI assistant that helps you manage high-volume communication without reading everything yourself. It connects to your messaging platforms and productivity tools, understands conversations in context, and produces clear, actionable outputs you can use immediately. + +AlphaHuman is not a chatbot, browser extension, or cloud-only service. It is a native application that runs on your device, connects to your tools, and works only when you ask it to. Think of it as a second brain that sits across your communication and productivity stack. + +## What It Does + +- **Summarize conversations** -- Understand what happened without reading everything +- **Surface signals** -- Decisions, action items, risks, and sentiment shifts extracted automatically +- **Generate responses** -- Context-aware reply suggestions informed by conversation history +- **Create workflows** -- Turn unstructured commitments into trackable actions +- **Export intelligence** -- Push summaries, action items, and structured data to Notion, Google Sheets, and connected tools +- **Run automations** -- Via a sandboxed skills engine that extends the platform without app updates + +## Download + +> **Early Beta** -- AlphaHuman is under active development. Expect rough edges. + +### macOS + +| Chip | Download | +| --------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Apple Silicon (M1/M2/M3/M4) | [`.dmg` (aarch64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_aarch64.dmg) | +| Intel | [`.dmg` (x64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64.dmg) | + +### Windows + +| Architecture | Download | +| ------------ | ------------------------------------------------------------------------------------------------------- | +| x64 | [`.msi`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x64_en-US.msi) | + +### Linux + +| Format | Download | +| --------------- | ------------------------------------------------------------------------------------------------------------- | +| Debian / Ubuntu | [`.deb` (amd64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.deb) | +| Fedora / RHEL | [`.rpm` (x86_64)](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_x86_64.rpm) | +| Universal | [`.AppImage`](https://github.com/alphahumanxyz/alphahuman/releases/latest/download/AlphaHuman_amd64.AppImage) | + +### Mobile + +- **Android** and **iOS** -- Coming soon + +Browse all releases: [github.com/alphahumanxyz/alphahuman/releases](https://github.com/alphahumanxyz/alphahuman/releases) + +## Skills + +AlphaHuman uses a pluggable **skills** architecture. Each skill connects to an external service, syncs relevant data locally, and exposes tools that you (or the AI) can use. Skills run in a sandboxed environment with their own database, storage, and permissions. + +| Skill | Status | Description | +| --------------- | ----------- | ------------------------------------------------------------- | +| Telegram | Available | Chats, messages, contacts, search, admin tools, AI summaries | +| Notion | Available | Pages, databases, blocks, users, comments, search, local sync | +| Gmail | In Progress | Email management, labels, search, send/receive with OAuth2 | +| Google Calendar | In Progress | Calendars, events, scheduling with OAuth2 | +| Google Drive | In Progress | Files, Sheets, Docs with OAuth2 | +| Slack | In Progress | Messages, channels, real-time events | +| Web3 Wallets | Planned | EVM wallet management, balance checks, network monitoring | + +## Privacy-First + +- **Zero retention** -- Message content is processed to produce output, then discarded +- **OS-level credential storage** -- Desktop platforms use native keychains (macOS Keychain, Windows Credential Manager) +- **No training on your data** -- Your data is never used for model improvement +- **Request-only processing** -- Nothing happens without an explicit user action; no background scanning +- **Sandboxed skills** -- Each skill runs in isolation with memory and resource limits + +## Getting Started + +1. **Download** the installer for your platform from the [releases page](https://github.com/alphahumanxyz/alphahuman/releases/latest) +2. **Install** the app (drag to Applications on macOS, or use your package manager on Linux) +3. **Connect a source** -- follow the in-app onboarding to link Telegram, Notion, Gmail, or other services +4. **Run your first request** -- ask the AI to summarize what you missed, extract action items, or surface key decisions + +## Links + +- [Architecture Overview](./ARCHITECTURE.md) -- How AlphaHuman is built +- [Changelog](./CHANGELOG.md) -- Release history +- [Website](https://alphahuman.xyz) -- Learn more + +--- + +

+ Made with love by a bunch of Web3 nerds +

+ +

+ AlphaHuman is in early beta. Features may change, break, or disappear. Use at your own risk. +

diff --git a/docs/the-tet.png b/docs/the-tet.png new file mode 100644 index 000000000..cb5ace124 Binary files /dev/null and b/docs/the-tet.png differ diff --git a/eslint.config.js b/eslint.config.js index 87ee3d172..cd4683534 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -205,6 +205,44 @@ export default [ }, }, + // E2E test files (Appium/WebDriverIO) — use tsconfig.e2e.json for parsing + { + files: ['test/e2e/**/*.ts', 'wdio.conf.ts'], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: './tsconfig.e2e.json', + tsconfigRootDir: __dirname, + }, + globals: { + browser: 'readonly', + $: 'readonly', + $$: 'readonly', + describe: 'readonly', + it: 'readonly', + before: 'readonly', + after: 'readonly', + beforeEach: 'readonly', + afterEach: 'readonly', + expect: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tseslint, + }, + rules: { + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'off', + 'no-undef': 'off', + }, + }, + // JavaScript files configuration { files: ['**/*.js', '**/*.jsx'], diff --git a/package.json b/package.json index d638c1188..6efa12d46 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,11 @@ "test:unit:watch": "vitest", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:rust": "cargo test --manifest-path src-tauri/Cargo.toml", - "test:e2e:build": "yarn macos:build:debug", - "test:e2e:run": "bash scripts/e2e.sh", - "test:e2e": "yarn test:e2e:build && yarn test:e2e:run", + "test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml", + "test:e2e:build": "bash scripts/e2e-build.sh", + "test:e2e:login": "bash scripts/e2e-login.sh", + "test:e2e:auth": "bash scripts/e2e-auth.sh", + "test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth", "test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e", "format": "prettier --write .", "format:check": "prettier --check .", @@ -55,6 +56,7 @@ "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", "react-redux": "^9.2.0", "react-router-dom": "^7.13.0", "redux-logger": "^3.0.6", diff --git a/scripts/e2e-auth.sh b/scripts/e2e-auth.sh new file mode 100755 index 000000000..d004011ff --- /dev/null +++ b/scripts/e2e-auth.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# Run E2E auth & access control tests only. +# +# Starts Appium, cleans app caches, runs the auth-access-control spec, +# then tears everything down. Each flow script is self-contained so +# specs don't pollute each other's Redux Persist state. +# +# Usage: +# ./scripts/e2e-auth.sh +# APPIUM_PORT=4723 ./scripts/e2e-auth.sh +# +set -euo pipefail + +APPIUM_PORT="${APPIUM_PORT:-4723}" +E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +SPEC="test/e2e/specs/auth-access-control.spec.ts" + +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" +export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" + +# Clean cached app data for a fresh state — Redux Persist would otherwise +# remember the JWT from a previous run and skip the login flow. +echo "Cleaning cached app data..." +rm -rf ~/Library/WebKit/com.alphahuman.app +rm -rf ~/Library/Caches/com.alphahuman.app +rm -rf "$HOME/Library/Application Support/com.alphahuman.app" + +# Verify the frontend dist has the mock server URL baked in. +DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" +if [ -z "$DIST_JS" ]; then + echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2 + echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2 + exit 1 +fi +if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then + echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 + echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2 + exit 1 +fi +echo "Verified: frontend bundle contains mock server URL." + +# --- Resolve Node 24 via nvm --------------------------------------------------- +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +NODE24="$(nvm which 24 2>/dev/null || true)" +if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then + echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2 + exit 1 +fi + +APPIUM_BIN="$(dirname "$NODE24")/appium" +if [ ! -x "$APPIUM_BIN" ]; then + echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2 + exit 1 +fi + +# --- Start Appium in the background ------------------------------------------- +APPIUM_LOG="/tmp/appium-e2e-auth.log" +NODE_VER=$("$NODE24" --version) +echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." +echo " Appium logs: $APPIUM_LOG" +"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & +APPIUM_PID=$! + +cleanup() { + echo "Stopping Appium (pid $APPIUM_PID)..." + kill "$APPIUM_PID" 2>/dev/null || true + wait "$APPIUM_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for Appium to be ready +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then + echo "Appium is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Appium did not start within 30 seconds." >&2 + exit 1 + fi + sleep 1 +done + +# --- Run WebDriverIO ---------------------------------------------------------- +echo "Running E2E auth flow tests ($SPEC)..." +npx wdio run wdio.conf.ts --spec "$SPEC" diff --git a/scripts/e2e-build.sh b/scripts/e2e-build.sh new file mode 100755 index 000000000..3522fafcb --- /dev/null +++ b/scripts/e2e-build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# +# Build the .app bundle for E2E tests with the mock server URL baked in. +# +# This does a cargo clean first to ensure the frontend assets are re-embedded +# (Cargo's incremental build won't detect changes to dist/). +# +set -euo pipefail + +# Source Cargo environment +[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" + +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}" + +echo "Building E2E app bundle with VITE_BACKEND_URL=$VITE_BACKEND_URL" + +# Clean Rust build cache so frontend assets get re-embedded +cargo clean --manifest-path src-tauri/Cargo.toml + +# Load .env for other vars (Telegram API keys, etc.) +source scripts/load-dotenv.sh + +# Re-export mock URL in case load-dotenv.sh clobbered it +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT:-18473}" + +# Build .app only (skip DMG to avoid bundle_dmg.sh failures) +tauri build --bundles app --debug + +echo "E2E build complete." diff --git a/scripts/e2e-login.sh b/scripts/e2e-login.sh new file mode 100755 index 000000000..bb00464cc --- /dev/null +++ b/scripts/e2e-login.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# Run E2E login flow tests only. +# +# Starts Appium, cleans app caches, runs the login-flow spec, +# then tears everything down. Each flow script is self-contained so +# specs don't pollute each other's Redux Persist state. +# +# Usage: +# ./scripts/e2e-login.sh +# APPIUM_PORT=4723 ./scripts/e2e-login.sh +# +set -euo pipefail + +APPIUM_PORT="${APPIUM_PORT:-4723}" +E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +SPEC="test/e2e/specs/login-flow.spec.ts" + +# Point the app at the local mock server (baked into the build already, but +# also exported here so the Rust backend / any env-based config picks it up). +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" +export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" + +# Clean cached app data for a fresh state — Redux Persist would otherwise +# remember the JWT from a previous run and skip the login flow. +echo "Cleaning cached app data..." +rm -rf ~/Library/WebKit/com.alphahuman.app +rm -rf ~/Library/Caches/com.alphahuman.app +rm -rf "$HOME/Library/Application Support/com.alphahuman.app" + +# Verify the frontend dist has the mock server URL baked in (not production). +# Tauri compresses assets when embedding, so we check dist/ not the binary. +DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" +if [ -z "$DIST_JS" ]; then + echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2 + echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2 + exit 1 +fi +if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then + echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 + echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2 + exit 1 +fi +echo "Verified: frontend bundle contains mock server URL." + +# --- Resolve Node 24 via nvm --------------------------------------------------- +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +# shellcheck source=/dev/null +[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" + +NODE24="$(nvm which 24 2>/dev/null || true)" +if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then + echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2 + exit 1 +fi + +APPIUM_BIN="$(dirname "$NODE24")/appium" +if [ ! -x "$APPIUM_BIN" ]; then + echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2 + exit 1 +fi + +# --- Start Appium in the background ------------------------------------------- +APPIUM_LOG="/tmp/appium-e2e-login.log" +NODE_VER=$("$NODE24" --version) +echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." +echo " Appium logs: $APPIUM_LOG" +"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & +APPIUM_PID=$! + +cleanup() { + echo "Stopping Appium (pid $APPIUM_PID)..." + kill "$APPIUM_PID" 2>/dev/null || true + wait "$APPIUM_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for Appium to be ready +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then + echo "Appium is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Appium did not start within 30 seconds." >&2 + exit 1 + fi + sleep 1 +done + +# --- Run WebDriverIO ---------------------------------------------------------- +echo "Running E2E login flow tests ($SPEC)..." +npx wdio run wdio.conf.ts --spec "$SPEC" diff --git a/scripts/e2e.sh b/scripts/e2e.sh deleted file mode 100755 index fe986696c..000000000 --- a/scripts/e2e.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -# -# Start Appium under Node 24 (required by appium v3), run WebDriverIO E2E -# tests, then tear everything down. -# -# Usage: -# ./scripts/e2e.sh # run tests -# APPIUM_PORT=4723 ./scripts/e2e.sh # custom port -# -set -euo pipefail - -APPIUM_PORT="${APPIUM_PORT:-4723}" - -# --- Resolve Node 24 via nvm --------------------------------------------------- -export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" -# shellcheck source=/dev/null -[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" - -NODE24="$(nvm which 24 2>/dev/null || true)" -if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then - echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2 - exit 1 -fi - -APPIUM_BIN="$(dirname "$NODE24")/appium" -if [ ! -x "$APPIUM_BIN" ]; then - echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2 - exit 1 -fi - -# --- Start Appium in the background ------------------------------------------- -NODE_VER=$("$NODE24" --version) -echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." -"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security & -APPIUM_PID=$! - -cleanup() { - echo "Stopping Appium (pid $APPIUM_PID)..." - kill "$APPIUM_PID" 2>/dev/null || true - wait "$APPIUM_PID" 2>/dev/null || true -} -trap cleanup EXIT - -# Wait for Appium to be ready -for i in $(seq 1 30); do - if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then - echo "Appium is ready." - break - fi - if [ "$i" -eq 30 ]; then - echo "ERROR: Appium did not start within 30 seconds." >&2 - exit 1 - fi - sleep 1 -done - -# --- Run WebDriverIO ---------------------------------------------------------- -echo "Running E2E tests..." -npx wdio run wdio.conf.ts diff --git a/skills b/skills index 272640010..0c6a11b1e 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 272640010a8a6bdee31b610fc7519067d4fd2e44 +Subproject commit 0c6a11b1e05aed1d6c1cb986e81501f13afcf203 diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f1ef2ff2b..46a58e40b 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -108,6 +108,14 @@ const AppRoutes = () => { } /> + + + + } + /> {/* Invites */} ), }, - // { - // id: 'conversations', - // label: 'Conversations', - // path: '/conversations', - // icon: ( - // - // - // - // ), - // }, + { + id: 'conversations', + label: 'Conversations', + path: '/conversations', + icon: ( + + + + ), + }, { id: 'intelligence', label: 'Intelligence', @@ -91,6 +91,17 @@ const MiniSidebar = () => { const navigate = useNavigate(); const token = useAppSelector(state => state.auth.token); + // Unread count for Conversations: threads with lastMessageAt > lastViewedAt (must be before early return) + const conversationsUnreadCount = useAppSelector(state => { + const { threads, lastViewedAt } = state.thread; + if (threads.length === 0) return 0; + return threads.filter(t => { + const viewed = lastViewedAt[t.id]; + const lastMsg = new Date(t.lastMessageAt || t.createdAt).getTime(); + return viewed == null || lastMsg > viewed; + }).length; + }); + // Hide sidebar when not authenticated or on public/onboarding routes const hiddenPaths = ['/', '/login', '/onboarding']; if (!token || hiddenPaths.includes(location.pathname)) { @@ -107,6 +118,7 @@ const MiniSidebar = () => {
{navItems.map(item => { const active = isActive(item.path); + const showUnreadBadge = item.id === 'conversations' && conversationsUnreadCount > 0; return (
+ {showUnreadBadge && ( + + {conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount} + + )} {/* Tooltip - appears to the right */}
{item.label} diff --git a/src/components/SkillsGrid.tsx b/src/components/SkillsGrid.tsx index a4f08c710..482f6f3ea 100644 --- a/src/components/SkillsGrid.tsx +++ b/src/components/SkillsGrid.tsx @@ -6,15 +6,15 @@ import { deriveConnectionStatus, useSkillConnectionStatus } from '../lib/skills/ import type { SkillConnectionStatus } from '../lib/skills/types'; import { useAppSelector } from '../store/hooks'; import { IS_DEV } from '../utils/config'; -import SkillSetupModal from './skills/SkillSetupModal'; import { DefaultIcon, SKILL_ICONS, SkillActionButton, + type SkillListEntry, STATUS_DISPLAY, STATUS_PRIORITY, - type SkillListEntry, } from './skills/shared'; +import SkillSetupModal from './skills/SkillSetupModal'; interface SkillRowProps { skillId: string; diff --git a/src/components/__tests__/ConnectionIndicator.test.tsx b/src/components/__tests__/ConnectionIndicator.test.tsx index ed8ae77fa..805f48854 100644 --- a/src/components/__tests__/ConnectionIndicator.test.tsx +++ b/src/components/__tests__/ConnectionIndicator.test.tsx @@ -1,49 +1,41 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import ConnectionIndicator from "../ConnectionIndicator"; -import { renderWithProviders } from "../../test/test-utils"; +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; -describe("ConnectionIndicator", () => { - it("renders connected state with override prop", () => { +import { renderWithProviders } from '../../test/test-utils'; +import ConnectionIndicator from '../ConnectionIndicator'; + +describe('ConnectionIndicator', () => { + it('renders connected state with override prop', () => { renderWithProviders(); - expect( - screen.getByText(/Connected to AlphaHuman AI/) - ).toBeInTheDocument(); + expect(screen.getByText(/Connected to AlphaHuman AI/)).toBeInTheDocument(); }); - it("renders disconnected state", () => { + it('renders disconnected state', () => { renderWithProviders(); - expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); }); - it("renders connecting state", () => { + it('renders connecting state', () => { renderWithProviders(); - expect(screen.getByText("Connecting")).toBeInTheDocument(); + expect(screen.getByText('Connecting')).toBeInTheDocument(); }); - it("renders description text when provided", () => { + it('renders description text when provided', () => { renderWithProviders( - + ); - expect(screen.getByText("Custom description")).toBeInTheDocument(); + expect(screen.getByText('Custom description')).toBeInTheDocument(); }); - it("does not render description when empty string", () => { - renderWithProviders( - - ); + it('does not render description when empty string', () => { + renderWithProviders(); // Default description should not appear - expect( - screen.queryByText(/Keep the app running/) - ).not.toBeInTheDocument(); + expect(screen.queryByText(/Keep the app running/)).not.toBeInTheDocument(); }); - it("falls back to store socket status when no override", () => { + it('falls back to store socket status when no override', () => { // Default store state has no socket connection → disconnected renderWithProviders(); - expect(screen.getByText("Disconnected")).toBeInTheDocument(); + expect(screen.getByText('Disconnected')).toBeInTheDocument(); }); }); diff --git a/src/components/__tests__/ProtectedRoute.test.tsx b/src/components/__tests__/ProtectedRoute.test.tsx index 2fe06baeb..e659d77e7 100644 --- a/src/components/__tests__/ProtectedRoute.test.tsx +++ b/src/components/__tests__/ProtectedRoute.test.tsx @@ -1,11 +1,12 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { Route, Routes } from "react-router-dom"; -import ProtectedRoute from "../ProtectedRoute"; -import { renderWithProviders } from "../../test/test-utils"; +import { screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; -describe("ProtectedRoute", () => { - it("renders children when token exists", () => { +import { renderWithProviders } from '../../test/test-utils'; +import ProtectedRoute from '../ProtectedRoute'; + +describe('ProtectedRoute', () => { + it('renders children when token exists', () => { renderWithProviders( { , { preloadedState: { - auth: { - token: "valid-jwt", - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.getByText("Protected Content")).toBeInTheDocument(); + expect(screen.getByText('Protected Content')).toBeInTheDocument(); }); - it("redirects to / when no token and requireAuth=true", () => { + it('redirects to / when no token and requireAuth=true', () => { renderWithProviders( { Landing
} /> , { - initialEntries: ["/dashboard"], + initialEntries: ['/dashboard'], preloadedState: { - auth: { - token: null, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.queryByText("Dashboard")).not.toBeInTheDocument(); - expect(screen.getByText("Landing")).toBeInTheDocument(); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); + expect(screen.getByText('Landing')).toBeInTheDocument(); }); - it("redirects to custom redirectTo when no token", () => { + it('redirects to custom redirectTo when no token', () => { renderWithProviders( { Login Page
} /> , { - initialEntries: ["/dashboard"], + initialEntries: ['/dashboard'], preloadedState: { - auth: { - token: null, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.getByText("Login Page")).toBeInTheDocument(); + expect(screen.getByText('Login Page')).toBeInTheDocument(); }); - it("redirects to /onboarding when requireOnboarded but not onboarded", () => { + it('redirects to /onboarding when requireOnboarded but not onboarded', () => { renderWithProviders( { Onboarding
} /> , { - initialEntries: ["/home"], + initialEntries: ['/home'], preloadedState: { - auth: { - token: "valid-jwt", - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, - user: { user: { _id: "u1" }, isLoading: false, error: null }, + auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, + user: { user: { _id: 'u1' }, isLoading: false, error: null }, }, } ); - expect(screen.getByText("Onboarding")).toBeInTheDocument(); + expect(screen.getByText('Onboarding')).toBeInTheDocument(); }); }); diff --git a/src/components/__tests__/PublicRoute.test.tsx b/src/components/__tests__/PublicRoute.test.tsx index ae606400a..b9dcaa987 100644 --- a/src/components/__tests__/PublicRoute.test.tsx +++ b/src/components/__tests__/PublicRoute.test.tsx @@ -1,11 +1,12 @@ -import { screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { Route, Routes } from "react-router-dom"; -import PublicRoute from "../PublicRoute"; -import { renderWithProviders } from "../../test/test-utils"; +import { screen } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; -describe("PublicRoute", () => { - it("renders children when user is not authenticated", () => { +import { renderWithProviders } from '../../test/test-utils'; +import PublicRoute from '../PublicRoute'; + +describe('PublicRoute', () => { + it('renders children when user is not authenticated', () => { renderWithProviders( { , { preloadedState: { - auth: { - token: null, - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.getByText("Welcome Page")).toBeInTheDocument(); + expect(screen.getByText('Welcome Page')).toBeInTheDocument(); }); - it("redirects to /home when user is authenticated", () => { + it('redirects to /home when user is authenticated', () => { renderWithProviders( { , { preloadedState: { - auth: { - token: "jwt-token", - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.queryByText("Welcome Page")).not.toBeInTheDocument(); - expect(screen.getByText("Home")).toBeInTheDocument(); + expect(screen.queryByText('Welcome Page')).not.toBeInTheDocument(); + expect(screen.getByText('Home')).toBeInTheDocument(); }); - it("redirects to custom path when authenticated", () => { + it('redirects to custom path when authenticated', () => { renderWithProviders( { , { preloadedState: { - auth: { - token: "jwt-token", - isOnboardedByUser: {}, - isAnalyticsEnabledByUser: {}, - }, + auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} }, }, } ); - expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); }); }); diff --git a/src/components/settings/hooks/useSettingsNavigation.ts b/src/components/settings/hooks/useSettingsNavigation.ts index cc10eda62..c8fccae1a 100644 --- a/src/components/settings/hooks/useSettingsNavigation.ts +++ b/src/components/settings/hooks/useSettingsNavigation.ts @@ -70,7 +70,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { navigate('/home'); } else if (currentRoute === 'team-members' || currentRoute === 'team-invites') { // Check if we're in team management context (has teamId in URL) - const teamManageMatch = location.pathname.match(/\/team\/manage\/([^\/]+)/); + const teamManageMatch = location.pathname.match(/\/team\/manage\/([^/]+)/); if (teamManageMatch) { const teamId = teamManageMatch[1]; navigate(`/settings/team/manage/${teamId}`); diff --git a/src/hooks/useIntelligenceStats.ts b/src/hooks/useIntelligenceStats.ts index 320bddeb1..1625e987a 100644 --- a/src/hooks/useIntelligenceStats.ts +++ b/src/hooks/useIntelligenceStats.ts @@ -105,13 +105,5 @@ export function useIntelligenceStats(): IntelligenceStats { fetchStats(); }, [fetchStats, aiStatus]); - return { - sessions, - memoryFiles, - entities, - entityError, - aiStatus, - isLoading, - refetch: fetchStats, - }; + return { sessions, memoryFiles, entities, entityError, aiStatus, isLoading, refetch: fetchStats }; } diff --git a/src/index.css b/src/index.css index 68ce3b159..ab0607de8 100644 --- a/src/index.css +++ b/src/index.css @@ -259,6 +259,15 @@ background-color: rgb(115 115 115); } + /* Hide scrollbar while keeping scroll behavior */ + .scrollbar-hide { + scrollbar-width: none; + -ms-overflow-style: none; + } + .scrollbar-hide::-webkit-scrollbar { + display: none; + } + /* Text selection styling */ .select-primary::selection { @apply bg-primary-600 text-white; diff --git a/src/pages/Conversations.tsx b/src/pages/Conversations.tsx index 5ebf582b5..664fd4907 100644 --- a/src/pages/Conversations.tsx +++ b/src/pages/Conversations.tsx @@ -1,20 +1,36 @@ -import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import Markdown from 'react-markdown'; +import { useNavigate, useParams } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { addOptimisticMessage, + clearCreateStatus, + clearDeleteStatus, + clearPurgeStatus, clearSelectedThread, + clearSendError, createThread, + deleteThread, + fetchSuggestedQuestions, fetchThreadMessages, fetchThreads, purgeThreads, sendMessage, + setLastViewed, + setPanelWidth, setSelectedThread, } from '../store/threadSlice'; const MIN_PANEL_WIDTH = 200; const MAX_PANEL_WIDTH = 480; -const DEFAULT_PANEL_WIDTH = 320; function formatRelativeTime(dateStr: string): string { const now = Date.now(); @@ -31,55 +47,112 @@ function formatRelativeTime(dateStr: string): string { const Conversations = () => { const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { threadId: urlThreadId } = useParams<{ threadId?: string }>(); const { threads, isLoading, + error, selectedThreadId, messages, isLoadingMessages, + messagesError, createStatus, + deleteStatus, purgeStatus, sendStatus, sendError, + panelWidth, + lastViewedAt, + suggestedQuestions, + isLoadingSuggestions, } = useAppSelector(state => state.thread); const [showPurgeConfirm, setShowPurgeConfirm] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [inputValue, setInputValue] = useState(''); - const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH); + const [searchQuery, setSearchQuery] = useState(''); + const [copiedMessageId, setCopiedMessageId] = useState(null); const isDragging = useRef(false); const messagesEndRef = useRef(null); + const lastPanelWidthRef = useRef(panelWidth); - const handleResizePointerDown = useCallback((e: ReactPointerEvent) => { - e.preventDefault(); - isDragging.current = true; - const startX = e.clientX; - const startWidth = panelWidth; + // Filtered threads based on search query (#13) + const filteredThreads = useMemo(() => { + if (!searchQuery.trim()) return threads; + const q = searchQuery.toLowerCase(); + return threads.filter(t => (t.title || 'Untitled Thread').toLowerCase().includes(q)); + }, [threads, searchQuery]); - const onPointerMove = (ev: globalThis.PointerEvent) => { - const delta = ev.clientX - startX; - const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta)); - setPanelWidth(newWidth); - }; + // Unread: thread has messages since last view (#15) + const isThreadUnread = useCallback( + (thread: { id: string; lastMessageAt?: string | null; createdAt: string }) => { + const viewed = lastViewedAt[thread.id]; + const lastMsg = new Date(thread.lastMessageAt || thread.createdAt).getTime(); + return viewed == null || lastMsg > viewed; + }, + [lastViewedAt] + ); - const onPointerUp = () => { - isDragging.current = false; - document.removeEventListener('pointermove', onPointerMove); - document.removeEventListener('pointerup', onPointerUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; + // Mobile: detect small screens for responsive layout (#12) + const [isMobile, setIsMobile] = useState(false); + useEffect(() => { + const mq = window.matchMedia('(max-width: 767px)'); + setIsMobile(mq.matches); + const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); - document.addEventListener('pointermove', onPointerMove); - document.addEventListener('pointerup', onPointerUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }, [panelWidth]); + const handleResizePointerDown = useCallback( + (e: ReactPointerEvent) => { + e.preventDefault(); + isDragging.current = true; + const startX = e.clientX; + const startWidth = panelWidth; + + const onPointerMove = (ev: globalThis.PointerEvent) => { + const delta = ev.clientX - startX; + const newWidth = Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, startWidth + delta)); + lastPanelWidthRef.current = newWidth; + dispatch(setPanelWidth(newWidth)); + }; + + const onPointerUp = () => { + isDragging.current = false; + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, + [panelWidth, dispatch] + ); // Fetch threads on mount useEffect(() => { dispatch(fetchThreads()); }, [dispatch]); + // Sync URL → Redux: when URL has a threadId param, select that thread + useEffect(() => { + if (urlThreadId && urlThreadId !== selectedThreadId) { + dispatch(setSelectedThread(urlThreadId)); + } else if (!urlThreadId && selectedThreadId) { + dispatch(clearSelectedThread()); + } + }, [urlThreadId]); // eslint-disable-line react-hooks/exhaustive-deps + + // Mark thread as viewed when selected (#15) — stored in Redux (persisted via redux-persist) + useEffect(() => { + if (selectedThreadId) dispatch(setLastViewed(selectedThreadId)); + }, [selectedThreadId, dispatch]); + // Fetch messages when a thread is selected useEffect(() => { if (selectedThreadId) { @@ -87,6 +160,13 @@ const Conversations = () => { } }, [dispatch, selectedThreadId]); + // Fetch suggested questions when thread is empty (beginning of new thread) + useEffect(() => { + if (selectedThreadId && messages.length === 0) { + dispatch(fetchSuggestedQuestions(selectedThreadId)); + } + }, [selectedThreadId, messages.length, dispatch]); + // Auto-scroll to bottom when messages load useEffect(() => { if (messages.length > 0) { @@ -94,25 +174,62 @@ const Conversations = () => { } }, [messages]); + // Clear transient status flags after they settle + useEffect(() => { + if (createStatus === 'success' || createStatus === 'error') { + dispatch(clearCreateStatus()); + } + }, [createStatus, dispatch]); + + useEffect(() => { + if (deleteStatus === 'success' || deleteStatus === 'error') { + dispatch(clearDeleteStatus()); + } + }, [deleteStatus, dispatch]); + + useEffect(() => { + if (purgeStatus === 'success' || purgeStatus === 'error') { + dispatch(clearPurgeStatus()); + } + }, [purgeStatus, dispatch]); + + // Clear send error when user starts typing again + useEffect(() => { + if (sendError && inputValue.length > 0) { + dispatch(clearSendError()); + } + }, [inputValue, sendError, dispatch]); + const handleSelectThread = (threadId: string) => { if (threadId === selectedThreadId) return; - dispatch(setSelectedThread(threadId)); + navigate(`/conversations/${threadId}`, { replace: true }); }; - const handleNewThread = () => { - dispatch(createThread(undefined)); + const handleNewThread = async () => { + const result = await dispatch(createThread(undefined)); + if (createThread.fulfilled.match(result)) { + navigate(`/conversations/${result.payload.id}`, { replace: true }); + } + }; + + const handleDeleteThread = async (threadId: string) => { + const result = await dispatch(deleteThread(threadId)); + setConfirmDeleteId(null); + if (deleteThread.fulfilled.match(result) && threadId === selectedThreadId) { + navigate('/conversations', { replace: true }); + } }; const handlePurge = async () => { const result = await dispatch(purgeThreads()); if (purgeThreads.fulfilled.match(result)) { setShowPurgeConfirm(false); - dispatch(clearSelectedThread()); + navigate('/conversations', { replace: true }); } }; - const handleSendMessage = () => { - const trimmed = inputValue.trim(); + const handleSendMessage = (text?: string) => { + const trimmed = text ?? inputValue.trim(); if (!trimmed || !selectedThreadId || sendStatus === 'loading') return; dispatch(addOptimisticMessage({ content: trimmed })); setInputValue(''); @@ -126,93 +243,556 @@ const Conversations = () => { } }; + // Copy message to clipboard (#10) + const handleCopyMessage = async (messageId: string, content: string) => { + try { + await navigator.clipboard.writeText(content); + setCopiedMessageId(messageId); + setTimeout(() => setCopiedMessageId(null), 1500); + } catch { + // Clipboard API not available — silently fail + } + }; + + // Mobile: back to thread list + const handleMobileBack = () => { + navigate('/conversations', { replace: true }); + }; + const selectedThread = threads.find(t => t.id === selectedThreadId); + // Mobile layout: show only one panel at a time (#12) + const showThreadList = !isMobile || !selectedThreadId; + const showMessages = !isMobile || !!selectedThreadId; + return (
{/* Left Panel: Thread List */} -
- {/* Header */} -
-

Conversations

- -
+ {showThreadList && ( +
+ {/* Header */} +
+

Conversations

+ +
- {/* Thread list */} -
- {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} + {/* Search bar (#13) */} + {threads.length > 0 && ( +
+
+ + + + setSearchQuery(e.target.value)} + placeholder="Search threads..." + className="w-full bg-white/5 border border-white/10 rounded-lg pl-8 pr-3 py-1.5 text-xs placeholder:text-stone-500 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all" + /> + {searchQuery && ( + + )} +
- ) : threads.length > 0 ? ( -
- {threads.map(thread => ( + )} + + {/* Thread list */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : error ? ( +
+ + + +

Failed to load conversations

+

{error}

- ))} +
+ ) : filteredThreads.length > 0 ? ( +
+ {filteredThreads.map(thread => ( +
+ {confirmDeleteId === thread.id ? ( +
+ + Delete this thread? + +
+ + +
+
+ ) : ( + <> + + {/* Delete button — visible on hover */} + + + )} +
+ ))} +
+ ) : threads.length > 0 && searchQuery ? ( +
+

No matching threads

+ +
+ ) : ( +
+ + + +

No conversations yet

+ +
+ )} +
+ + {/* Footer: Delete All */} + {threads.length > 0 && ( +
+ {showPurgeConfirm ? ( +
+ Delete all threads? +
+ + +
+
+ ) : ( + + )}
+ )} +
+ )} + + {/* Resize Handle — desktop only */} + {!isMobile && ( +
+ )} + + {/* Right Panel: Messages */} + {showMessages && ( +
+ {selectedThread ? ( + <> + {/* Thread header */} +
+ {/* Mobile back button (#12) */} + {isMobile && ( + + )} +
+
+

+ {selectedThread.title || 'Untitled Thread'} +

+ {selectedThread.isActive && ( + + Active + + )} +
+

+ Created {formatRelativeTime(selectedThread.createdAt)} +

+
+
+ + {/* Messages */} +
+ {isLoadingMessages ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : messagesError ? ( +
+ + + +

Failed to load messages

+

{messagesError}

+ +
+ ) : messages.length > 0 ? ( +
+ {messages.map(msg => ( +
+
+
+ {msg.sender === 'agent' ? ( +
+ {msg.content} +
+ ) : ( +

+ {msg.content} +

+ )} +

+ {formatRelativeTime(msg.createdAt)} +

+
+ {/* Copy button (#10) */} + +
+
+ ))} + {/* Typing indicator (#14) */} + {sendStatus === 'loading' && ( +
+
+
+ + + +
+
+
+ )} +
+
+ ) : ( +
+

No messages in this thread

+
+ )} +
+ + {/* Suggested questions — only at start of new thread (no messages yet); horizontal scroll */} + {messages.length === 0 && suggestedQuestions.length > 0 && !isLoadingSuggestions && ( +
+
+ {suggestedQuestions.map((s, i) => ( + + ))} +
+
+ )} + + {/* Message Input */} +
+ {sendError && ( +
+

{sendError}

+ +
+ )} +
+