Merge pull request #15 from matt1398/dev
Docker support, session management, context insights, and subagent display improvements
This commit is contained in:
commit
eda80b90ea
61 changed files with 3319 additions and 2015 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-standalone
|
||||
out
|
||||
release
|
||||
.git
|
||||
.claude
|
||||
*.md
|
||||
!README.md
|
||||
!SECURITY.md
|
||||
!CONTRIBUTING.md
|
||||
!CODE_OF_CONDUCT.md
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ node_modules/
|
|||
# Build output
|
||||
dist/
|
||||
dist-electron/
|
||||
dist-standalone/
|
||||
out/
|
||||
release/
|
||||
coverage/
|
||||
|
|
|
|||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# =============================================================================
|
||||
# claude-devtools standalone Docker image
|
||||
#
|
||||
# Runs the HTTP server without Electron, serving the full UI over HTTP.
|
||||
# Mount your ~/.claude directory to make session data available.
|
||||
#
|
||||
# Build: docker build -t claude-devtools .
|
||||
# Run: docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
|
||||
# =============================================================================
|
||||
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Install dependencies first (better layer caching)
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN pnpm standalone:build
|
||||
|
||||
# =============================================================================
|
||||
# Production stage — minimal image with only the built output
|
||||
# =============================================================================
|
||||
FROM node:20-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Copy package files and install production-only dependencies
|
||||
# (fastify, @fastify/cors, @fastify/static are externalized from the bundle)
|
||||
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
# Copy built standalone server and renderer output
|
||||
COPY --from=builder /app/dist-standalone ./dist-standalone
|
||||
COPY --from=builder /app/out/renderer ./out/renderer
|
||||
|
||||
# Create data directory for Claude session mount
|
||||
RUN mkdir -p /data/.claude
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV CLAUDE_ROOT=/data/.claude
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3456
|
||||
|
||||
EXPOSE 3456
|
||||
|
||||
CMD ["node", "dist-standalone/index.cjs"]
|
||||
62
README.md
62
README.md
|
|
@ -17,7 +17,7 @@
|
|||
<a href="https://github.com/matt1398/claude-devtools/releases/latest"><img src="https://img.shields.io/github/v/release/matt1398/claude-devtools?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml"><img src="https://github.com/matt1398/claude-devtools/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://github.com/matt1398/claude-devtools/releases"><img src="https://img.shields.io/github/downloads/matt1398/claude-devtools/total?style=flat-square&color=green" alt="Downloads" /></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows-lightgrey?style=flat-square" alt="Platform" />
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20(Apple%20Silicon%20%2B%20Intel)%20%7C%20Linux%20%7C%20Windows%20%7C%20Docker-lightgrey?style=flat-square" alt="Platform" />
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
|
@ -185,6 +185,66 @@ Every tool call is paired with its result in an expandable card. Specialized vie
|
|||
|
||||
---
|
||||
|
||||
## Docker / Standalone Deployment
|
||||
|
||||
Run claude-devtools without Electron — in Docker, on a remote server, or anywhere Node.js runs.
|
||||
|
||||
### Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Open `http://localhost:3456` in your browser.
|
||||
|
||||
### Quick Start (Docker)
|
||||
|
||||
```bash
|
||||
docker build -t claude-devtools .
|
||||
docker run -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
|
||||
```
|
||||
|
||||
### Quick Start (Node.js)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm standalone:build
|
||||
node dist-standalone/index.cjs
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_ROOT` | `~/.claude` | Path to the `.claude` data directory |
|
||||
| `HOST` | `0.0.0.0` | Bind address |
|
||||
| `PORT` | `3456` | Listen port |
|
||||
| `CORS_ORIGIN` | `*` (standalone) | CORS origin policy (`*`, specific origin, or comma-separated list) |
|
||||
|
||||
### Notes
|
||||
|
||||
- **Real-time updates may be slower than Electron.** The Electron app uses native file system watchers with IPC for instant updates. The Docker/standalone server uses SSE (Server-Sent Events) over HTTP, which may introduce slight delays when sessions are actively being written to.
|
||||
- **Custom Claude root path.** If your `.claude` directory is not at `~/.claude`, update the volume mount to point to the correct location:
|
||||
```bash
|
||||
# Example: Claude root at /home/user/custom-claude-dir
|
||||
docker run -p 3456:3456 -v /home/user/custom-claude-dir:/data/.claude:ro claude-devtools
|
||||
|
||||
# Or with docker compose, set the CLAUDE_DIR env variable:
|
||||
CLAUDE_DIR=/home/user/custom-claude-dir docker compose up
|
||||
```
|
||||
|
||||
### Security-Focused Deployment
|
||||
|
||||
The standalone server has **zero** outbound network calls. For maximum isolation:
|
||||
|
||||
```bash
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
|
||||
```
|
||||
|
||||
See [SECURITY.md](SECURITY.md) for a full audit of network activity.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
<details>
|
||||
|
|
|
|||
44
SECURITY.md
44
SECURITY.md
|
|
@ -1,9 +1,50 @@
|
|||
# Security Policy
|
||||
# Security & Privacy
|
||||
|
||||
## Network Activity
|
||||
|
||||
claude-devtools makes **zero** outbound network calls to third-party servers. There is no telemetry, analytics, tracking, or data exfiltration of any kind.
|
||||
|
||||
| Network activity | When | Mode | User-initiated |
|
||||
|---|---|---|---|
|
||||
| GitHub Releases API (auto-updater) | App launch | Electron only | No (automatic) |
|
||||
| SSH connections | Settings > SSH | Electron only | Yes |
|
||||
| HTTP server (`127.0.0.1` or `0.0.0.0`) | When enabled | Both | Yes |
|
||||
|
||||
### Standalone / Docker mode
|
||||
|
||||
In standalone mode (Docker or `node dist-standalone/index.cjs`), the auto-updater and SSH features are disabled entirely. The only network activity is the HTTP server listening for incoming connections on the configured port.
|
||||
|
||||
## Data Handling
|
||||
|
||||
- All session data is read **locally** from `~/.claude/` — it never leaves your machine.
|
||||
- The app does not write to session files. Volume mounts in Docker use `:ro` (read-only) by default.
|
||||
- Configuration is stored at `~/.claude/claude-devtools-config.json` on the local filesystem.
|
||||
- No data is sent to Anthropic, GitHub (other than the auto-updater in Electron mode), or any other third party.
|
||||
|
||||
## Docker Network Isolation
|
||||
|
||||
For maximum trust, run the Docker container with `--network none`:
|
||||
|
||||
```bash
|
||||
docker build -t claude-devtools .
|
||||
docker run --network none -p 3456:3456 -v ~/.claude:/data/.claude:ro claude-devtools
|
||||
```
|
||||
|
||||
Or with Docker Compose, uncomment `network_mode: "none"` in `docker-compose.yml`.
|
||||
|
||||
## IPC & Input Validation
|
||||
|
||||
- All IPC handlers validate inputs with strict path containment checks
|
||||
- File reads are constrained to the project root and `~/.claude/`
|
||||
- Path traversal attacks are blocked
|
||||
- Sensitive credential paths are rejected
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Only the latest release is supported with security fixes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report vulnerabilities privately and do not open public issues for undisclosed security problems.
|
||||
|
||||
Include:
|
||||
|
|
@ -15,6 +56,7 @@ Include:
|
|||
If you do not have a private contact path yet, open a minimal GitHub issue asking for a secure reporting channel without disclosing technical details.
|
||||
|
||||
## Disclosure Process
|
||||
|
||||
- We will acknowledge reports as quickly as possible.
|
||||
- We will validate, triage severity, and prepare a fix.
|
||||
- We will coordinate a release and publish advisories when appropriate.
|
||||
|
|
|
|||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# =============================================================================
|
||||
# claude-devtools — Docker Compose
|
||||
#
|
||||
# Quick start:
|
||||
# docker compose up
|
||||
#
|
||||
# Then open http://localhost:3456 in your browser.
|
||||
#
|
||||
# Security note:
|
||||
# The standalone server has zero outbound network calls — no telemetry,
|
||||
# no analytics, no auto-updater. For maximum isolation, uncomment
|
||||
# network_mode below.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
claude-devtools:
|
||||
build: .
|
||||
ports:
|
||||
- "3456:3456"
|
||||
volumes:
|
||||
- ${CLAUDE_DIR:-~/.claude}:/data/.claude:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- CLAUDE_ROOT=/data/.claude
|
||||
- HOST=0.0.0.0
|
||||
- PORT=3456
|
||||
restart: unless-stopped
|
||||
# Uncomment for maximum network isolation (no outbound connections):
|
||||
# network_mode: "none"
|
||||
|
|
@ -2,13 +2,13 @@
|
|||
"$schema": "https://unpkg.com/knip@next/schema.json",
|
||||
"entry": [
|
||||
"src/main/index.ts",
|
||||
"src/main/standalone.ts",
|
||||
"src/preload/index.ts",
|
||||
"src/renderer/main.tsx",
|
||||
"electron.vite.config.ts",
|
||||
"remotion/index.ts",
|
||||
"remotion/**/*.{ts,tsx}"
|
||||
"vite.standalone.config.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}!", "remotion/**/*.{ts,tsx}!"],
|
||||
"project": ["src/**/*.{ts,tsx}!"],
|
||||
"ignore": ["tsconfig*.json"],
|
||||
"paths": {
|
||||
"@main/*": ["./src/main/*"],
|
||||
|
|
@ -16,6 +16,5 @@
|
|||
"@preload/*": ["./src/preload/*"],
|
||||
"@shared/*": ["./src/shared/*"]
|
||||
},
|
||||
"ignoreDependencies": ["@remotion/light-leaks", "remotion"],
|
||||
"ignoreBinaries": ["pkg"]
|
||||
}
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -43,9 +43,9 @@
|
|||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:critical": "vitest run --coverage --config vitest.critical.config.ts",
|
||||
"remotion:preview": "remotion studio remotion/index.ts",
|
||||
"remotion:render": "remotion render remotion/index.ts DemoVideo out/demo.mp4",
|
||||
"remotion:render:gif": "remotion render remotion/index.ts DemoVideo out/demo.gif --image-format png"
|
||||
"standalone": "tsx src/main/standalone.ts",
|
||||
"standalone:build": "electron-vite build && vite build --config vite.standalone.config.ts",
|
||||
"standalone:start": "node dist-standalone/index.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -53,7 +53,6 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@remotion/light-leaks": "4.0.421",
|
||||
"@tanstack/react-virtual": "^3.10.8",
|
||||
"date-fns": "^3.6.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
|
|
@ -74,10 +73,6 @@
|
|||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.6.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@remotion/cli": "^4.0.421",
|
||||
"@remotion/google-fonts": "^4.0.421",
|
||||
"@remotion/media": "^4.0.421",
|
||||
"@remotion/transitions": "^4.0.421",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/mdast": "^4.0.4",
|
||||
|
|
@ -110,7 +105,6 @@
|
|||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"remotion": "^4.0.421",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
|
|
@ -130,6 +124,9 @@
|
|||
"package.json"
|
||||
],
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"out/renderer/**"
|
||||
],
|
||||
"npmRebuild": false,
|
||||
"extraMetadata": {
|
||||
"main": "dist-electron/main/index.cjs"
|
||||
|
|
|
|||
1393
pnpm-lock.yaml
1393
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -382,6 +382,94 @@ export function registerConfigRoutes(app: FastifyInstance): void {
|
|||
}
|
||||
);
|
||||
|
||||
// Hide session
|
||||
app.post<{ Body: { projectId: string; sessionId: string } }>(
|
||||
'/api/config/hide-session',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionId } = request.body;
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return { success: false, error: 'Session ID is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.hideSession(projectId, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/hide-session:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Unhide session
|
||||
app.post<{ Body: { projectId: string; sessionId: string } }>(
|
||||
'/api/config/unhide-session',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionId } = request.body;
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return { success: false, error: 'Session ID is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.unhideSession(projectId, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/unhide-session:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Bulk hide sessions
|
||||
app.post<{ Body: { projectId: string; sessionIds: string[] } }>(
|
||||
'/api/config/hide-sessions',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionIds } = request.body;
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
||||
return { success: false, error: 'Session IDs must be an array of strings' };
|
||||
}
|
||||
|
||||
configManager.hideSessions(projectId, sessionIds);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/hide-sessions:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Bulk unhide sessions
|
||||
app.post<{ Body: { projectId: string; sessionIds: string[] } }>(
|
||||
'/api/config/unhide-sessions',
|
||||
async (request) => {
|
||||
try {
|
||||
const { projectId, sessionIds } = request.body;
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
||||
return { success: false, error: 'Session IDs must be an array of strings' };
|
||||
}
|
||||
|
||||
configManager.unhideSessions(projectId, sessionIds);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in POST /api/config/unhide-sessions:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Select folders - no-op in browser mode
|
||||
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
||||
return { success: true, data: [] };
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
}
|
||||
|
||||
// Wire file-change events to renderer and HTTP SSE
|
||||
const fileChangeHandler = (event: unknown) => {
|
||||
const fileChangeHandler = (event: unknown): void => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('file-change', event);
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
fileChangeCleanup = () => context.fileWatcher.off('file-change', fileChangeHandler);
|
||||
|
||||
// Forward checklist-change events to renderer and HTTP SSE (mirrors file-change pattern above)
|
||||
const todoChangeHandler = (event: unknown) => {
|
||||
const todoChangeHandler = (event: unknown): void => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('todo-change', event);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@
|
|||
* - config:testTrigger: Test a trigger against historical session data
|
||||
*/
|
||||
|
||||
import {
|
||||
getAutoDetectedClaudeBasePath,
|
||||
getClaudeBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { getAutoDetectedClaudeBasePath, getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
|
|
@ -54,9 +51,8 @@ const execFileAsync = promisify(execFile);
|
|||
|
||||
// Get singleton instance
|
||||
const configManager = ConfigManager.getInstance();
|
||||
let onClaudeRootPathUpdated:
|
||||
| ((claudeRootPath: string | null) => Promise<void> | void)
|
||||
| null = null;
|
||||
let onClaudeRootPathUpdated: ((claudeRootPath: string | null) => Promise<void> | void) | null =
|
||||
null;
|
||||
|
||||
/**
|
||||
* Response type for config operations
|
||||
|
|
@ -111,6 +107,12 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle('config:pinSession', handlePinSession);
|
||||
ipcMain.handle('config:unpinSession', handleUnpinSession);
|
||||
|
||||
// Session hide handlers
|
||||
ipcMain.handle('config:hideSession', handleHideSession);
|
||||
ipcMain.handle('config:unhideSession', handleUnhideSession);
|
||||
ipcMain.handle('config:hideSessions', handleHideSessions);
|
||||
ipcMain.handle('config:unhideSessions', handleUnhideSessions);
|
||||
|
||||
// Dialog handlers
|
||||
ipcMain.handle('config:selectFolders', handleSelectFolders);
|
||||
ipcMain.handle('config:selectClaudeRootFolder', handleSelectClaudeRootFolder);
|
||||
|
|
@ -789,9 +791,10 @@ function decodeWslOutput(output: string | Buffer | undefined): string {
|
|||
}
|
||||
|
||||
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
|
||||
const decoded = hasUtf16LeBom || looksLikeUtf16Le(output)
|
||||
? output.toString('utf16le')
|
||||
: output.toString('utf8');
|
||||
const decoded =
|
||||
hasUtf16LeBom || looksLikeUtf16Le(output)
|
||||
? output.toString('utf16le')
|
||||
: output.toString('utf8');
|
||||
return decoded.replace(/\0/g, '');
|
||||
}
|
||||
|
||||
|
|
@ -853,11 +856,7 @@ function parseWslDistros(stdout: string): string[] {
|
|||
}
|
||||
|
||||
async function listWslDistros(): Promise<string[]> {
|
||||
const commands: string[][] = [
|
||||
['--list', '--quiet'],
|
||||
['-l', '-q'],
|
||||
['-l'],
|
||||
];
|
||||
const commands: string[][] = [['--list', '--quiet'], ['-l', '-q'], ['-l']];
|
||||
|
||||
for (const command of commands) {
|
||||
try {
|
||||
|
|
@ -885,10 +884,7 @@ function stripDefaultSuffix(input: string): string {
|
|||
|
||||
async function resolveWslHome(distro: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runWsl(
|
||||
['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'],
|
||||
5000
|
||||
);
|
||||
const { stdout } = await runWsl(['-d', distro, '--', 'sh', '-lc', 'printf %s "$HOME"'], 5000);
|
||||
return normalizeWslHomePath(stdout);
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -958,6 +954,102 @@ async function handleFindWslClaudeRoots(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:hideSession' - Hides a session for a project.
|
||||
*/
|
||||
async function handleHideSession(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionId: string
|
||||
): Promise<ConfigResult> {
|
||||
try {
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return { success: false, error: 'Session ID is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.hideSession(projectId, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:hideSession:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:unhideSession' - Unhides a session for a project.
|
||||
*/
|
||||
async function handleUnhideSession(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionId: string
|
||||
): Promise<ConfigResult> {
|
||||
try {
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
return { success: false, error: 'Session ID is required and must be a string' };
|
||||
}
|
||||
|
||||
configManager.unhideSession(projectId, sessionId);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:unhideSession:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:hideSessions' - Bulk hide sessions for a project.
|
||||
*/
|
||||
async function handleHideSessions(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionIds: string[]
|
||||
): Promise<ConfigResult> {
|
||||
try {
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
||||
return { success: false, error: 'Session IDs must be an array of strings' };
|
||||
}
|
||||
|
||||
configManager.hideSessions(projectId, sessionIds);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:hideSessions:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for 'config:unhideSessions' - Bulk unhide sessions for a project.
|
||||
*/
|
||||
async function handleUnhideSessions(
|
||||
_event: IpcMainInvokeEvent,
|
||||
projectId: string,
|
||||
sessionIds: string[]
|
||||
): Promise<ConfigResult> {
|
||||
try {
|
||||
if (!projectId || typeof projectId !== 'string') {
|
||||
return { success: false, error: 'Project ID is required and must be a string' };
|
||||
}
|
||||
if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== 'string')) {
|
||||
return { success: false, error: 'Session IDs must be an array of strings' };
|
||||
}
|
||||
|
||||
configManager.unhideSessions(projectId, sessionIds);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error('Error in config:unhideSessions:', error);
|
||||
return { success: false, error: getErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup
|
||||
// =============================================================================
|
||||
|
|
@ -982,6 +1074,10 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler('config:testTrigger');
|
||||
ipcMain.removeHandler('config:pinSession');
|
||||
ipcMain.removeHandler('config:unpinSession');
|
||||
ipcMain.removeHandler('config:hideSession');
|
||||
ipcMain.removeHandler('config:unhideSession');
|
||||
ipcMain.removeHandler('config:hideSessions');
|
||||
ipcMain.removeHandler('config:unhideSessions');
|
||||
ipcMain.removeHandler('config:selectFolders');
|
||||
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
||||
ipcMain.removeHandler('config:getClaudeRootInfo');
|
||||
|
|
|
|||
|
|
@ -758,6 +758,9 @@ export class ProjectScanner {
|
|||
isOngoing: metadata.isOngoing,
|
||||
gitBranch: metadata.gitBranch ?? undefined,
|
||||
metadataLevel,
|
||||
contextConsumption: metadata.contextConsumption,
|
||||
compactionCount: metadata.compactionCount,
|
||||
phaseBreakdown: metadata.phaseBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export interface DisplayConfig {
|
|||
|
||||
export interface SessionsConfig {
|
||||
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
||||
hiddenSessions: Record<string, { sessionId: string; hiddenAt: number }[]>;
|
||||
}
|
||||
|
||||
export interface SshPersistConfig {
|
||||
|
|
@ -255,6 +256,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
},
|
||||
sessions: {
|
||||
pinnedSessions: {},
|
||||
hiddenSessions: {},
|
||||
},
|
||||
ssh: {
|
||||
lastConnection: null,
|
||||
|
|
@ -746,6 +748,89 @@ export class ConfigManager {
|
|||
this.saveConfig();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Session Hide Management
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Hides a session for a project.
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID to hide
|
||||
*/
|
||||
hideSession(projectId: string, sessionId: string): void {
|
||||
const hidden = this.config.sessions.hiddenSessions[projectId] ?? [];
|
||||
|
||||
if (hidden.some((h) => h.sessionId === sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.sessions.hiddenSessions[projectId] = [
|
||||
{ sessionId, hiddenAt: Date.now() },
|
||||
...hidden,
|
||||
];
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unhides a session for a project.
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID to unhide
|
||||
*/
|
||||
unhideSession(projectId: string, sessionId: string): void {
|
||||
const hidden = this.config.sessions.hiddenSessions[projectId];
|
||||
if (!hidden) return;
|
||||
|
||||
this.config.sessions.hiddenSessions[projectId] = hidden.filter(
|
||||
(h) => h.sessionId !== sessionId
|
||||
);
|
||||
|
||||
if (this.config.sessions.hiddenSessions[projectId].length === 0) {
|
||||
delete this.config.sessions.hiddenSessions[projectId];
|
||||
}
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides multiple sessions for a project in a single write.
|
||||
* @param projectId - The project ID
|
||||
* @param sessionIds - The session IDs to hide
|
||||
*/
|
||||
hideSessions(projectId: string, sessionIds: string[]): void {
|
||||
const hidden = this.config.sessions.hiddenSessions[projectId] ?? [];
|
||||
const existingIds = new Set(hidden.map((h) => h.sessionId));
|
||||
const now = Date.now();
|
||||
const newEntries = sessionIds
|
||||
.filter((id) => !existingIds.has(id))
|
||||
.map((sessionId) => ({ sessionId, hiddenAt: now }));
|
||||
|
||||
if (newEntries.length === 0) return;
|
||||
|
||||
this.config.sessions.hiddenSessions[projectId] = [...newEntries, ...hidden];
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unhides multiple sessions for a project in a single write.
|
||||
* @param projectId - The project ID
|
||||
* @param sessionIds - The session IDs to unhide
|
||||
*/
|
||||
unhideSessions(projectId: string, sessionIds: string[]): void {
|
||||
const hidden = this.config.sessions.hiddenSessions[projectId];
|
||||
if (!hidden) return;
|
||||
|
||||
const toRemove = new Set(sessionIds);
|
||||
this.config.sessions.hiddenSessions[projectId] = hidden.filter(
|
||||
(h) => !toRemove.has(h.sessionId)
|
||||
);
|
||||
|
||||
if (this.config.sessions.hiddenSessions[projectId].length === 0) {
|
||||
delete this.config.sessions.hiddenSessions[projectId];
|
||||
}
|
||||
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SSH Profile Management
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -13,11 +13,35 @@ import { type HttpServices, registerHttpRoutes } from '@main/http';
|
|||
import { broadcastEvent } from '@main/http/events';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const logger = createLogger('Service:HttpServer');
|
||||
|
||||
/**
|
||||
* Resolves the renderer output directory from multiple candidate paths.
|
||||
* Returns the first path that exists on disk.
|
||||
*/
|
||||
function resolveRendererPath(): string | null {
|
||||
const candidates = [
|
||||
// Electron production (asarUnpack): app.asar.unpacked/out/renderer (real filesystem)
|
||||
join(__dirname, '../../out/renderer').replace('app.asar', 'app.asar.unpacked'),
|
||||
// Electron production (asar fallback): app.asar/out/renderer
|
||||
join(__dirname, '../../out/renderer'),
|
||||
// Standalone: dist-standalone/index.cjs → ../out/renderer
|
||||
join(__dirname, '../out/renderer'),
|
||||
// Fallback: relative to cwd (dev mode, standalone)
|
||||
join(process.cwd(), 'out/renderer'),
|
||||
];
|
||||
|
||||
// Allow explicit override via env
|
||||
if (process.env.RENDERER_PATH) {
|
||||
candidates.unshift(process.env.RENDERER_PATH);
|
||||
}
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
export class HttpServer {
|
||||
private app: FastifyInstance | null = null;
|
||||
private port: number = 3456;
|
||||
|
|
@ -28,70 +52,83 @@ export class HttpServer {
|
|||
* @param services - Service instances to pass to route handlers
|
||||
* @param sshModeSwitchCallback - Callback for SSH mode switching
|
||||
* @param preferredPort - Port to try first (default 3456)
|
||||
* @param host - Host to bind to (default '127.0.0.1')
|
||||
*/
|
||||
async start(
|
||||
services: HttpServices,
|
||||
sshModeSwitchCallback: (mode: 'local' | 'ssh') => Promise<void>,
|
||||
preferredPort: number = 3456
|
||||
preferredPort: number = 3456,
|
||||
host: string = '127.0.0.1'
|
||||
): Promise<number> {
|
||||
this.app = Fastify({ logger: false });
|
||||
|
||||
// Register CORS - allow all localhost origins
|
||||
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
|
||||
await this.app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
// Allow requests with no origin (same-origin, curl, etc.)
|
||||
if (!origin) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
// Allow any localhost origin
|
||||
if (localhostPattern.test(origin)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
cb(new Error('Not allowed by CORS'), false);
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
// Register CORS
|
||||
const corsOrigin = process.env.CORS_ORIGIN;
|
||||
if (corsOrigin === '*') {
|
||||
// Standalone/Docker mode: allow all origins (Docker network isolation replaces CORS)
|
||||
await this.app.register(cors, { origin: true, credentials: true });
|
||||
} else if (corsOrigin) {
|
||||
// Custom origin(s) from env
|
||||
const origins = corsOrigin.split(',').map((o) => o.trim());
|
||||
await this.app.register(cors, { origin: origins, credentials: true });
|
||||
} else {
|
||||
// Default: allow all localhost origins
|
||||
// eslint-disable-next-line security/detect-unsafe-regex -- anchored, no backtracking risk
|
||||
const localhostPattern = /^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/;
|
||||
await this.app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
if (!origin) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
if (localhostPattern.test(origin)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
cb(new Error('Not allowed by CORS'), false);
|
||||
},
|
||||
credentials: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Register static file serving and SPA fallback when renderer output exists.
|
||||
// In dev mode this requires a prior `pnpm build`; in production/standalone it's always present.
|
||||
const rendererPath = resolveRendererPath();
|
||||
if (rendererPath) {
|
||||
logger.info(`Serving static files from: ${rendererPath}`);
|
||||
|
||||
// Cache index.html for SPA fallback
|
||||
const indexHtml = readFileSync(join(rendererPath, 'index.html'), 'utf-8');
|
||||
|
||||
// Register static file serving (production only)
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (!isDev) {
|
||||
const rendererPathCandidates = [
|
||||
join(__dirname, '../../../out/renderer'),
|
||||
join(__dirname, '../../renderer'),
|
||||
];
|
||||
const rendererPath =
|
||||
rendererPathCandidates.find((candidate) => existsSync(candidate)) ??
|
||||
rendererPathCandidates[0];
|
||||
await this.app.register(fastifyStatic, {
|
||||
root: rendererPath,
|
||||
prefix: '/',
|
||||
// Don't serve index.html for API routes
|
||||
wildcard: false,
|
||||
});
|
||||
|
||||
// Serve index.html for all non-API routes (SPA fallback)
|
||||
// Register all API routes BEFORE the not-found handler
|
||||
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
|
||||
|
||||
// SPA fallback: serve index.html for all non-API routes
|
||||
this.app.setNotFoundHandler(async (request, reply) => {
|
||||
if (request.url.startsWith('/api/')) {
|
||||
return reply.status(404).send({ error: 'Not found' });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
return reply.type('text/html').send(indexHtml);
|
||||
});
|
||||
} else {
|
||||
logger.warn('Renderer output directory not found (run `pnpm build` first), serving API only');
|
||||
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
|
||||
}
|
||||
|
||||
// Register all API routes
|
||||
registerHttpRoutes(this.app, services, sshModeSwitchCallback);
|
||||
|
||||
// Try ports starting from preferredPort
|
||||
for (let attempt = 0; attempt <= 10; attempt++) {
|
||||
const tryPort = preferredPort + attempt;
|
||||
try {
|
||||
await this.app.listen({ host: '127.0.0.1', port: tryPort });
|
||||
await this.app.listen({ host, port: tryPort });
|
||||
this.port = tryPort;
|
||||
this.running = true;
|
||||
logger.info(`HTTP server started on http://127.0.0.1:${tryPort}`);
|
||||
logger.info(`HTTP server started on http://${host}:${tryPort}`);
|
||||
return tryPort;
|
||||
} catch (err: unknown) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
|
|
|
|||
194
src/main/standalone.ts
Normal file
194
src/main/standalone.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* Standalone (non-Electron) entry point for claude-devtools.
|
||||
*
|
||||
* Runs the HTTP server + API without Electron, suitable for Docker
|
||||
* or any headless/remote environment. The renderer is served as
|
||||
* static files over HTTP.
|
||||
*
|
||||
* Environment variables:
|
||||
* - HOST: Bind address (default '0.0.0.0')
|
||||
* - PORT: Listen port (default 3456)
|
||||
* - CLAUDE_ROOT: Path to .claude directory (default ~/.claude)
|
||||
* - CORS_ORIGIN: CORS origin policy (default '*')
|
||||
*/
|
||||
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import {
|
||||
getProjectsBasePath,
|
||||
getTodosBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from './utils/pathDecoder';
|
||||
import { LocalFileSystemProvider, NotificationManager, ServiceContext } from './services';
|
||||
|
||||
import type { HttpServices } from './http';
|
||||
import type { SshConnectionManager } from './services/infrastructure/SshConnectionManager';
|
||||
import type { UpdaterService } from './services/infrastructure/UpdaterService';
|
||||
|
||||
const logger = createLogger('Standalone');
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
const PORT = parseInt(process.env.PORT ?? '3456', 10);
|
||||
const CLAUDE_ROOT = process.env.CLAUDE_ROOT;
|
||||
|
||||
// Default CORS to allow all in standalone mode (Docker isolation replaces CORS)
|
||||
if (!process.env.CORS_ORIGIN) {
|
||||
process.env.CORS_ORIGIN = '*';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stub services (Electron-only features unavailable in standalone)
|
||||
// =============================================================================
|
||||
|
||||
/** No-op UpdaterService stub — auto-updater requires Electron. */
|
||||
const updaterServiceStub = {
|
||||
checkForUpdates: async () => {},
|
||||
downloadUpdate: async () => {},
|
||||
quitAndInstall: () => {},
|
||||
setMainWindow: () => {},
|
||||
} as unknown as UpdaterService;
|
||||
|
||||
/** No-op SshConnectionManager stub — SSH is managed per-user in the Electron app. */
|
||||
const sshConnectionManagerStub = {
|
||||
getStatus: () => ({
|
||||
state: 'disconnected' as const,
|
||||
host: null,
|
||||
error: null,
|
||||
remoteProjectsPath: null,
|
||||
}),
|
||||
getProvider: () => new LocalFileSystemProvider(),
|
||||
isRemote: () => false,
|
||||
connect: async () => {},
|
||||
disconnect: () => {},
|
||||
testConnection: async () => ({ success: false, error: 'SSH not available in standalone mode' }),
|
||||
getConfigHosts: async () => [],
|
||||
resolveHostConfig: async () => null,
|
||||
dispose: () => {},
|
||||
on: () => sshConnectionManagerStub,
|
||||
off: () => sshConnectionManagerStub,
|
||||
emit: () => false,
|
||||
} as unknown as SshConnectionManager;
|
||||
|
||||
// =============================================================================
|
||||
// Application State
|
||||
// =============================================================================
|
||||
|
||||
let localContext: ServiceContext;
|
||||
let notificationManager: NotificationManager;
|
||||
let httpServer: HttpServer;
|
||||
|
||||
// =============================================================================
|
||||
// Lifecycle
|
||||
// =============================================================================
|
||||
|
||||
async function start(): Promise<void> {
|
||||
logger.info('Starting standalone server...');
|
||||
|
||||
// Apply Claude root override if set
|
||||
if (CLAUDE_ROOT) {
|
||||
setClaudeBasePathOverride(CLAUDE_ROOT);
|
||||
logger.info(`Using CLAUDE_ROOT: ${CLAUDE_ROOT}`);
|
||||
}
|
||||
|
||||
const projectsDir = getProjectsBasePath();
|
||||
const todosDir = getTodosBasePath();
|
||||
|
||||
logger.info(`Projects directory: ${projectsDir}`);
|
||||
logger.info(`Todos directory: ${todosDir}`);
|
||||
|
||||
// Create local context (the only context in standalone mode)
|
||||
localContext = new ServiceContext({
|
||||
id: 'local',
|
||||
type: 'local',
|
||||
fsProvider: new LocalFileSystemProvider(),
|
||||
projectsDir,
|
||||
todosDir,
|
||||
});
|
||||
localContext.start();
|
||||
|
||||
// Initialize notification manager
|
||||
notificationManager = NotificationManager.getInstance();
|
||||
localContext.fileWatcher.setNotificationManager(notificationManager);
|
||||
|
||||
// Create HTTP server
|
||||
httpServer = new HttpServer();
|
||||
|
||||
// Wire file watcher events to SSE broadcast
|
||||
localContext.fileWatcher.on('file-change', (event: unknown) => {
|
||||
httpServer.broadcast('file-change', event);
|
||||
});
|
||||
localContext.fileWatcher.on('todo-change', (event: unknown) => {
|
||||
httpServer.broadcast('todo-change', event);
|
||||
});
|
||||
|
||||
// Forward notification events to SSE
|
||||
notificationManager.on('notification-new', (notification: unknown) => {
|
||||
httpServer.broadcast('notification:new', notification);
|
||||
});
|
||||
notificationManager.on('notification-updated', (data: unknown) => {
|
||||
httpServer.broadcast('notification:updated', data);
|
||||
});
|
||||
notificationManager.on('notification-clicked', (data: unknown) => {
|
||||
httpServer.broadcast('notification:clicked', data);
|
||||
});
|
||||
|
||||
// Build services for HTTP routes
|
||||
const services: HttpServices = {
|
||||
projectScanner: localContext.projectScanner,
|
||||
sessionParser: localContext.sessionParser,
|
||||
subagentResolver: localContext.subagentResolver,
|
||||
chunkBuilder: localContext.chunkBuilder,
|
||||
dataCache: localContext.dataCache,
|
||||
updaterService: updaterServiceStub,
|
||||
sshConnectionManager: sshConnectionManagerStub,
|
||||
};
|
||||
|
||||
// No-op mode switch handler (no SSH in standalone)
|
||||
const modeSwitchHandler = async (): Promise<void> => {};
|
||||
|
||||
// Start the server
|
||||
const port = await httpServer.start(services, modeSwitchHandler, PORT, HOST);
|
||||
logger.info(`Standalone server running at http://${HOST}:${port}`);
|
||||
logger.info('Open in your browser to view Claude Code sessions');
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
logger.info('Shutting down...');
|
||||
|
||||
if (httpServer?.isRunning()) {
|
||||
await httpServer.stop();
|
||||
}
|
||||
|
||||
if (localContext) {
|
||||
localContext.dispose();
|
||||
}
|
||||
|
||||
logger.info('Shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Signal Handlers
|
||||
// =============================================================================
|
||||
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('Unhandled promise rejection:', reason);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('Uncaught exception:', error);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Start
|
||||
// =============================================================================
|
||||
|
||||
void start();
|
||||
|
|
@ -64,6 +64,20 @@ export interface Project {
|
|||
*/
|
||||
export type SessionMetadataLevel = 'light' | 'deep';
|
||||
|
||||
/**
|
||||
* Per-phase token breakdown for compaction-aware context consumption.
|
||||
*/
|
||||
export interface PhaseTokenBreakdown {
|
||||
/** 1-based phase number */
|
||||
phaseNumber: number;
|
||||
/** Tokens added during this phase */
|
||||
contribution: number;
|
||||
/** Context window at peak (pre-compaction or final) */
|
||||
peakTokens: number;
|
||||
/** Tokens after compaction (undefined for the last/current phase) */
|
||||
postCompaction?: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
/** Session UUID (JSONL filename without extension) */
|
||||
id: string;
|
||||
|
|
@ -89,6 +103,12 @@ export interface Session {
|
|||
gitBranch?: string;
|
||||
/** Metadata completeness level */
|
||||
metadataLevel?: SessionMetadataLevel;
|
||||
/** Total context consumed (compaction-aware sum of all phases) */
|
||||
contextConsumption?: number;
|
||||
/** Number of compaction events */
|
||||
compactionCount?: number;
|
||||
/** Per-phase token breakdown for tooltip display */
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { extractToolCalls, extractToolResults } from './toolExtraction';
|
||||
|
||||
import type { FileSystemProvider } from '../services/infrastructure/FileSystemProvider';
|
||||
import type { PhaseTokenBreakdown } from '../types/domain';
|
||||
|
||||
const logger = createLogger('Util:jsonl');
|
||||
|
||||
|
|
@ -300,6 +301,12 @@ export interface SessionFileMetadata {
|
|||
messageCount: number;
|
||||
isOngoing: boolean;
|
||||
gitBranch: string | null;
|
||||
/** Total context consumed (compaction-aware) */
|
||||
contextConsumption?: number;
|
||||
/** Number of compaction events */
|
||||
compactionCount?: number;
|
||||
/** Per-phase token breakdown */
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -328,6 +335,8 @@ export async function analyzeSessionFileMetadata(
|
|||
let firstUserMessage: { text: string; timestamp: string } | null = null;
|
||||
let firstCommandMessage: { text: string; timestamp: string } | null = null;
|
||||
let messageCount = 0;
|
||||
// After a UserGroup, await the first main-thread assistant message to count the AIGroup
|
||||
let awaitingAIGroup = false;
|
||||
let gitBranch: string | null = null;
|
||||
|
||||
let activityIndex = 0;
|
||||
|
|
@ -337,6 +346,13 @@ export async function analyzeSessionFileMetadata(
|
|||
// Track tool_use IDs that are shutdown responses so their tool_results are also ending events
|
||||
const shutdownToolIds = new Set<string>();
|
||||
|
||||
// Context consumption tracking
|
||||
|
||||
let lastMainAssistantInputTokens = 0;
|
||||
const compactionPhases: { pre: number; post: number }[] = [];
|
||||
|
||||
let awaitingPostCompaction = false;
|
||||
|
||||
for await (const line of rl) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
|
|
@ -357,6 +373,15 @@ export async function analyzeSessionFileMetadata(
|
|||
|
||||
if (isParsedUserChunkMessage(parsed)) {
|
||||
messageCount++;
|
||||
awaitingAIGroup = true;
|
||||
} else if (
|
||||
awaitingAIGroup &&
|
||||
parsed.type === 'assistant' &&
|
||||
parsed.model !== '<synthetic>' &&
|
||||
!parsed.isSidechain
|
||||
) {
|
||||
messageCount++;
|
||||
awaitingAIGroup = false;
|
||||
}
|
||||
|
||||
if (!gitBranch && 'gitBranch' in entry && entry.gitBranch) {
|
||||
|
|
@ -472,6 +497,86 @@ export async function analyzeSessionFileMetadata(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context consumption: track main-thread assistant input tokens
|
||||
if (parsed.type === 'assistant' && !parsed.isSidechain && parsed.model !== '<synthetic>') {
|
||||
const inputTokens =
|
||||
(parsed.usage?.input_tokens ?? 0) +
|
||||
(parsed.usage?.cache_read_input_tokens ?? 0) +
|
||||
(parsed.usage?.cache_creation_input_tokens ?? 0);
|
||||
if (inputTokens > 0) {
|
||||
if (awaitingPostCompaction && compactionPhases.length > 0) {
|
||||
compactionPhases[compactionPhases.length - 1].post = inputTokens;
|
||||
awaitingPostCompaction = false;
|
||||
}
|
||||
lastMainAssistantInputTokens = inputTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Context consumption: detect compaction events
|
||||
if (parsed.isCompactSummary) {
|
||||
compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
|
||||
awaitingPostCompaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute context consumption from tracked phases
|
||||
let contextConsumption: number | undefined;
|
||||
let phaseBreakdown: PhaseTokenBreakdown[] | undefined;
|
||||
|
||||
if (lastMainAssistantInputTokens > 0) {
|
||||
if (compactionPhases.length === 0) {
|
||||
// No compaction: just the final input tokens
|
||||
contextConsumption = lastMainAssistantInputTokens;
|
||||
phaseBreakdown = [
|
||||
{
|
||||
phaseNumber: 1,
|
||||
contribution: lastMainAssistantInputTokens,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
phaseBreakdown = [];
|
||||
let total = 0;
|
||||
|
||||
// Phase 1: tokens up to first compaction
|
||||
const phase1Contribution = compactionPhases[0].pre;
|
||||
total += phase1Contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: 1,
|
||||
contribution: phase1Contribution,
|
||||
peakTokens: compactionPhases[0].pre,
|
||||
postCompaction: compactionPhases[0].post,
|
||||
});
|
||||
|
||||
// Middle phases: contribution = pre[i] - post[i-1]
|
||||
for (let i = 1; i < compactionPhases.length; i++) {
|
||||
const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post;
|
||||
total += contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: i + 1,
|
||||
contribution,
|
||||
peakTokens: compactionPhases[i].pre,
|
||||
postCompaction: compactionPhases[i].post,
|
||||
});
|
||||
}
|
||||
|
||||
// Last phase: final tokens - last post-compaction
|
||||
// Guard: if the last compaction had no subsequent assistant message, post is 0.
|
||||
// In that case, skip the final phase to avoid double-counting.
|
||||
const lastPhase = compactionPhases[compactionPhases.length - 1];
|
||||
if (lastPhase.post > 0) {
|
||||
const lastContribution = lastMainAssistantInputTokens - lastPhase.post;
|
||||
total += lastContribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: compactionPhases.length + 1,
|
||||
contribution: lastContribution,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
});
|
||||
}
|
||||
|
||||
contextConsumption = total;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -479,5 +584,8 @@ export async function analyzeSessionFileMetadata(
|
|||
messageCount,
|
||||
isOngoing: lastEndingIndex === -1 ? hasAnyOngoingActivity : hasActivityAfterLastEnding,
|
||||
gitBranch,
|
||||
contextConsumption,
|
||||
compactionCount: compactionPhases.length > 0 ? compactionPhases.length : undefined,
|
||||
phaseBreakdown,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,18 @@ export const CONFIG_PIN_SESSION = 'config:pinSession';
|
|||
/** Unpin a session */
|
||||
export const CONFIG_UNPIN_SESSION = 'config:unpinSession';
|
||||
|
||||
/** Hide a session */
|
||||
export const CONFIG_HIDE_SESSION = 'config:hideSession';
|
||||
|
||||
/** Unhide a session */
|
||||
export const CONFIG_UNHIDE_SESSION = 'config:unhideSession';
|
||||
|
||||
/** Bulk hide sessions */
|
||||
export const CONFIG_HIDE_SESSIONS = 'config:hideSessions';
|
||||
|
||||
/** Bulk unhide sessions */
|
||||
export const CONFIG_UNHIDE_SESSIONS = 'config:unhideSessions';
|
||||
|
||||
// =============================================================================
|
||||
// SSH API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import {
|
|||
CONFIG_GET,
|
||||
CONFIG_GET_CLAUDE_ROOT_INFO,
|
||||
CONFIG_GET_TRIGGERS,
|
||||
CONFIG_HIDE_SESSION,
|
||||
CONFIG_HIDE_SESSIONS,
|
||||
CONFIG_OPEN_IN_EDITOR,
|
||||
CONFIG_PIN_SESSION,
|
||||
CONFIG_REMOVE_IGNORE_REGEX,
|
||||
|
|
@ -45,6 +47,8 @@ import {
|
|||
CONFIG_SELECT_FOLDERS,
|
||||
CONFIG_SNOOZE,
|
||||
CONFIG_TEST_TRIGGER,
|
||||
CONFIG_UNHIDE_SESSION,
|
||||
CONFIG_UNHIDE_SESSIONS,
|
||||
CONFIG_UNPIN_SESSION,
|
||||
CONFIG_UPDATE,
|
||||
CONFIG_UPDATE_TRIGGER,
|
||||
|
|
@ -292,6 +296,18 @@ const electronAPI: ElectronAPI = {
|
|||
unpinSession: async (projectId: string, sessionId: string): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_UNPIN_SESSION, projectId, sessionId);
|
||||
},
|
||||
hideSession: async (projectId: string, sessionId: string): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSION, projectId, sessionId);
|
||||
},
|
||||
unhideSession: async (projectId: string, sessionId: string): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSION, projectId, sessionId);
|
||||
},
|
||||
hideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_HIDE_SESSIONS, projectId, sessionIds);
|
||||
},
|
||||
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
||||
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
|
||||
},
|
||||
},
|
||||
|
||||
// Deep link navigation
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- event callbacks have varying signatures
|
||||
private eventListeners = new Map<string, Set<(...args: any[]) => void>>();
|
||||
|
||||
constructor(port: number) {
|
||||
this.baseUrl = `http://127.0.0.1:${port}`;
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.initEventSource();
|
||||
}
|
||||
|
||||
|
|
@ -436,6 +436,14 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
this.post('/api/config/pin-session', { projectId, sessionId }),
|
||||
unpinSession: (projectId: string, sessionId: string): Promise<void> =>
|
||||
this.post('/api/config/unpin-session', { projectId, sessionId }),
|
||||
hideSession: (projectId: string, sessionId: string): Promise<void> =>
|
||||
this.post('/api/config/hide-session', { projectId, sessionId }),
|
||||
unhideSession: (projectId: string, sessionId: string): Promise<void> =>
|
||||
this.post('/api/config/unhide-session', { projectId, sessionId }),
|
||||
hideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
||||
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
|
||||
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
||||
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -16,9 +16,20 @@ import { HttpAPIClient } from './httpClient';
|
|||
|
||||
import type { ElectronAPI } from '@shared/types/api';
|
||||
|
||||
function getHttpPort(): number {
|
||||
/**
|
||||
* Resolves the base URL for the HTTP API client.
|
||||
*
|
||||
* - Electron "server mode" (browser opened via ?port=XXXX): use explicit port on 127.0.0.1
|
||||
* - Standalone/Docker (page served by the same server): use window.location.origin
|
||||
* to avoid cross-origin issues (localhost vs 127.0.0.1)
|
||||
*/
|
||||
function getHttpBaseUrl(): string {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return parseInt(params.get('port') ?? '3456', 10);
|
||||
const explicitPort = params.get('port');
|
||||
if (explicitPort) {
|
||||
return `http://127.0.0.1:${parseInt(explicitPort, 10)}`;
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
let httpClient: HttpAPIClient | null = null;
|
||||
|
|
@ -28,7 +39,7 @@ function getImpl(): ElectronAPI {
|
|||
// Lazily create the HTTP client only when actually needed (browser mode).
|
||||
// Caching avoids creating multiple EventSource connections.
|
||||
if (!httpClient) {
|
||||
httpClient = new HttpAPIClient(getHttpPort());
|
||||
httpClient = new HttpAPIClient(getHttpBaseUrl());
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,7 +252,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
selectSearchMatch,
|
||||
});
|
||||
|
||||
const effectiveHighlightToolUseId = controllerToolUseId ?? undefined;
|
||||
// Local tool highlight for context panel navigation (separate from controller)
|
||||
const [contextNavToolUseId, setContextNavToolUseId] = useState<string | null>(null);
|
||||
const effectiveHighlightToolUseId = controllerToolUseId ?? contextNavToolUseId ?? undefined;
|
||||
// Use blue for context panel tool navigation, otherwise use controller's color
|
||||
const effectiveHighlightColor = contextNavToolUseId ? ('blue' as const) : highlightColor;
|
||||
|
||||
// Keep search match indices aligned with this tab's rendered conversation.
|
||||
// This avoids stale/global match lists after tab switches or in-place refreshes.
|
||||
|
|
@ -396,6 +400,91 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
[conversation, ensureGroupVisible, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Handler to navigate to a user message group (preceding the AI group at turnIndex)
|
||||
const handleNavigateToUserGroup = useCallback(
|
||||
(turnIndex: number) => {
|
||||
if (!conversation) return;
|
||||
const aiItemIndex = conversation.items.findIndex(
|
||||
(item) => item.type === 'ai' && item.group.turnIndex === turnIndex
|
||||
);
|
||||
if (aiItemIndex < 0) return;
|
||||
|
||||
// Find the user item preceding this AI group
|
||||
const prevItem = aiItemIndex > 0 ? conversation.items[aiItemIndex - 1] : null;
|
||||
if (prevItem?.type !== 'user') return;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const groupId = prevItem.group.id;
|
||||
await ensureGroupVisible(groupId);
|
||||
const element = chatItemRefs.current.get(groupId);
|
||||
if (!element) return;
|
||||
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setHighlightedGroupId(groupId);
|
||||
setIsNavigationHighlight(true);
|
||||
if (navigationHighlightTimerRef.current) {
|
||||
clearTimeout(navigationHighlightTimerRef.current);
|
||||
}
|
||||
navigationHighlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedGroupId(null);
|
||||
setIsNavigationHighlight(false);
|
||||
navigationHighlightTimerRef.current = null;
|
||||
}, 2000);
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[conversation, ensureGroupVisible, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Handler to navigate to a specific tool within a turn from context panel
|
||||
const handleNavigateToTool = useCallback(
|
||||
(turnIndex: number, toolUseId: string) => {
|
||||
if (!conversation) return;
|
||||
const targetItem = conversation.items.find(
|
||||
(item) => item.type === 'ai' && item.group.turnIndex === turnIndex
|
||||
);
|
||||
if (targetItem?.type !== 'ai') return;
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const groupId = targetItem.group.id;
|
||||
await ensureGroupVisible(groupId);
|
||||
|
||||
// Set group + tool highlight immediately
|
||||
setHighlightedGroupId(groupId);
|
||||
setIsNavigationHighlight(true);
|
||||
setContextNavToolUseId(toolUseId);
|
||||
|
||||
// Wait for tool element to appear in DOM (up to 500ms)
|
||||
let toolElement: HTMLElement | undefined;
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < 500) {
|
||||
toolElement = toolItemRefs.current.get(toolUseId);
|
||||
if (toolElement) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
// Scroll to tool element, or fall back to AI group
|
||||
const scrollTarget = toolElement ?? aiGroupRefs.current.get(groupId);
|
||||
if (scrollTarget) {
|
||||
scrollTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
// Clear highlight after 2s
|
||||
if (navigationHighlightTimerRef.current) {
|
||||
clearTimeout(navigationHighlightTimerRef.current);
|
||||
}
|
||||
navigationHighlightTimerRef.current = setTimeout(() => {
|
||||
setHighlightedGroupId(null);
|
||||
setIsNavigationHighlight(false);
|
||||
setContextNavToolUseId(null);
|
||||
navigationHighlightTimerRef.current = null;
|
||||
}, 2000);
|
||||
};
|
||||
void run();
|
||||
},
|
||||
[conversation, ensureGroupVisible, setHighlightedGroupId]
|
||||
);
|
||||
|
||||
// Scroll to current search result when it changes
|
||||
useEffect(() => {
|
||||
const currentMatch = currentSearchIndex >= 0 ? searchMatches[currentSearchIndex] : null;
|
||||
|
|
@ -695,7 +784,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
highlightToolUseId={effectiveHighlightToolUseId}
|
||||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={highlightColor}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
@ -713,7 +802,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
highlightToolUseId={effectiveHighlightToolUseId}
|
||||
isSearchHighlight={isSearchHighlight}
|
||||
isNavigationHighlight={isNavigationHighlight}
|
||||
highlightColor={highlightColor}
|
||||
highlightColor={effectiveHighlightColor}
|
||||
registerChatItemRef={registerChatItemRef}
|
||||
registerAIGroupRef={registerAIGroupRefCombined}
|
||||
registerToolRef={registerToolRef}
|
||||
|
|
@ -732,6 +821,8 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
onClose={() => setContextPanelVisible(false)}
|
||||
projectRoot={sessionDetail?.session?.projectPath}
|
||||
onNavigateToTurn={handleNavigateToTurn}
|
||||
onNavigateToTool={handleNavigateToTool}
|
||||
onNavigateToUserGroup={handleNavigateToUserGroup}
|
||||
totalSessionTokens={lastAiGroupTotalTokens}
|
||||
phaseInfo={sessionPhaseInfo ?? undefined}
|
||||
selectedPhase={selectedContextPhase}
|
||||
|
|
|
|||
|
|
@ -97,11 +97,10 @@ const ChatHistoryItemInner = ({
|
|||
}
|
||||
case 'ai': {
|
||||
const isHighlighted = highlightedGroupId === item.group.id;
|
||||
// Pass highlightToolUseId to ALL AI groups (when not search/navigation)
|
||||
// Pass highlightToolUseId to ALL AI groups (when not search highlight)
|
||||
// Each group will check if it contains the tool and expand accordingly
|
||||
// This fixes issues where timestamp matching might fail to find the correct group
|
||||
const toolUseIdForGroup =
|
||||
!isSearchHighlight && !isNavigationHighlight ? highlightToolUseId : undefined;
|
||||
// Allowed during navigation highlights so context panel tool deep-linking works
|
||||
const toolUseIdForGroup = !isSearchHighlight ? highlightToolUseId : undefined;
|
||||
const hl = getHighlight(
|
||||
isHighlighted,
|
||||
isSearchHighlight,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
|
||||
import {
|
||||
CODE_BG,
|
||||
CODE_BORDER,
|
||||
COLOR_TEXT_MUTED,
|
||||
TOOL_CALL_BG,
|
||||
TOOL_CALL_BORDER,
|
||||
TOOL_CALL_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronRight, Layers, MailOpen } from 'lucide-react';
|
||||
|
||||
import { BaseItem } from './items/BaseItem';
|
||||
import { LinkedToolItem } from './items/LinkedToolItem';
|
||||
import { SlashItem } from './items/SlashItem';
|
||||
import { SubagentItem } from './items/SubagentItem';
|
||||
import { TeammateMessageItem } from './items/TeammateMessageItem';
|
||||
import { TextItem } from './items/TextItem';
|
||||
import { ThinkingItem } from './items/ThinkingItem';
|
||||
import { MarkdownViewer } from './viewers/MarkdownViewer';
|
||||
|
||||
import type { AIGroupDisplayItem } from '@renderer/types/groups';
|
||||
import type { TriggerColor } from '@shared/constants/triggerColors';
|
||||
|
|
@ -208,6 +222,103 @@ export const DisplayItemList = ({
|
|||
break;
|
||||
}
|
||||
|
||||
case 'subagent_input': {
|
||||
itemKey = `input-${index}`;
|
||||
const inputContent = item.content;
|
||||
const inputTokenCount = item.tokenCount;
|
||||
element = (
|
||||
<BaseItem
|
||||
icon={<MailOpen className="size-4" />}
|
||||
label="Input"
|
||||
summary={truncateText(inputContent, 80)}
|
||||
tokenCount={inputTokenCount}
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
isExpanded={expandedItemIds.has(itemKey)}
|
||||
>
|
||||
<MarkdownViewer content={inputContent} copyable />
|
||||
</BaseItem>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'compact_boundary': {
|
||||
itemKey = `compact-${index}`;
|
||||
const compactContent = item.content;
|
||||
const compactExpanded = expandedItemIds.has(itemKey);
|
||||
element = (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onItemClick(itemKey)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: TOOL_CALL_BG,
|
||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||
}}
|
||||
aria-expanded={compactExpanded}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${compactExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||
Compacted
|
||||
</span>
|
||||
{item.tokenDelta && (
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||
color: '#818cf8',
|
||||
}}
|
||||
>
|
||||
Phase {item.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[11px]"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||
</span>
|
||||
</button>
|
||||
{compactExpanded && compactContent && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-lg"
|
||||
style={{
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||
>
|
||||
<MarkdownViewer content={compactContent} copyable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* RankedInjectionList - All context injections sorted by token size descending.
|
||||
* Injections are shown as grouped rows (e.g., "Tool output in Turn N").
|
||||
* Tool-output rows are expandable to reveal individual tool breakdowns sorted desc.
|
||||
* Individual tools support deep-link navigation to the exact tool in chat.
|
||||
* CLAUDE.md and File items show a copy-path button.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { COLOR_TEXT_MUTED, COLOR_TEXT_SECONDARY } from '@renderer/constants/cssVariables';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
import { formatTokens } from '../utils/formatting';
|
||||
import { parseTurnIndex } from '../utils/pathParsing';
|
||||
|
||||
import type { ContextInjection, ToolOutputInjection } from '@renderer/types/contextInjection';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORY_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
'claude-md': { bg: 'rgba(99, 102, 241, 0.15)', text: '#818cf8', label: 'CLAUDE.md' },
|
||||
'mentioned-file': { bg: 'rgba(52, 211, 153, 0.15)', text: '#34d399', label: 'File' },
|
||||
'tool-output': { bg: 'rgba(251, 191, 36, 0.15)', text: '#fbbf24', label: 'Tool' },
|
||||
'thinking-text': { bg: 'rgba(167, 139, 250, 0.15)', text: '#a78bfa', label: 'Thinking' },
|
||||
'task-coordination': { bg: 'rgba(251, 146, 60, 0.15)', text: '#fb923c', label: 'Team' },
|
||||
'user-message': { bg: 'rgba(96, 165, 250, 0.15)', text: '#60a5fa', label: 'User' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
|
||||
interface RankedInjectionListProps {
|
||||
injections: ContextInjection[];
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
function getInjectionDescription(injection: ContextInjection): string {
|
||||
switch (injection.category) {
|
||||
case 'claude-md':
|
||||
return injection.displayName || injection.path;
|
||||
case 'mentioned-file':
|
||||
return injection.displayName;
|
||||
case 'tool-output':
|
||||
return `${injection.toolCount} tool${injection.toolCount !== 1 ? 's' : ''} in Turn ${injection.turnIndex + 1}`;
|
||||
case 'thinking-text':
|
||||
return `Turn ${injection.turnIndex + 1} thinking/text`;
|
||||
case 'task-coordination':
|
||||
return `Turn ${injection.turnIndex + 1} coordination`;
|
||||
case 'user-message':
|
||||
return injection.textPreview;
|
||||
}
|
||||
}
|
||||
|
||||
function getInjectionTurnIndex(injection: ContextInjection): number {
|
||||
switch (injection.category) {
|
||||
case 'claude-md':
|
||||
return parseTurnIndex(injection.firstSeenInGroup);
|
||||
case 'mentioned-file':
|
||||
return injection.firstSeenTurnIndex;
|
||||
case 'tool-output':
|
||||
case 'thinking-text':
|
||||
case 'task-coordination':
|
||||
case 'user-message':
|
||||
return injection.turnIndex;
|
||||
}
|
||||
}
|
||||
|
||||
/** Get copyable path for path-based injections. */
|
||||
function getCopyablePath(injection: ContextInjection): string | null {
|
||||
if (injection.category === 'claude-md') return injection.path;
|
||||
if (injection.category === 'mentioned-file') return injection.path;
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
||||
/** Expandable tool-output row with breakdown sorted by token count desc. */
|
||||
const ToolOutputRankedItem = ({
|
||||
injection,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
}: Readonly<{
|
||||
injection: ToolOutputInjection;
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
}>): React.ReactElement => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasBreakdown = injection.toolBreakdown.length > 0;
|
||||
const categoryInfo = CATEGORY_COLORS['tool-output'];
|
||||
|
||||
const sortedBreakdown = useMemo(
|
||||
() => [...injection.toolBreakdown].sort((a, b) => b.tokenCount - a.tokenCount),
|
||||
[injection.toolBreakdown]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasBreakdown) {
|
||||
setExpanded(!expanded);
|
||||
} else if (onNavigateToTurn) {
|
||||
const turnIndex = getInjectionTurnIndex(injection);
|
||||
if (turnIndex >= 0) onNavigateToTurn(turnIndex);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||
>
|
||||
{/* Expand chevron */}
|
||||
{hasBreakdown && (
|
||||
<ChevronRight
|
||||
className={`size-3 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
/>
|
||||
)}
|
||||
{/* Category pill */}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||
>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{/* Description */}
|
||||
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{getInjectionDescription(injection)}
|
||||
</span>
|
||||
{/* Token count */}
|
||||
<span
|
||||
className="shrink-0 text-xs font-medium tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokens(injection.estimatedTokens)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded tool breakdown */}
|
||||
{expanded && hasBreakdown && (
|
||||
<div className="ml-7 space-y-0.5 pb-1">
|
||||
{sortedBreakdown.map((tool, idx) => (
|
||||
<button
|
||||
key={`${tool.toolName}-${idx}`}
|
||||
onClick={() => {
|
||||
if (tool.toolUseId && onNavigateToTool) {
|
||||
onNavigateToTool(injection.turnIndex, tool.toolUseId);
|
||||
} else if (onNavigateToTurn) {
|
||||
onNavigateToTurn(injection.turnIndex);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-0.5 text-left text-xs transition-colors hover:bg-white/5"
|
||||
>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||
>
|
||||
{tool.toolName}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<span
|
||||
className="shrink-0 tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED, opacity: 0.8 }}
|
||||
>
|
||||
{formatTokens(tool.tokenCount)}
|
||||
</span>
|
||||
{tool.isError && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 py-0.5"
|
||||
style={{
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||
color: '#ef4444',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const RankedInjectionList = ({
|
||||
injections,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
}: Readonly<RankedInjectionListProps>): React.ReactElement => {
|
||||
const sortedInjections = useMemo(
|
||||
() => [...injections].sort((a, b) => b.estimatedTokens - a.estimatedTokens),
|
||||
[injections]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{sortedInjections.map((inj) => {
|
||||
// Tool-output: expandable row
|
||||
if (inj.category === 'tool-output') {
|
||||
return (
|
||||
<ToolOutputRankedItem
|
||||
key={inj.id}
|
||||
injection={inj}
|
||||
onNavigateToTurn={onNavigateToTurn}
|
||||
onNavigateToTool={onNavigateToTool}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryInfo = CATEGORY_COLORS[inj.category] ?? {
|
||||
bg: 'rgba(161, 161, 170, 0.15)',
|
||||
text: '#a1a1aa',
|
||||
label: inj.category,
|
||||
};
|
||||
const copyPath = getCopyablePath(inj);
|
||||
|
||||
const handleClick = (): void => {
|
||||
const turnIndex = getInjectionTurnIndex(inj);
|
||||
if (turnIndex < 0) return;
|
||||
// User messages → navigate to user group; others → navigate to AI group
|
||||
if (inj.category === 'user-message' && onNavigateToUserGroup) {
|
||||
onNavigateToUserGroup(turnIndex);
|
||||
} else if (onNavigateToTurn) {
|
||||
onNavigateToTurn(turnIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={inj.id} className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-left transition-colors hover:bg-white/5"
|
||||
>
|
||||
{/* Category pill */}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium"
|
||||
style={{ backgroundColor: categoryInfo.bg, color: categoryInfo.text }}
|
||||
>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{/* Description */}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-xs"
|
||||
style={{ color: COLOR_TEXT_SECONDARY }}
|
||||
>
|
||||
{getInjectionDescription(inj)}
|
||||
</span>
|
||||
{/* Token count */}
|
||||
<span
|
||||
className="shrink-0 text-xs font-medium tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokens(inj.estimatedTokens)}
|
||||
</span>
|
||||
</button>
|
||||
{/* Copy path button for CLAUDE.md and File items */}
|
||||
{copyPath && (
|
||||
<span className="shrink-0">
|
||||
<CopyButton text={copyPath} inline />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,12 +12,13 @@ import {
|
|||
COLOR_TEXT_MUTED,
|
||||
COLOR_TEXT_SECONDARY,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { FileText, X } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, FileText, LayoutList, X } from 'lucide-react';
|
||||
|
||||
import { formatTokens } from '../utils/formatting';
|
||||
|
||||
import { SessionContextHelpTooltip } from './SessionContextHelpTooltip';
|
||||
|
||||
import type { ContextViewMode } from '../types';
|
||||
import type { ContextPhaseInfo } from '@renderer/types/contextInjection';
|
||||
|
||||
interface SessionContextHeaderProps {
|
||||
|
|
@ -28,6 +29,8 @@ interface SessionContextHeaderProps {
|
|||
phaseInfo?: ContextPhaseInfo;
|
||||
selectedPhase: number | null;
|
||||
onPhaseChange: (phase: number | null) => void;
|
||||
viewMode: ContextViewMode;
|
||||
onViewModeChange: (mode: ContextViewMode) => void;
|
||||
}
|
||||
|
||||
export const SessionContextHeader = ({
|
||||
|
|
@ -38,6 +41,8 @@ export const SessionContextHeader = ({
|
|||
phaseInfo,
|
||||
selectedPhase,
|
||||
onPhaseChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: Readonly<SessionContextHeaderProps>): React.ReactElement => {
|
||||
return (
|
||||
<div className="shrink-0 px-4 py-3" style={{ borderBottom: `1px solid ${COLOR_BORDER}` }}>
|
||||
|
|
@ -150,6 +155,40 @@ export const SessionContextHeader = ({
|
|||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div
|
||||
className="mt-2 flex items-center gap-1 pt-2"
|
||||
style={{ borderTop: `1px solid ${COLOR_BORDER_SUBTLE}` }}
|
||||
>
|
||||
<span className="mr-1 text-[10px]" style={{ color: COLOR_TEXT_MUTED }}>
|
||||
View:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onViewModeChange('category')}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
viewMode === 'category' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||
color: viewMode === 'category' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
<LayoutList size={10} />
|
||||
Category
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('ranked')}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] transition-colors"
|
||||
style={{
|
||||
backgroundColor:
|
||||
viewMode === 'ranked' ? 'rgba(99, 102, 241, 0.2)' : COLOR_SURFACE_OVERLAY,
|
||||
color: viewMode === 'ranked' ? '#818cf8' : COLOR_TEXT_MUTED,
|
||||
}}
|
||||
>
|
||||
<ArrowDownWideNarrow size={10} />
|
||||
By Size
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { COLOR_BORDER, COLOR_SURFACE, COLOR_TEXT_MUTED } from '@renderer/constan
|
|||
|
||||
import { ClaudeMdFilesSection } from './components/ClaudeMdFilesSection';
|
||||
import { MentionedFilesSection } from './components/MentionedFilesSection';
|
||||
import { RankedInjectionList } from './components/RankedInjectionList';
|
||||
import { SessionContextHeader } from './components/SessionContextHeader';
|
||||
import { TaskCoordinationSection } from './components/TaskCoordinationSection';
|
||||
import { ThinkingTextSection } from './components/ThinkingTextSection';
|
||||
|
|
@ -23,7 +24,7 @@ import {
|
|||
SECTION_USER_MESSAGES,
|
||||
} from './types';
|
||||
|
||||
import type { SectionType, SessionContextPanelProps } from './types';
|
||||
import type { ContextViewMode, SectionType, SessionContextPanelProps } from './types';
|
||||
import type {
|
||||
ClaudeMdContextInjection,
|
||||
MentionedFileInjection,
|
||||
|
|
@ -38,11 +39,16 @@ export const SessionContextPanel = ({
|
|||
onClose,
|
||||
projectRoot,
|
||||
onNavigateToTurn,
|
||||
onNavigateToTool,
|
||||
onNavigateToUserGroup,
|
||||
totalSessionTokens,
|
||||
phaseInfo,
|
||||
selectedPhase,
|
||||
onPhaseChange,
|
||||
}: Readonly<SessionContextPanelProps>): React.ReactElement => {
|
||||
// View mode: category sections or flat ranked list
|
||||
const [viewMode, setViewMode] = useState<ContextViewMode>('category');
|
||||
|
||||
// Track which main sections are expanded
|
||||
const [expandedSections, setExpandedSections] = useState<Set<SectionType>>(
|
||||
new Set([
|
||||
|
|
@ -180,6 +186,8 @@ export const SessionContextPanel = ({
|
|||
phaseInfo={phaseInfo}
|
||||
selectedPhase={selectedPhase}
|
||||
onPhaseChange={onPhaseChange}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -191,7 +199,7 @@ export const SessionContextPanel = ({
|
|||
>
|
||||
No context injections detected in this session
|
||||
</div>
|
||||
) : (
|
||||
) : viewMode === 'category' ? (
|
||||
<>
|
||||
<UserMessagesSection
|
||||
injections={userMessageInjections}
|
||||
|
|
@ -243,6 +251,13 @@ export const SessionContextPanel = ({
|
|||
onNavigateToTurn={onNavigateToTurn}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<RankedInjectionList
|
||||
injections={injections}
|
||||
onNavigateToTurn={onNavigateToTurn}
|
||||
onNavigateToTool={onNavigateToTool}
|
||||
onNavigateToUserGroup={onNavigateToUserGroup}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ export interface SessionContextPanelProps {
|
|||
projectRoot?: string;
|
||||
/** Click Turn N to navigate to that turn */
|
||||
onNavigateToTurn?: (turnIndex: number) => void;
|
||||
/** Navigate to a specific tool within a turn by toolUseId */
|
||||
onNavigateToTool?: (turnIndex: number, toolUseId: string) => void;
|
||||
/** Navigate to the user message group preceding the AI group at turnIndex */
|
||||
onNavigateToUserGroup?: (turnIndex: number) => void;
|
||||
/** Total session tokens (input + output + cache) for comparison */
|
||||
totalSessionTokens?: number;
|
||||
/** Phase information for phase selector */
|
||||
|
|
@ -49,6 +53,9 @@ export type SectionType =
|
|||
| typeof SECTION_TASK_COORDINATION
|
||||
| typeof SECTION_USER_MESSAGES;
|
||||
|
||||
/** View mode for the context panel */
|
||||
export type ContextViewMode = 'category' | 'ranked';
|
||||
|
||||
// =============================================================================
|
||||
// CLAUDE.md Group Types
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -1,9 +1,24 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { CARD_ICON_MUTED } from '@renderer/constants/cssVariables';
|
||||
import {
|
||||
CARD_ICON_MUTED,
|
||||
CODE_BG,
|
||||
CODE_BORDER,
|
||||
COLOR_TEXT_MUTED,
|
||||
TOOL_CALL_BG,
|
||||
TOOL_CALL_BORDER,
|
||||
TOOL_CALL_TEXT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { truncateText } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { format } from 'date-fns';
|
||||
import { ChevronRight, Layers, MailOpen } from 'lucide-react';
|
||||
|
||||
import { MarkdownViewer } from '../viewers/MarkdownViewer';
|
||||
|
||||
import { BaseItem } from './BaseItem';
|
||||
import { LinkedToolItem } from './LinkedToolItem';
|
||||
import { TeammateMessageItem } from './TeammateMessageItem';
|
||||
import { TextItem } from './TextItem';
|
||||
import { ThinkingItem } from './ThinkingItem';
|
||||
|
||||
|
|
@ -142,6 +157,115 @@ export const ExecutionTrace: React.FC<ExecutionTraceProps> = ({
|
|||
</div>
|
||||
);
|
||||
|
||||
case 'subagent_input': {
|
||||
const itemId = `subagent-input-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<BaseItem
|
||||
key={itemId}
|
||||
icon={<MailOpen className="size-4" />}
|
||||
label="Input"
|
||||
summary={truncateText(item.content, 80)}
|
||||
tokenCount={item.tokenCount}
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
isExpanded={isExpanded}
|
||||
>
|
||||
<MarkdownViewer content={item.content} copyable />
|
||||
</BaseItem>
|
||||
);
|
||||
}
|
||||
|
||||
case 'teammate_message': {
|
||||
const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<TeammateMessageItem
|
||||
key={itemId}
|
||||
teammateMessage={item.teammateMessage}
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
isExpanded={isExpanded}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'compact_boundary': {
|
||||
const itemId = `subagent-compact-${index}`;
|
||||
const isExpanded = expandedItemId === itemId;
|
||||
return (
|
||||
<div key={itemId}>
|
||||
{/* Header — matches CompactBoundary.tsx amber styling */}
|
||||
<button
|
||||
onClick={() => handleItemClick(itemId)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-2 transition-all duration-200"
|
||||
style={{
|
||||
backgroundColor: TOOL_CALL_BG,
|
||||
border: `1px solid ${TOOL_CALL_BORDER}`,
|
||||
}}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
style={{ color: TOOL_CALL_TEXT }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||
/>
|
||||
<Layers size={14} />
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium" style={{ color: TOOL_CALL_TEXT }}>
|
||||
Compacted
|
||||
</span>
|
||||
{item.tokenDelta && (
|
||||
<span
|
||||
className="min-w-0 truncate text-[11px] tabular-nums"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(item.tokenDelta.preCompactionTokens)} →{' '}
|
||||
{formatTokensCompact(item.tokenDelta.postCompactionTokens)}
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
({formatTokensCompact(Math.abs(item.tokenDelta.delta))} freed)
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: 'rgba(99, 102, 241, 0.15)',
|
||||
color: '#818cf8',
|
||||
}}
|
||||
>
|
||||
Phase {item.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="ml-auto shrink-0 text-[11px]"
|
||||
style={{ color: COLOR_TEXT_MUTED }}
|
||||
>
|
||||
{format(new Date(item.timestamp), 'h:mm:ss a')}
|
||||
</span>
|
||||
</button>
|
||||
{/* Expanded content */}
|
||||
{isExpanded && item.content && (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-lg"
|
||||
style={{
|
||||
backgroundColor: CODE_BG,
|
||||
border: `1px solid ${CODE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto border-l-2 px-3 py-2"
|
||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||
>
|
||||
<MarkdownViewer content={item.content} copyable />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { formatTokensCompact } from '@renderer/utils/formatters';
|
|||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
import type { PhaseTokenBreakdown } from '@renderer/types/data';
|
||||
|
||||
interface MetricsPillProps {
|
||||
mainSessionImpact?: {
|
||||
|
|
@ -30,6 +31,10 @@ interface MetricsPillProps {
|
|||
};
|
||||
/** Label override for the right segment (e.g. "Context Window" for team members) */
|
||||
isolatedLabel?: string;
|
||||
/** Override isolated total (for multi-phase total consumption) */
|
||||
isolatedOverride?: number;
|
||||
/** Phase breakdown for tooltip (shown when multiple phases exist) */
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -40,6 +45,8 @@ export const MetricsPill = ({
|
|||
mainSessionImpact,
|
||||
lastUsage,
|
||||
isolatedLabel,
|
||||
isolatedOverride,
|
||||
phaseBreakdown,
|
||||
}: Readonly<MetricsPillProps>): React.ReactElement | null => {
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
|
||||
|
|
@ -47,14 +54,21 @@ export const MetricsPill = ({
|
|||
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hasMainImpact = mainSessionImpact && mainSessionImpact.totalTokens > 0;
|
||||
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||
const hasIsolated =
|
||||
isolatedOverride != null
|
||||
? isolatedOverride > 0
|
||||
: lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||
|
||||
const isolatedTotal = lastUsage
|
||||
? lastUsage.input_tokens +
|
||||
lastUsage.output_tokens +
|
||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||
: 0;
|
||||
const isolatedTotal =
|
||||
isolatedOverride ??
|
||||
(lastUsage
|
||||
? lastUsage.input_tokens +
|
||||
lastUsage.output_tokens +
|
||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||
: 0);
|
||||
|
||||
const hasPhases = phaseBreakdown && phaseBreakdown.length > 1;
|
||||
|
||||
const clearHideTimeout = (): void => {
|
||||
if (hideTimeoutRef.current) {
|
||||
|
|
@ -109,7 +123,7 @@ export const MetricsPill = ({
|
|||
|
||||
const mainValue = hasMainImpact ? formatTokensCompact(mainSessionImpact.totalTokens) : null;
|
||||
const isolatedValue = hasIsolated ? formatTokensCompact(isolatedTotal) : null;
|
||||
const rightLabel = isolatedLabel ?? 'Isolated Usage';
|
||||
const rightLabel = isolatedLabel ?? 'Subagent Context';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -160,6 +174,29 @@ export const MetricsPill = ({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasPhases &&
|
||||
phaseBreakdown.map((phase) => (
|
||||
<div
|
||||
key={phase.phaseNumber}
|
||||
className="flex items-center justify-between gap-3 pl-2"
|
||||
>
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
Phase {phase.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[10px] tabular-nums"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(phase.peakTokens)}
|
||||
{phase.postCompaction != null && (
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
→ {formatTokensCompact(phase.postCompaction)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className="mt-1 pt-1.5 text-[10px]"
|
||||
style={{ borderTop: `1px solid ${TAG_BORDER}`, color: CARD_ICON_MUTED }}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildDisplayItemsFromMessages, buildSummary } from '@renderer/utils/aiGroupEnhancer';
|
||||
import { formatDuration } from '@renderer/utils/formatters';
|
||||
import { computeSubagentPhaseBreakdown } from '@renderer/utils/aiGroupHelpers';
|
||||
import { formatDuration, formatTokensCompact } from '@renderer/utils/formatters';
|
||||
import { getHighlightProps, type TriggerColor } from '@shared/constants/triggerColors';
|
||||
import { getModelColorClass, parseModelString } from '@shared/utils/modelParser';
|
||||
import {
|
||||
|
|
@ -154,6 +155,12 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
return null;
|
||||
}, [subagent.messages]);
|
||||
|
||||
// Multi-phase context breakdown (for subagents with compaction)
|
||||
const phaseData = useMemo(() => {
|
||||
if (!subagent.messages?.length) return null;
|
||||
return computeSubagentPhaseBreakdown(subagent.messages);
|
||||
}, [subagent.messages]);
|
||||
|
||||
// Search expansion
|
||||
const searchExpandedSubagentIds = useStore((s) => s.searchExpandedSubagentIds);
|
||||
const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId);
|
||||
|
|
@ -196,12 +203,15 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
// Computed values for metrics
|
||||
const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0;
|
||||
const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0;
|
||||
const isolatedTotal = lastUsage
|
||||
? lastUsage.input_tokens +
|
||||
lastUsage.output_tokens +
|
||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||
: 0;
|
||||
const isMultiPhase = phaseData != null && phaseData.compactionCount > 0;
|
||||
const isolatedTotal = isMultiPhase
|
||||
? phaseData.totalConsumption
|
||||
: lastUsage
|
||||
? lastUsage.input_tokens +
|
||||
lastUsage.output_tokens +
|
||||
(lastUsage.cache_read_input_tokens ?? 0) +
|
||||
(lastUsage.cache_creation_input_tokens ?? 0)
|
||||
: 0;
|
||||
|
||||
// Shutdown-only team activations: minimal inline row (no metrics, no expand)
|
||||
if (isShutdownOnly && teamColors && subagent.team) {
|
||||
|
|
@ -338,6 +348,10 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
mainSessionImpact={subagent.team ? undefined : subagent.mainSessionImpact}
|
||||
lastUsage={lastUsage ?? undefined}
|
||||
isolatedLabel={subagent.team ? 'Context Window' : undefined}
|
||||
isolatedOverride={
|
||||
phaseData && phaseData.compactionCount > 0 ? phaseData.totalConsumption : undefined
|
||||
}
|
||||
phaseBreakdown={phaseData?.phases}
|
||||
/>
|
||||
|
||||
{/* Duration */}
|
||||
|
|
@ -453,7 +467,7 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<CircleDot className="size-3" style={{ color: 'rgba(56, 189, 248, 0.7)' }} />
|
||||
<span className="text-xs" style={{ color: COLOR_TEXT_SECONDARY }}>
|
||||
{subagent.team ? 'Context Window' : 'Isolated Usage'}
|
||||
{subagent.team ? 'Context Window' : 'Subagent Context'}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -464,6 +478,28 @@ export const SubagentItem: React.FC<SubagentItemProps> = ({
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Per-phase breakdown when multi-phase */}
|
||||
{isMultiPhase &&
|
||||
phaseData.phases.map((phase) => (
|
||||
<div key={phase.phaseNumber} className="flex items-center justify-between pl-5">
|
||||
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
Phase {phase.phaseNumber}
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-[11px] tabular-nums"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
{formatTokensCompact(phase.peakTokens)}
|
||||
{phase.postCompaction != null && (
|
||||
<span style={{ color: '#4ade80' }}>
|
||||
{' '}
|
||||
→ {formatTokensCompact(phase.postCompaction)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ let globalResolver: ConfirmResolver = null;
|
|||
* Usage:
|
||||
* const confirmed = await confirm({ title: 'Delete?', message: 'This cannot be undone.' });
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- imperative API shares singleton state with component
|
||||
export async function confirm(opts: {
|
||||
title: string;
|
||||
message: string;
|
||||
|
|
|
|||
|
|
@ -33,14 +33,20 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
|||
>
|
||||
{isDownloading ? (
|
||||
<div className="pr-8">
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
<div
|
||||
className="mb-1.5 flex items-center gap-2 text-xs"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
<Loader2 className="size-3.5 shrink-0 animate-spin text-blue-400" />
|
||||
<span>Updating app</span>
|
||||
<span className="tabular-nums" style={{ color: 'var(--color-text-muted)' }}>
|
||||
{clampedPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full" style={{ backgroundColor: 'var(--color-border)' }}>
|
||||
<div
|
||||
className="h-1 w-full overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-500 transition-all duration-300 ease-out"
|
||||
style={{ width: `${clampedPercent}%` }}
|
||||
|
|
@ -74,7 +80,7 @@ export const UpdateBanner = (): React.JSX.Element | null => {
|
|||
{/* Dismiss */}
|
||||
<button
|
||||
onClick={dismissUpdateBanner}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 shrink-0 rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
className="absolute right-3 top-1/2 shrink-0 -translate-y-1/2 rounded p-0.5 transition-colors hover:bg-white/10"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
const logger = createLogger('Component:DashboardView');
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, Search } from 'lucide-react';
|
||||
import { Command, FolderGit2, FolderOpen, GitBranch, Search, Settings } from 'lucide-react';
|
||||
|
||||
import type { RepositoryGroup } from '@renderer/types/data';
|
||||
|
||||
|
|
@ -394,6 +394,7 @@ const ProjectsGrid = ({
|
|||
|
||||
export const DashboardView = (): React.JSX.Element => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const openSettingsTab = useStore((s) => s.openSettingsTab);
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 overflow-auto bg-surface">
|
||||
|
|
@ -415,14 +416,24 @@ export const DashboardView = (): React.JSX.Element => {
|
|||
<h2 className="text-xs font-medium uppercase tracking-wider text-text-muted">
|
||||
{searchQuery.trim() ? 'Search Results' : 'Recent Projects'}
|
||||
</h2>
|
||||
{searchQuery.trim() && (
|
||||
<div className="flex items-center gap-3">
|
||||
{searchQuery.trim() && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
onClick={() => openSettingsTab('general')}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted transition-colors hover:text-text-secondary"
|
||||
title="Change Claude data folder"
|
||||
>
|
||||
Clear search
|
||||
<Settings className="size-3" />
|
||||
Change default folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
|
|
|
|||
|
|
@ -333,9 +333,7 @@ export const SidebarHeader = (): React.JSX.Element => {
|
|||
style={
|
||||
{
|
||||
height: `${HEADER_ROW1_HEIGHT}px`,
|
||||
paddingLeft: isMacElectron
|
||||
? 'var(--macos-traffic-light-padding-left, 72px)'
|
||||
: '16px',
|
||||
paddingLeft: isMacElectron ? 'var(--macos-traffic-light-padding-left, 72px)' : '16px',
|
||||
WebkitAppRegion: isMacElectron ? 'drag' : undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
splitPane,
|
||||
togglePinSession,
|
||||
pinnedSessionIds,
|
||||
toggleHideSession,
|
||||
hiddenSessionIds,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
pane: s.paneLayout.panes.find((p) => p.id === paneId),
|
||||
|
|
@ -72,6 +74,8 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
splitPane: s.splitPane,
|
||||
togglePinSession: s.togglePinSession,
|
||||
pinnedSessionIds: s.pinnedSessionIds,
|
||||
toggleHideSession: s.toggleHideSession,
|
||||
hiddenSessionIds: s.hiddenSessionIds,
|
||||
}))
|
||||
);
|
||||
|
||||
|
|
@ -235,6 +239,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
isContextMenuTabSession && contextMenuTab?.sessionId
|
||||
? pinnedSessionIds.includes(contextMenuTab.sessionId)
|
||||
: false;
|
||||
const isContextMenuTabHidden =
|
||||
isContextMenuTabSession && contextMenuTab?.sessionId
|
||||
? hiddenSessionIds.includes(contextMenuTab.sessionId)
|
||||
: false;
|
||||
|
||||
// Show sidebar expand button only in the leftmost pane
|
||||
const isLeftmostPane = useStore(
|
||||
|
|
@ -384,22 +392,20 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
)}
|
||||
</button>
|
||||
|
||||
{/* Settings gear icon (Electron only - browser can't access native settings) */}
|
||||
{isElectronMode() && (
|
||||
<button
|
||||
onClick={openSettingsTab}
|
||||
onMouseEnter={() => setSettingsHover(true)}
|
||||
onMouseLeave={() => setSettingsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Settings gear icon */}
|
||||
<button
|
||||
onClick={() => openSettingsTab()}
|
||||
onMouseEnter={() => setSettingsHover(true)}
|
||||
onMouseLeave={() => setSettingsHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: settingsHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: settingsHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Context menu */}
|
||||
|
|
@ -427,6 +433,12 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
? () => togglePinSession(contextMenuTab.sessionId!)
|
||||
: undefined
|
||||
}
|
||||
isHidden={isContextMenuTabHidden}
|
||||
onToggleHide={
|
||||
isContextMenuTabSession && contextMenuTab?.sessionId
|
||||
? () => toggleHideSession(contextMenuTab.sessionId!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ interface TabContextMenuProps {
|
|||
isPinned?: boolean;
|
||||
/** Callback to toggle pin state */
|
||||
onTogglePin?: () => void;
|
||||
/** Whether this session is currently hidden from the sidebar */
|
||||
isHidden?: boolean;
|
||||
/** Callback to toggle hide state */
|
||||
onToggleHide?: () => void;
|
||||
}
|
||||
|
||||
export const TabContextMenu = ({
|
||||
|
|
@ -44,6 +48,8 @@ export const TabContextMenu = ({
|
|||
isSessionTab,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
isHidden,
|
||||
onToggleHide,
|
||||
}: TabContextMenuProps): React.JSX.Element => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -114,6 +120,12 @@ export const TabContextMenu = ({
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
{isSessionTab && onToggleHide && (
|
||||
<MenuItem
|
||||
label={isHidden ? 'Unhide from Sidebar' : 'Hide from Sidebar'}
|
||||
onClick={handleClick(onToggleHide)}
|
||||
/>
|
||||
)}
|
||||
<div className="mx-2 my-1 border-t" style={{ borderColor: 'var(--color-border)' }} />
|
||||
<MenuItem label="Close All Tabs" shortcut="⇧⌘W" onClick={handleClick(onCloseAllTabs)} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { isElectronMode } from '@renderer/api';
|
||||
import { Bell, HardDrive, Server, Settings, Wrench } from 'lucide-react';
|
||||
|
||||
export type SettingsSection = 'general' | 'connection' | 'workspace' | 'notifications' | 'advanced';
|
||||
|
|
@ -13,12 +14,13 @@ interface TabConfig {
|
|||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
electronOnly?: boolean;
|
||||
}
|
||||
|
||||
const tabs: TabConfig[] = [
|
||||
{ id: 'general', label: 'General', icon: Settings },
|
||||
{ id: 'connection', label: 'Connection', icon: Server },
|
||||
{ id: 'workspace', label: 'Workspaces', icon: HardDrive },
|
||||
{ id: 'connection', label: 'Connection', icon: Server, electronOnly: true },
|
||||
{ id: 'workspace', label: 'Workspaces', icon: HardDrive, electronOnly: true },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ id: 'advanced', label: 'Advanced', icon: Wrench },
|
||||
];
|
||||
|
|
@ -28,10 +30,15 @@ export const SettingsTabs = ({
|
|||
onSectionChange,
|
||||
}: Readonly<SettingsTabsProps>): React.JSX.Element => {
|
||||
const [hoveredTab, setHoveredTab] = useState<SettingsSection | null>(null);
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const visibleTabs = useMemo(
|
||||
() => tabs.filter((tab) => !tab.electronOnly || isElectron),
|
||||
[isElectron]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="inline-flex gap-1 border-b" style={{ borderColor: 'var(--color-border)' }}>
|
||||
{tabs.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeSection === tab.id;
|
||||
const isHovered = hoveredTab === tab.id;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useSettingsConfig, useSettingsHandlers } from './hooks';
|
||||
|
|
@ -19,6 +20,18 @@ import { type SettingsSection, SettingsTabs } from './SettingsTabs';
|
|||
|
||||
export const SettingsView = (): React.JSX.Element | null => {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('general');
|
||||
const pendingSettingsSection = useStore((s) => s.pendingSettingsSection);
|
||||
const clearPendingSettingsSection = useStore((s) => s.clearPendingSettingsSection);
|
||||
|
||||
// Consume pending section during render (React-recommended pattern for adjusting state on prop change)
|
||||
const [prevPending, setPrevPending] = useState<string | null>(null);
|
||||
if (pendingSettingsSection !== prevPending) {
|
||||
setPrevPending(pendingSettingsSection);
|
||||
if (pendingSettingsSection) {
|
||||
setActiveSection(pendingSettingsSection as SettingsSection);
|
||||
clearPendingSettingsSection();
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
config,
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ export function useSettingsHandlers({
|
|||
},
|
||||
sessions: {
|
||||
pinnedSessions: {},
|
||||
hiddenSessions: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
* AdvancedSection - Advanced settings including config management and about info.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import appIcon from '@renderer/favicon.png';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { CheckCircle, Code2, Download, Loader2, RefreshCw, Upload } from 'lucide-react';
|
||||
|
|
@ -26,6 +26,7 @@ export const AdvancedSection = ({
|
|||
onImportConfig,
|
||||
onOpenInEditor,
|
||||
}: AdvancedSectionProps): React.JSX.Element => {
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
const [version, setVersion] = useState<string>('');
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const availableVersion = useStore((s) => s.availableVersion);
|
||||
|
|
@ -128,17 +129,19 @@ export const AdvancedSection = ({
|
|||
<Upload className="size-4" />
|
||||
Import Config
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenInEditor}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
{isElectron && (
|
||||
<button
|
||||
onClick={onOpenInEditor}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border px-4 py-2.5 text-sm font-medium transition-all duration-150"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Code2 className="size-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsSectionHeader title="About" />
|
||||
|
|
@ -149,22 +152,35 @@ export const AdvancedSection = ({
|
|||
<p className="text-sm font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
claude-devtools
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={updateStatus === 'checking'}
|
||||
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color:
|
||||
updateStatus === 'not-available'
|
||||
? 'var(--color-text-muted)'
|
||||
: updateStatus === 'available' || updateStatus === 'downloaded'
|
||||
? '#60a5fa'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{getUpdateButtonContent()}
|
||||
</button>
|
||||
{isElectron && (
|
||||
<button
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={updateStatus === 'checking'}
|
||||
className="flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5 disabled:opacity-50"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color:
|
||||
updateStatus === 'not-available'
|
||||
? 'var(--color-text-muted)'
|
||||
: updateStatus === 'available' || updateStatus === 'downloaded'
|
||||
? '#60a5fa'
|
||||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{getUpdateButtonContent()}
|
||||
</button>
|
||||
)}
|
||||
{!isElectron && (
|
||||
<span
|
||||
className="rounded-md border px-2.5 py-1 text-xs font-medium"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
Standalone
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Version {version || '...'}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,8 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { FolderOpen, Laptop, Loader2, Monitor, RotateCcw, Server, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Loader2, Monitor, Server, Wifi, WifiOff } from 'lucide-react';
|
||||
|
||||
import { SettingRow } from '../components/SettingRow';
|
||||
import { SettingsSectionHeader } from '../components/SettingsSectionHeader';
|
||||
|
|
@ -26,7 +24,6 @@ import type {
|
|||
SshConfigHostEntry,
|
||||
SshConnectionConfig,
|
||||
SshConnectionProfile,
|
||||
WslClaudeRootCandidate,
|
||||
} from '@shared/types';
|
||||
|
||||
const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
||||
|
|
@ -37,7 +34,6 @@ const authMethodOptions: readonly { value: SshAuthMethod; label: string }[] = [
|
|||
];
|
||||
|
||||
export const ConnectionSection = (): React.JSX.Element => {
|
||||
const connectionMode = useStore((s) => s.connectionMode);
|
||||
const connectionState = useStore((s) => s.connectionState);
|
||||
const connectedHost = useStore((s) => s.connectedHost);
|
||||
const connectionError = useStore((s) => s.connectionError);
|
||||
|
|
@ -48,8 +44,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
const fetchSshConfigHosts = useStore((s) => s.fetchSshConfigHosts);
|
||||
const lastSshConfig = useStore((s) => s.lastSshConfig);
|
||||
const loadLastConnection = useStore((s) => s.loadLastConnection);
|
||||
const fetchProjects = useStore((s) => s.fetchProjects);
|
||||
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
||||
|
||||
// Form state
|
||||
const [host, setHost] = useState('');
|
||||
|
|
@ -70,11 +64,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
const [savedProfiles, setSavedProfiles] = useState<SshConnectionProfile[]>([]);
|
||||
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
||||
const [claudeRootError, setClaudeRootError] = useState<string | null>(null);
|
||||
const [findingWslRoots, setFindingWslRoots] = useState(false);
|
||||
const [wslCandidates, setWslCandidates] = useState<WslClaudeRootCandidate[]>([]);
|
||||
const [showWslModal, setShowWslModal] = useState(false);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -90,10 +79,8 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
try {
|
||||
const info = await api.config.getClaudeRootInfo();
|
||||
setClaudeRootInfo(info);
|
||||
} catch (error) {
|
||||
setClaudeRootError(
|
||||
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -197,155 +184,9 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
await disconnectSsh();
|
||||
};
|
||||
|
||||
const resetWorkspaceForRootChange = useCallback((): void => {
|
||||
useStore.setState({
|
||||
projects: [],
|
||||
repositoryGroups: [],
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
paneLayout: {
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
},
|
||||
],
|
||||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyClaudeRootPath = useCallback(
|
||||
async (claudeRootPath: string | null): Promise<void> => {
|
||||
try {
|
||||
setUpdatingClaudeRoot(true);
|
||||
setClaudeRootError(null);
|
||||
|
||||
await api.config.update('general', { claudeRootPath });
|
||||
await loadClaudeRootInfo();
|
||||
|
||||
if (connectionMode === 'local') {
|
||||
resetWorkspaceForRootChange();
|
||||
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
|
||||
}
|
||||
} catch (error) {
|
||||
setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
|
||||
} finally {
|
||||
setUpdatingClaudeRoot(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
connectionMode,
|
||||
fetchProjects,
|
||||
fetchRepositoryGroups,
|
||||
loadClaudeRootInfo,
|
||||
resetWorkspaceForRootChange,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSelectClaudeRootFolder = useCallback(async (): Promise<void> => {
|
||||
setClaudeRootError(null);
|
||||
|
||||
const selection = await api.config.selectClaudeRootFolder();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.isClaudeDirName) {
|
||||
const proceed = await confirm({
|
||||
title: 'Selected folder is not .claude',
|
||||
message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
|
||||
confirmLabel: 'Use Folder',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection.hasProjectsDir) {
|
||||
const proceed = await confirm({
|
||||
title: 'No projects directory found',
|
||||
message: 'This folder does not contain a "projects" directory. Continue anyway?',
|
||||
confirmLabel: 'Use Folder',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await applyClaudeRootPath(selection.path);
|
||||
}, [applyClaudeRootPath]);
|
||||
|
||||
const handleResetClaudeRoot = useCallback(async (): Promise<void> => {
|
||||
await applyClaudeRootPath(null);
|
||||
}, [applyClaudeRootPath]);
|
||||
|
||||
const applyWslCandidate = useCallback(
|
||||
async (candidate: WslClaudeRootCandidate): Promise<void> => {
|
||||
if (!candidate.hasProjectsDir) {
|
||||
const proceed = await confirm({
|
||||
title: 'WSL path missing projects directory',
|
||||
message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
|
||||
confirmLabel: 'Use Path',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await applyClaudeRootPath(candidate.path);
|
||||
setShowWslModal(false);
|
||||
},
|
||||
[applyClaudeRootPath]
|
||||
);
|
||||
|
||||
const handleUseWslForClaude = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setFindingWslRoots(true);
|
||||
setClaudeRootError(null);
|
||||
const candidates = await api.config.findWslClaudeRoots();
|
||||
setWslCandidates(candidates);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const pickManually = await confirm({
|
||||
title: 'No WSL Claude paths found',
|
||||
message: 'Could not find WSL distros with Claude data automatically. Select folder manually?',
|
||||
confirmLabel: 'Select Folder',
|
||||
});
|
||||
if (pickManually) {
|
||||
await handleSelectClaudeRootFolder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir);
|
||||
if (candidatesWithProjects.length === 1) {
|
||||
await applyWslCandidate(candidatesWithProjects[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowWslModal(true);
|
||||
} catch (error) {
|
||||
setClaudeRootError(
|
||||
error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
|
||||
);
|
||||
} finally {
|
||||
setFindingWslRoots(false);
|
||||
}
|
||||
}, [applyWslCandidate, handleSelectClaudeRootFolder]);
|
||||
|
||||
const isConnecting = connectionState === 'connecting';
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
|
||||
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
||||
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
||||
const isWindowsStyleDefaultPath =
|
||||
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
||||
|
||||
const inputClass = 'w-full rounded-md border px-3 py-1.5 text-sm focus:outline-none focus:ring-1';
|
||||
const inputStyle = {
|
||||
|
|
@ -356,175 +197,6 @@ export const ConnectionSection = (): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsSectionHeader title="Local Claude Root" />
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Choose which local folder is treated as your Claude data root
|
||||
</p>
|
||||
|
||||
<SettingRow
|
||||
label="Current Local Root"
|
||||
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
||||
>
|
||||
<div className="max-w-96 text-right">
|
||||
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
||||
{resolvedClaudeRootPath}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Auto-detected: {defaultClaudeRootPath}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => void handleSelectClaudeRootFolder()}
|
||||
disabled={updatingClaudeRoot}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{updatingClaudeRoot ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<FolderOpen className="size-3" />
|
||||
)}
|
||||
Select Folder
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => void handleResetClaudeRoot()}
|
||||
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RotateCcw className="size-3" />
|
||||
Use Auto-Detect
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isWindowsStyleDefaultPath && (
|
||||
<button
|
||||
onClick={() => void handleUseWslForClaude()}
|
||||
disabled={updatingClaudeRoot || findingWslRoots}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{findingWslRoots ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Laptop className="size-3" />
|
||||
)}
|
||||
Using Linux/WSL?
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claudeRootError && (
|
||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{claudeRootError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWslModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
className="absolute inset-0 cursor-default"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||
onClick={() => setShowWslModal(false)}
|
||||
aria-label="Close WSL path modal"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Select WSL Claude Root
|
||||
</h3>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Detected WSL distributions and Claude root candidates
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{wslCandidates.map((candidate) => (
|
||||
<div
|
||||
key={`${candidate.distro}:${candidate.path}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{candidate.distro}
|
||||
</p>
|
||||
<p
|
||||
className="truncate font-mono text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{candidate.path}
|
||||
</p>
|
||||
{!candidate.hasProjectsDir && (
|
||||
<p className="text-[11px] text-amber-400">No projects directory detected</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void applyWslCandidate(candidate)}
|
||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
Use This Path
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowWslModal(false)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWslModal(false);
|
||||
void handleSelectClaudeRootFolder();
|
||||
}}
|
||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
Select Folder Manually
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsSectionHeader title="Remote Connection" />
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Connect to a remote machine to view Claude Code sessions running there
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
/**
|
||||
* GeneralSection - General settings including startup, appearance, and browser access.
|
||||
* GeneralSection - General settings including startup, appearance, browser access, and local Claude root.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
import { Check, Copy, Loader2 } from 'lucide-react';
|
||||
import { api, isElectronMode } from '@renderer/api';
|
||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { getFullResetState } from '@renderer/store/utils/stateResetHelpers';
|
||||
import { Check, Copy, FolderOpen, Laptop, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { SettingRow, SettingsSectionHeader, SettingsSelect, SettingsToggle } from '../components';
|
||||
|
||||
import type { SafeConfig } from '../hooks/useSettingsConfig';
|
||||
import type { ClaudeRootInfo, WslClaudeRootCandidate } from '@shared/types';
|
||||
import type { HttpServerStatus } from '@shared/types/api';
|
||||
|
||||
// Theme options
|
||||
|
|
@ -39,11 +43,38 @@ export const GeneralSection = ({
|
|||
const [serverLoading, setServerLoading] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Fetch server status on mount
|
||||
// Claude Root state
|
||||
const connectionMode = useStore((s) => s.connectionMode);
|
||||
const fetchProjects = useStore((s) => s.fetchProjects);
|
||||
const fetchRepositoryGroups = useStore((s) => s.fetchRepositoryGroups);
|
||||
|
||||
const [claudeRootInfo, setClaudeRootInfo] = useState<ClaudeRootInfo | null>(null);
|
||||
const [updatingClaudeRoot, setUpdatingClaudeRoot] = useState(false);
|
||||
const [claudeRootError, setClaudeRootError] = useState<string | null>(null);
|
||||
const [findingWslRoots, setFindingWslRoots] = useState(false);
|
||||
const [wslCandidates, setWslCandidates] = useState<WslClaudeRootCandidate[]>([]);
|
||||
const [showWslModal, setShowWslModal] = useState(false);
|
||||
|
||||
// Fetch server status and Claude root info on mount
|
||||
useEffect(() => {
|
||||
void api.httpServer.getStatus().then(setServerStatus);
|
||||
}, []);
|
||||
|
||||
const loadClaudeRootInfo = useCallback(async () => {
|
||||
try {
|
||||
const info = await api.config.getClaudeRootInfo();
|
||||
setClaudeRootInfo(info);
|
||||
} catch (error) {
|
||||
setClaudeRootError(
|
||||
error instanceof Error ? error.message : 'Failed to load local Claude root settings'
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadClaudeRootInfo();
|
||||
}, [loadClaudeRootInfo]);
|
||||
|
||||
const handleServerToggle = useCallback(async (enabled: boolean) => {
|
||||
setServerLoading(true);
|
||||
try {
|
||||
|
|
@ -64,24 +95,186 @@ export const GeneralSection = ({
|
|||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [serverUrl]);
|
||||
|
||||
// Claude Root handlers
|
||||
const resetWorkspaceForRootChange = useCallback((): void => {
|
||||
useStore.setState({
|
||||
projects: [],
|
||||
repositoryGroups: [],
|
||||
openTabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
paneLayout: {
|
||||
panes: [
|
||||
{
|
||||
id: 'pane-default',
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
selectedTabIds: [],
|
||||
widthFraction: 1,
|
||||
},
|
||||
],
|
||||
focusedPaneId: 'pane-default',
|
||||
},
|
||||
...getFullResetState(),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const applyClaudeRootPath = useCallback(
|
||||
async (claudeRootPath: string | null): Promise<void> => {
|
||||
try {
|
||||
setUpdatingClaudeRoot(true);
|
||||
setClaudeRootError(null);
|
||||
|
||||
await api.config.update('general', { claudeRootPath });
|
||||
await loadClaudeRootInfo();
|
||||
|
||||
if (connectionMode === 'local') {
|
||||
resetWorkspaceForRootChange();
|
||||
await Promise.all([fetchProjects(), fetchRepositoryGroups()]);
|
||||
}
|
||||
} catch (error) {
|
||||
setClaudeRootError(error instanceof Error ? error.message : 'Failed to update Claude root');
|
||||
} finally {
|
||||
setUpdatingClaudeRoot(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
connectionMode,
|
||||
fetchProjects,
|
||||
fetchRepositoryGroups,
|
||||
loadClaudeRootInfo,
|
||||
resetWorkspaceForRootChange,
|
||||
]
|
||||
);
|
||||
|
||||
const handleSelectClaudeRootFolder = useCallback(async (): Promise<void> => {
|
||||
setClaudeRootError(null);
|
||||
|
||||
const selection = await api.config.selectClaudeRootFolder();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.isClaudeDirName) {
|
||||
const proceed = await confirm({
|
||||
title: 'Selected folder is not .claude',
|
||||
message: `This folder is named "${selection.path.split(/[\\/]/).pop() ?? selection.path}", not ".claude". Continue anyway?`,
|
||||
confirmLabel: 'Use Folder',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selection.hasProjectsDir) {
|
||||
const proceed = await confirm({
|
||||
title: 'No projects directory found',
|
||||
message: 'This folder does not contain a "projects" directory. Continue anyway?',
|
||||
confirmLabel: 'Use Folder',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await applyClaudeRootPath(selection.path);
|
||||
}, [applyClaudeRootPath]);
|
||||
|
||||
const handleResetClaudeRoot = useCallback(async (): Promise<void> => {
|
||||
await applyClaudeRootPath(null);
|
||||
}, [applyClaudeRootPath]);
|
||||
|
||||
const applyWslCandidate = useCallback(
|
||||
async (candidate: WslClaudeRootCandidate): Promise<void> => {
|
||||
if (!candidate.hasProjectsDir) {
|
||||
const proceed = await confirm({
|
||||
title: 'WSL path missing projects directory',
|
||||
message: `"${candidate.path}" does not contain a "projects" directory. Continue anyway?`,
|
||||
confirmLabel: 'Use Path',
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await applyClaudeRootPath(candidate.path);
|
||||
setShowWslModal(false);
|
||||
},
|
||||
[applyClaudeRootPath]
|
||||
);
|
||||
|
||||
const handleUseWslForClaude = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setFindingWslRoots(true);
|
||||
setClaudeRootError(null);
|
||||
const candidates = await api.config.findWslClaudeRoots();
|
||||
setWslCandidates(candidates);
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const pickManually = await confirm({
|
||||
title: 'No WSL Claude paths found',
|
||||
message:
|
||||
'Could not find WSL distros with Claude data automatically. Select folder manually?',
|
||||
confirmLabel: 'Select Folder',
|
||||
});
|
||||
if (pickManually) {
|
||||
await handleSelectClaudeRootFolder();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const candidatesWithProjects = candidates.filter((candidate) => candidate.hasProjectsDir);
|
||||
if (candidatesWithProjects.length === 1) {
|
||||
await applyWslCandidate(candidatesWithProjects[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
setShowWslModal(true);
|
||||
} catch (error) {
|
||||
setClaudeRootError(
|
||||
error instanceof Error ? error.message : 'Failed to detect WSL Claude root paths'
|
||||
);
|
||||
} finally {
|
||||
setFindingWslRoots(false);
|
||||
}
|
||||
}, [applyWslCandidate, handleSelectClaudeRootFolder]);
|
||||
|
||||
const isCustomClaudeRoot = Boolean(claudeRootInfo?.customPath);
|
||||
const resolvedClaudeRootPath = claudeRootInfo?.resolvedPath ?? '~/.claude';
|
||||
const defaultClaudeRootPath = claudeRootInfo?.defaultPath ?? '~/.claude';
|
||||
const isWindowsStyleDefaultPath =
|
||||
/^[a-zA-Z]:\\/.test(defaultClaudeRootPath) || defaultClaudeRootPath.startsWith('\\\\');
|
||||
|
||||
const isElectron = useMemo(() => isElectronMode(), []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsSectionHeader title="Startup" />
|
||||
<SettingRow label="Launch at login" description="Automatically start the app when you log in">
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.launchAtLogin}
|
||||
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{window.navigator.userAgent.includes('Macintosh') && (
|
||||
<SettingRow label="Show dock icon" description="Display the app icon in the dock (macOS)">
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.showDockIcon}
|
||||
onChange={(v) => onGeneralToggle('showDockIcon', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isElectron && (
|
||||
<>
|
||||
<SettingsSectionHeader title="Startup" />
|
||||
<SettingRow
|
||||
label="Launch at login"
|
||||
description="Automatically start the app when you log in"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.launchAtLogin}
|
||||
onChange={(v) => onGeneralToggle('launchAtLogin', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
{window.navigator.userAgent.includes('Macintosh') && (
|
||||
<SettingRow
|
||||
label="Show dock icon"
|
||||
description="Display the app icon in the dock (macOS)"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.general.showDockIcon}
|
||||
onChange={(v) => onGeneralToggle('showDockIcon', v)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SettingsSectionHeader title="Appearance" />
|
||||
|
|
@ -94,53 +287,283 @@ export const GeneralSection = ({
|
|||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingsSectionHeader title="Browser Access" />
|
||||
<SettingRow
|
||||
label="Enable server mode"
|
||||
description="Start an HTTP server to access the UI from a browser or embed in iframes"
|
||||
>
|
||||
{serverLoading ? (
|
||||
<Loader2 className="size-5 animate-spin" style={{ color: 'var(--color-text-muted)' }} />
|
||||
) : (
|
||||
<SettingsToggle
|
||||
enabled={serverStatus.running}
|
||||
onChange={handleServerToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
</SettingRow>
|
||||
{isElectron && (
|
||||
<>
|
||||
<SettingsSectionHeader title="Local Claude Root" />
|
||||
<p className="mb-4 text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Choose which local folder is treated as your Claude data root
|
||||
</p>
|
||||
|
||||
{serverStatus.running && (
|
||||
<div
|
||||
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Running on
|
||||
</span>
|
||||
<code
|
||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
<SettingRow
|
||||
label="Current Local Root"
|
||||
description={isCustomClaudeRoot ? 'Using custom path' : 'Using auto-detected path'}
|
||||
>
|
||||
{serverUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
<div className="max-w-96 text-right">
|
||||
<div className="truncate font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
||||
{resolvedClaudeRootPath}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Auto-detected: {defaultClaudeRootPath}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<button
|
||||
onClick={() => void handleSelectClaudeRootFolder()}
|
||||
disabled={updatingClaudeRoot}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{updatingClaudeRoot ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<FolderOpen className="size-3" />
|
||||
)}
|
||||
Select Folder
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => void handleResetClaudeRoot()}
|
||||
disabled={updatingClaudeRoot || !isCustomClaudeRoot}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<RotateCcw className="size-3" />
|
||||
Use Auto-Detect
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isWindowsStyleDefaultPath && (
|
||||
<button
|
||||
onClick={() => void handleUseWslForClaude()}
|
||||
disabled={updatingClaudeRoot || findingWslRoots}
|
||||
className="rounded-md px-4 py-1.5 text-sm transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{findingWslRoots ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<Laptop className="size-3" />
|
||||
)}
|
||||
Using Linux/WSL?
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{claudeRootError && (
|
||||
<div className="rounded-md border border-red-500/20 bg-red-500/10 px-4 py-3">
|
||||
<p className="text-sm text-red-400">{claudeRootError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWslModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
className="absolute inset-0 cursor-default"
|
||||
style={{ backgroundColor: 'rgba(0, 0, 0, 0.6)' }}
|
||||
onClick={() => setShowWslModal(false)}
|
||||
aria-label="Close WSL path modal"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<div
|
||||
className="relative mx-4 w-full max-w-2xl rounded-lg border p-5 shadow-xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
borderColor: 'var(--color-border-emphasis)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Select WSL Claude Root
|
||||
</h3>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Detected WSL distributions and Claude root candidates
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{wslCandidates.map((candidate) => (
|
||||
<div
|
||||
key={`${candidate.distro}:${candidate.path}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
style={{ borderColor: 'var(--color-border)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
{candidate.distro}
|
||||
</p>
|
||||
<p
|
||||
className="truncate font-mono text-[11px]"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
{candidate.path}
|
||||
</p>
|
||||
{!candidate.hasProjectsDir && (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
No projects directory detected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void applyWslCandidate(candidate)}
|
||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
Use This Path
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowWslModal(false)}
|
||||
className="rounded-md border px-3 py-1.5 text-xs transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowWslModal(false);
|
||||
void handleSelectClaudeRootFolder();
|
||||
}}
|
||||
className="rounded-md px-3 py-1.5 text-xs transition-colors"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
Select Folder Manually
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isElectron ? (
|
||||
<>
|
||||
<SettingsSectionHeader title="Browser Access" />
|
||||
<SettingRow
|
||||
label="Enable server mode"
|
||||
description="Start an HTTP server to access the UI from a browser or embed in iframes"
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
{serverLoading ? (
|
||||
<Loader2
|
||||
className="size-5 animate-spin"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
/>
|
||||
) : (
|
||||
<SettingsToggle
|
||||
enabled={serverStatus.running}
|
||||
onChange={handleServerToggle}
|
||||
disabled={saving}
|
||||
/>
|
||||
)}
|
||||
</SettingRow>
|
||||
|
||||
{serverStatus.running && (
|
||||
<div
|
||||
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: '#22c55e' }}
|
||||
/>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Running on
|
||||
</span>
|
||||
<code
|
||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{serverUrl}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SettingsSectionHeader title="Server" />
|
||||
<div
|
||||
className="mb-2 flex items-center gap-3 rounded-md px-3 py-2.5"
|
||||
style={{ backgroundColor: 'var(--color-surface-raised)' }}
|
||||
>
|
||||
<div className="size-2 shrink-0 rounded-full" style={{ backgroundColor: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Running on
|
||||
</span>
|
||||
<code
|
||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}
|
||||
>
|
||||
{window.location.origin}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(window.location.origin);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
className="ml-auto flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-white/5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
color: copied ? '#22c55e' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? 'Copied' : 'Copy URL'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||
Running in standalone mode. The HTTP server is always active. System notifications are
|
||||
not available — notification triggers are logged in-app only.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/**
|
||||
* DateGroupedSessions - Sessions organized by date categories with virtual scrolling.
|
||||
* Uses @tanstack/react-virtual for efficient DOM rendering with infinite scroll.
|
||||
* Supports multi-select with bulk actions and hidden session filtering.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import {
|
||||
|
|
@ -12,7 +14,17 @@ import {
|
|||
separatePinnedSessions,
|
||||
} from '@renderer/utils/dateGrouping';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Calendar, Loader2, MessageSquareOff, Pin } from 'lucide-react';
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Calendar,
|
||||
CheckSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
MessageSquareOff,
|
||||
Pin,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { SessionItem } from './SessionItem';
|
||||
|
|
@ -24,7 +36,7 @@ import type { DateCategory } from '@renderer/types/tabs';
|
|||
type VirtualItem =
|
||||
| { type: 'header'; category: DateCategory; id: string }
|
||||
| { type: 'pinned-header'; id: string }
|
||||
| { type: 'session'; session: Session; isPinned: boolean; id: string }
|
||||
| { type: 'session'; session: Session; isPinned: boolean; isHidden: boolean; id: string }
|
||||
| { type: 'loader'; id: string };
|
||||
|
||||
/**
|
||||
|
|
@ -47,9 +59,21 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
sessionsError,
|
||||
sessionsHasMore,
|
||||
sessionsLoadingMore,
|
||||
sessionsTotalCount,
|
||||
fetchSessionsMore,
|
||||
pinnedSessionIds,
|
||||
sessionSortMode,
|
||||
setSessionSortMode,
|
||||
hiddenSessionIds,
|
||||
showHiddenSessions,
|
||||
toggleShowHiddenSessions,
|
||||
sidebarSelectedSessionIds,
|
||||
sidebarMultiSelectActive,
|
||||
toggleSidebarSessionSelection,
|
||||
clearSidebarSelection,
|
||||
toggleSidebarMultiSelect,
|
||||
hideMultipleSessions,
|
||||
unhideMultipleSessions,
|
||||
pinMultipleSessions,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
sessions: s.sessions,
|
||||
|
|
@ -59,18 +83,41 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
sessionsError: s.sessionsError,
|
||||
sessionsHasMore: s.sessionsHasMore,
|
||||
sessionsLoadingMore: s.sessionsLoadingMore,
|
||||
sessionsTotalCount: s.sessionsTotalCount,
|
||||
fetchSessionsMore: s.fetchSessionsMore,
|
||||
pinnedSessionIds: s.pinnedSessionIds,
|
||||
sessionSortMode: s.sessionSortMode,
|
||||
setSessionSortMode: s.setSessionSortMode,
|
||||
hiddenSessionIds: s.hiddenSessionIds,
|
||||
showHiddenSessions: s.showHiddenSessions,
|
||||
toggleShowHiddenSessions: s.toggleShowHiddenSessions,
|
||||
sidebarSelectedSessionIds: s.sidebarSelectedSessionIds,
|
||||
sidebarMultiSelectActive: s.sidebarMultiSelectActive,
|
||||
toggleSidebarSessionSelection: s.toggleSidebarSessionSelection,
|
||||
clearSidebarSelection: s.clearSidebarSelection,
|
||||
toggleSidebarMultiSelect: s.toggleSidebarMultiSelect,
|
||||
hideMultipleSessions: s.hideMultipleSessions,
|
||||
unhideMultipleSessions: s.unhideMultipleSessions,
|
||||
pinMultipleSessions: s.pinMultipleSessions,
|
||||
}))
|
||||
);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const countRef = useRef<HTMLSpanElement>(null);
|
||||
const [showCountTooltip, setShowCountTooltip] = useState(false);
|
||||
|
||||
const hiddenSet = useMemo(() => new Set(hiddenSessionIds), [hiddenSessionIds]);
|
||||
const hasHiddenSessions = hiddenSessionIds.length > 0;
|
||||
|
||||
// Filter out hidden sessions unless showHiddenSessions is on
|
||||
const visibleSessions = useMemo(() => {
|
||||
if (showHiddenSessions) return sessions;
|
||||
return sessions.filter((s) => !hiddenSet.has(s.id));
|
||||
}, [sessions, hiddenSet, showHiddenSessions]);
|
||||
|
||||
// Separate pinned sessions from unpinned
|
||||
const { pinned: pinnedSessions, unpinned: unpinnedSessions } = useMemo(
|
||||
() => separatePinnedSessions(sessions, pinnedSessionIds),
|
||||
[sessions, pinnedSessionIds]
|
||||
() => separatePinnedSessions(visibleSessions, pinnedSessionIds),
|
||||
[visibleSessions, pinnedSessionIds]
|
||||
);
|
||||
|
||||
// Group only unpinned sessions by date
|
||||
|
|
@ -82,43 +129,64 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
[groupedSessions]
|
||||
);
|
||||
|
||||
// Sessions sorted by context consumption (for most-context sort mode)
|
||||
const contextSortedSessions = useMemo(() => {
|
||||
if (sessionSortMode !== 'most-context') return [];
|
||||
return [...visibleSessions].sort(
|
||||
(a, b) => (b.contextConsumption ?? 0) - (a.contextConsumption ?? 0)
|
||||
);
|
||||
}, [visibleSessions, sessionSortMode]);
|
||||
|
||||
// Flatten sessions with date headers into virtual list items
|
||||
const virtualItems = useMemo((): VirtualItem[] => {
|
||||
const items: VirtualItem[] = [];
|
||||
|
||||
// Add pinned section first
|
||||
if (pinnedSessions.length > 0) {
|
||||
items.push({
|
||||
type: 'pinned-header',
|
||||
id: 'header-pinned',
|
||||
});
|
||||
|
||||
for (const session of pinnedSessions) {
|
||||
if (sessionSortMode === 'most-context') {
|
||||
// Flat list sorted by consumption - no date headers, no pinned section
|
||||
for (const session of contextSortedSessions) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: true,
|
||||
isPinned: pinnedSessionIds.includes(session.id),
|
||||
isHidden: hiddenSet.has(session.id),
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of nonEmptyCategories) {
|
||||
// Add header item
|
||||
items.push({
|
||||
type: 'header',
|
||||
category,
|
||||
id: `header-${category}`,
|
||||
});
|
||||
|
||||
// Add session items
|
||||
for (const session of groupedSessions[category]) {
|
||||
} else {
|
||||
// Default: date-grouped view with pinned section
|
||||
if (pinnedSessions.length > 0) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: false,
|
||||
id: `session-${session.id}`,
|
||||
type: 'pinned-header',
|
||||
id: 'header-pinned',
|
||||
});
|
||||
|
||||
for (const session of pinnedSessions) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: true,
|
||||
isHidden: hiddenSet.has(session.id),
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const category of nonEmptyCategories) {
|
||||
items.push({
|
||||
type: 'header',
|
||||
category,
|
||||
id: `header-${category}`,
|
||||
});
|
||||
|
||||
for (const session of groupedSessions[category]) {
|
||||
items.push({
|
||||
type: 'session',
|
||||
session,
|
||||
isPinned: false,
|
||||
isHidden: hiddenSet.has(session.id),
|
||||
id: `session-${session.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +199,16 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
}
|
||||
|
||||
return items;
|
||||
}, [pinnedSessions, nonEmptyCategories, groupedSessions, sessionsHasMore]);
|
||||
}, [
|
||||
sessionSortMode,
|
||||
contextSortedSessions,
|
||||
pinnedSessionIds,
|
||||
hiddenSet,
|
||||
pinnedSessions,
|
||||
nonEmptyCategories,
|
||||
groupedSessions,
|
||||
sessionsHasMore,
|
||||
]);
|
||||
|
||||
// Estimate item size based on type
|
||||
const estimateSize = useCallback(
|
||||
|
|
@ -192,6 +269,32 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
fetchSessionsMore,
|
||||
]);
|
||||
|
||||
// Bulk action helpers
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(sidebarSelectedSessionIds),
|
||||
[sidebarSelectedSessionIds]
|
||||
);
|
||||
const someSelectedAreHidden = useMemo(
|
||||
() => sidebarSelectedSessionIds.some((id) => hiddenSet.has(id)),
|
||||
[sidebarSelectedSessionIds, hiddenSet]
|
||||
);
|
||||
|
||||
const handleBulkHide = useCallback(() => {
|
||||
void hideMultipleSessions(sidebarSelectedSessionIds);
|
||||
clearSidebarSelection();
|
||||
}, [hideMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
|
||||
|
||||
const handleBulkUnhide = useCallback(() => {
|
||||
const hiddenSelected = sidebarSelectedSessionIds.filter((id) => hiddenSet.has(id));
|
||||
void unhideMultipleSessions(hiddenSelected);
|
||||
clearSidebarSelection();
|
||||
}, [unhideMultipleSessions, sidebarSelectedSessionIds, hiddenSet, clearSidebarSelection]);
|
||||
|
||||
const handleBulkPin = useCallback(() => {
|
||||
void pinMultipleSessions(sidebarSelectedSessionIds);
|
||||
clearSidebarSelection();
|
||||
}, [pinMultipleSessions, sidebarSelectedSessionIds, clearSidebarSelection]);
|
||||
|
||||
if (!selectedProjectId) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
|
|
@ -273,14 +376,136 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
className="text-xs uppercase tracking-wider"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
Sessions
|
||||
{sessionSortMode === 'most-context' ? 'By Context' : 'Sessions'}
|
||||
</h2>
|
||||
<span className="text-xs" style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive */}
|
||||
<span
|
||||
ref={countRef}
|
||||
className="text-xs"
|
||||
style={{ color: 'var(--color-text-muted)', opacity: 0.6 }}
|
||||
onMouseEnter={() => setShowCountTooltip(true)}
|
||||
onMouseLeave={() => setShowCountTooltip(false)}
|
||||
>
|
||||
({sessions.length}
|
||||
{sessionsTotalCount > sessions.length ? ` of ${sessionsTotalCount}` : ''})
|
||||
{sessionsHasMore ? '+' : ''})
|
||||
</span>
|
||||
{showCountTooltip &&
|
||||
sessionsHasMore &&
|
||||
countRef.current &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 w-48 rounded-md px-2.5 py-1.5 text-[11px] leading-snug shadow-lg"
|
||||
style={{
|
||||
top: countRef.current.getBoundingClientRect().bottom + 6,
|
||||
left:
|
||||
countRef.current.getBoundingClientRect().left +
|
||||
countRef.current.getBoundingClientRect().width / 2 -
|
||||
96,
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
border: '1px solid var(--color-border-emphasis)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{sessions.length} loaded so far — scroll down to load more. Context sorting only ranks
|
||||
loaded sessions.
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{/* Multi-select toggle */}
|
||||
<button
|
||||
onClick={toggleSidebarMultiSelect}
|
||||
className="rounded p-1 transition-colors hover:bg-white/5"
|
||||
title={sidebarMultiSelectActive ? 'Exit selection mode' : 'Select sessions'}
|
||||
style={{
|
||||
color: sidebarMultiSelectActive ? '#818cf8' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<CheckSquare className="size-3.5" />
|
||||
</button>
|
||||
{/* Show hidden sessions toggle - only when hidden sessions exist */}
|
||||
{hasHiddenSessions && (
|
||||
<button
|
||||
onClick={toggleShowHiddenSessions}
|
||||
className="rounded p-1 transition-colors hover:bg-white/5"
|
||||
title={showHiddenSessions ? 'Hide hidden sessions' : 'Show hidden sessions'}
|
||||
style={{
|
||||
color: showHiddenSessions ? '#818cf8' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
{showHiddenSessions ? <Eye className="size-3.5" /> : <EyeOff className="size-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Sort mode toggle */}
|
||||
<button
|
||||
onClick={() =>
|
||||
setSessionSortMode(sessionSortMode === 'recent' ? 'most-context' : 'recent')
|
||||
}
|
||||
className="rounded p-1 transition-colors hover:bg-white/5"
|
||||
title={sessionSortMode === 'recent' ? 'Sort by context consumption' : 'Sort by recent'}
|
||||
style={{
|
||||
color: sessionSortMode === 'most-context' ? '#818cf8' : 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<ArrowDownWideNarrow className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar - shown when sessions are selected */}
|
||||
{sidebarMultiSelectActive && sidebarSelectedSessionIds.length > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-b px-3 py-1.5"
|
||||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-medium"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
{sidebarSelectedSessionIds.length} selected
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleBulkPin}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title="Pin selected sessions"
|
||||
>
|
||||
<Pin className="inline-block size-3" /> Pin
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkHide}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title="Hide selected sessions"
|
||||
>
|
||||
<EyeOff className="inline-block size-3" /> Hide
|
||||
</button>
|
||||
{showHiddenSessions && someSelectedAreHidden && (
|
||||
<button
|
||||
onClick={handleBulkUnhide}
|
||||
className="rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title="Unhide selected sessions"
|
||||
>
|
||||
<Eye className="inline-block size-3" /> Unhide
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={clearSidebarSelection}
|
||||
className="rounded p-0.5 transition-colors hover:bg-white/5"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
title="Cancel selection"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -349,6 +574,10 @@ export const DateGroupedSessions = (): React.JSX.Element => {
|
|||
session={item.session}
|
||||
isActive={selectedSessionId === item.session.id}
|
||||
isPinned={item.isPinned}
|
||||
isHidden={item.isHidden}
|
||||
multiSelectActive={sidebarMultiSelectActive}
|
||||
isSelected={selectedSet.has(item.session.id)}
|
||||
onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { MAX_PANES } from '@renderer/types/panes';
|
||||
import { Pin, PinOff } from 'lucide-react';
|
||||
import { Eye, EyeOff, Pin, PinOff } from 'lucide-react';
|
||||
|
||||
interface SessionContextMenuProps {
|
||||
x: number;
|
||||
|
|
@ -17,11 +17,13 @@ interface SessionContextMenuProps {
|
|||
sessionLabel: string;
|
||||
paneCount: number;
|
||||
isPinned: boolean;
|
||||
isHidden: boolean;
|
||||
onClose: () => void;
|
||||
onOpenInCurrentPane: () => void;
|
||||
onOpenInNewTab: () => void;
|
||||
onSplitRightAndOpen: () => void;
|
||||
onTogglePin: () => void;
|
||||
onToggleHide: () => void;
|
||||
}
|
||||
|
||||
export const SessionContextMenu = ({
|
||||
|
|
@ -29,11 +31,13 @@ export const SessionContextMenu = ({
|
|||
y,
|
||||
paneCount,
|
||||
isPinned,
|
||||
isHidden,
|
||||
onClose,
|
||||
onOpenInCurrentPane,
|
||||
onOpenInNewTab,
|
||||
onSplitRightAndOpen,
|
||||
onTogglePin,
|
||||
onToggleHide,
|
||||
}: SessionContextMenuProps): React.JSX.Element => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -55,7 +59,7 @@ export const SessionContextMenu = ({
|
|||
}, [onClose]);
|
||||
|
||||
const menuWidth = 240;
|
||||
const menuHeight = 180;
|
||||
const menuHeight = 204;
|
||||
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
|
||||
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
|
||||
|
||||
|
|
@ -92,6 +96,11 @@ export const SessionContextMenu = ({
|
|||
icon={isPinned ? <PinOff className="size-4" /> : <Pin className="size-4" />}
|
||||
onClick={handleClick(onTogglePin)}
|
||||
/>
|
||||
<MenuItem
|
||||
label={isHidden ? 'Unhide Session' : 'Hide Session'}
|
||||
icon={isHidden ? <Eye className="size-4" /> : <EyeOff className="size-4" />}
|
||||
onClick={handleClick(onToggleHide)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,24 +4,29 @@
|
|||
* Supports right-click context menu for pane management.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { MessageSquare, Pin } from 'lucide-react';
|
||||
import { EyeOff, MessageSquare, Pin } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
import { OngoingIndicator } from '../common/OngoingIndicator';
|
||||
|
||||
import { SessionContextMenu } from './SessionContextMenu';
|
||||
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { PhaseTokenBreakdown, Session } from '@renderer/types/data';
|
||||
|
||||
interface SessionItemProps {
|
||||
session: Session;
|
||||
isActive?: boolean;
|
||||
isPinned?: boolean;
|
||||
isHidden?: boolean;
|
||||
multiSelectActive?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -46,28 +51,125 @@ function formatShortTime(date: Date): string {
|
|||
.replace(' year', 'y');
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumption badge with hover popover showing phase breakdown.
|
||||
*/
|
||||
const ConsumptionBadge = ({
|
||||
contextConsumption,
|
||||
phaseBreakdown,
|
||||
}: Readonly<{
|
||||
contextConsumption: number;
|
||||
phaseBreakdown?: PhaseTokenBreakdown[];
|
||||
}>): React.JSX.Element => {
|
||||
const [popoverPosition, setPopoverPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||
const isHigh = contextConsumption > 150_000;
|
||||
|
||||
const showPopover = popoverPosition !== null;
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- tooltip trigger via hover, not interactive
|
||||
<span
|
||||
ref={badgeRef}
|
||||
className="tabular-nums"
|
||||
style={{ color: isHigh ? 'rgb(251, 191, 36)' : undefined }}
|
||||
onMouseEnter={() => {
|
||||
const rect = badgeRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
setPopoverPosition({
|
||||
top: rect.top - 6,
|
||||
left: rect.left + rect.width / 2,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setPopoverPosition(null)}
|
||||
>
|
||||
{formatTokensCompact(contextConsumption)}
|
||||
{showPopover &&
|
||||
popoverPosition &&
|
||||
phaseBreakdown &&
|
||||
phaseBreakdown.length > 0 &&
|
||||
createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-full whitespace-nowrap rounded-lg px-3 py-2 text-[10px] shadow-xl"
|
||||
style={{
|
||||
top: popoverPosition.top,
|
||||
left: popoverPosition.left,
|
||||
backgroundColor: 'var(--color-surface-overlay)',
|
||||
border: '1px solid var(--color-border-emphasis)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<div className="mb-1 font-medium" style={{ color: 'var(--color-text)' }}>
|
||||
Total Context: {formatTokensCompact(contextConsumption)} tokens
|
||||
</div>
|
||||
{phaseBreakdown.length === 1 ? (
|
||||
<div>Context: {formatTokensCompact(phaseBreakdown[0].peakTokens)}</div>
|
||||
) : (
|
||||
phaseBreakdown.map((phase) => (
|
||||
<div key={phase.phaseNumber} className="flex items-center gap-1">
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||
Phase {phase.phaseNumber}:
|
||||
</span>
|
||||
<span className="tabular-nums">{formatTokensCompact(phase.contribution)}</span>
|
||||
{phase.postCompaction != null && (
|
||||
<span style={{ color: 'var(--color-text-muted)' }}>
|
||||
(compacted to {formatTokensCompact(phase.postCompaction)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const SessionItem = ({
|
||||
session,
|
||||
isActive,
|
||||
isPinned,
|
||||
isHidden,
|
||||
multiSelectActive,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
}: Readonly<SessionItemProps>): React.JSX.Element => {
|
||||
const { openTab, activeProjectId, selectSession, paneCount, splitPane, togglePinSession } =
|
||||
useStore(
|
||||
useShallow((s) => ({
|
||||
openTab: s.openTab,
|
||||
activeProjectId: s.activeProjectId,
|
||||
selectSession: s.selectSession,
|
||||
paneCount: s.paneLayout.panes.length,
|
||||
splitPane: s.splitPane,
|
||||
togglePinSession: s.togglePinSession,
|
||||
}))
|
||||
);
|
||||
const {
|
||||
openTab,
|
||||
activeProjectId,
|
||||
selectSession,
|
||||
paneCount,
|
||||
splitPane,
|
||||
togglePinSession,
|
||||
toggleHideSession,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
openTab: s.openTab,
|
||||
activeProjectId: s.activeProjectId,
|
||||
selectSession: s.selectSession,
|
||||
paneCount: s.paneLayout.panes.length,
|
||||
splitPane: s.splitPane,
|
||||
togglePinSession: s.togglePinSession,
|
||||
toggleHideSession: s.toggleHideSession,
|
||||
}))
|
||||
);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleClick = (event: React.MouseEvent): void => {
|
||||
if (!activeProjectId) return;
|
||||
|
||||
// In multi-select mode, clicks toggle selection
|
||||
if (multiSelectActive && onToggleSelect) {
|
||||
onToggleSelect();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+click: open in new tab; plain click: replace current tab
|
||||
const forceNewTab = event.ctrlKey || event.metaKey;
|
||||
|
||||
|
|
@ -148,12 +250,23 @@ export const SessionItem = ({
|
|||
style={{
|
||||
borderColor: 'var(--color-border)',
|
||||
...(isActive ? { backgroundColor: 'var(--color-surface-raised)' } : {}),
|
||||
...(isHidden ? { opacity: 0.5 } : {}),
|
||||
}}
|
||||
>
|
||||
{/* First line: title + ongoing indicator + pin icon */}
|
||||
{/* First line: title + ongoing indicator + pin/hidden icons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{multiSelectActive && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected ?? false}
|
||||
onChange={() => onToggleSelect?.()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-blue-500"
|
||||
/>
|
||||
)}
|
||||
{session.isOngoing && <OngoingIndicator />}
|
||||
{isPinned && <Pin className="size-2.5 shrink-0 text-blue-400" />}
|
||||
{isHidden && <EyeOff className="size-2.5 shrink-0 text-zinc-500" />}
|
||||
<span
|
||||
className="truncate text-[13px] font-medium leading-tight"
|
||||
style={{ color: isActive ? 'var(--color-text)' : 'var(--color-text-muted)' }}
|
||||
|
|
@ -162,7 +275,7 @@ export const SessionItem = ({
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Second line: message count + time */}
|
||||
{/* Second line: message count + time + context consumption */}
|
||||
<div
|
||||
className="mt-0.5 flex items-center gap-2 text-[10px] leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
|
|
@ -173,6 +286,15 @@ export const SessionItem = ({
|
|||
</span>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span className="tabular-nums">{formatShortTime(new Date(session.createdAt))}</span>
|
||||
{session.contextConsumption != null && session.contextConsumption > 0 && (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<ConsumptionBadge
|
||||
contextConsumption={session.contextConsumption}
|
||||
phaseBreakdown={session.phaseBreakdown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
|
@ -187,11 +309,13 @@ export const SessionItem = ({
|
|||
sessionLabel={sessionLabel}
|
||||
paneCount={paneCount}
|
||||
isPinned={isPinned ?? false}
|
||||
isHidden={isHidden ?? false}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onOpenInCurrentPane={handleOpenInCurrentPane}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onSplitRightAndOpen={handleSplitRightAndOpen}
|
||||
onTogglePin={() => void togglePinSession(session.id)}
|
||||
onToggleHide={() => void toggleHideSession(session.id)}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -221,7 +221,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
(eventProjectBaseId == null || selectedProjectBaseId === eventProjectBaseId);
|
||||
const isTopLevelSessionEvent = !event.isSubagent;
|
||||
const isUnknownSessionInSidebar =
|
||||
event.sessionId == null || !state.sessions.some((session) => session.id === event.sessionId);
|
||||
event.sessionId == null ||
|
||||
!state.sessions.some((session) => session.id === event.sessionId);
|
||||
const shouldRefreshForPotentialNewSession =
|
||||
isTopLevelSessionEvent &&
|
||||
matchesSelectedProject &&
|
||||
|
|
|
|||
|
|
@ -20,11 +20,13 @@ export interface ConfigSlice {
|
|||
appConfig: AppConfig | null;
|
||||
configLoading: boolean;
|
||||
configError: string | null;
|
||||
pendingSettingsSection: string | null;
|
||||
|
||||
// Actions
|
||||
fetchConfig: () => Promise<void>;
|
||||
updateConfig: (section: string, data: Record<string, unknown>) => Promise<void>;
|
||||
openSettingsTab: () => void;
|
||||
openSettingsTab: (section?: string) => void;
|
||||
clearPendingSettingsSection: () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -36,6 +38,7 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
appConfig: null,
|
||||
configLoading: false,
|
||||
configError: null,
|
||||
pendingSettingsSection: null,
|
||||
|
||||
// Fetch app configuration from main process
|
||||
fetchConfig: async () => {
|
||||
|
|
@ -70,9 +73,13 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
},
|
||||
|
||||
// Open or focus the settings tab (per-pane singleton)
|
||||
openSettingsTab: () => {
|
||||
openSettingsTab: (section?: string) => {
|
||||
const state = get();
|
||||
|
||||
if (section) {
|
||||
set({ pendingSettingsSection: section });
|
||||
}
|
||||
|
||||
// Check if settings tab exists in focused pane
|
||||
const focusedPane = state.paneLayout.panes.find((p) => p.id === state.paneLayout.focusedPaneId);
|
||||
const settingsTab = focusedPane?.tabs.find((t) => t.type === 'settings');
|
||||
|
|
@ -87,4 +94,8 @@ export const createConfigSlice: StateCreator<AppState, [], [], ConfigSlice> = (s
|
|||
label: 'Settings',
|
||||
});
|
||||
},
|
||||
|
||||
clearPendingSettingsSection: () => {
|
||||
set({ pendingSettingsSection: null });
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { api } from '@renderer/api';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import type { AppState } from '../types';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { Session, SessionSortMode } from '@renderer/types/data';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
const logger = createLogger('Store:session');
|
||||
|
|
@ -34,6 +34,14 @@ export interface SessionSlice {
|
|||
sessionsLoadingMore: boolean;
|
||||
// Pinned sessions
|
||||
pinnedSessionIds: string[];
|
||||
// Hidden sessions
|
||||
hiddenSessionIds: string[];
|
||||
showHiddenSessions: boolean;
|
||||
// Multi-select
|
||||
sidebarSelectedSessionIds: string[];
|
||||
sidebarMultiSelectActive: boolean;
|
||||
// Sort mode
|
||||
sessionSortMode: SessionSortMode;
|
||||
|
||||
// Actions
|
||||
fetchSessions: (projectId: string) => Promise<void>;
|
||||
|
|
@ -48,6 +56,26 @@ export interface SessionSlice {
|
|||
togglePinSession: (sessionId: string) => Promise<void>;
|
||||
/** Load pinned sessions from config for current project */
|
||||
loadPinnedSessions: () => Promise<void>;
|
||||
/** Set session sort mode */
|
||||
setSessionSortMode: (mode: SessionSortMode) => void;
|
||||
/** Toggle hide/unhide for a session */
|
||||
toggleHideSession: (sessionId: string) => Promise<void>;
|
||||
/** Bulk hide sessions */
|
||||
hideMultipleSessions: (sessionIds: string[]) => Promise<void>;
|
||||
/** Bulk unhide sessions */
|
||||
unhideMultipleSessions: (sessionIds: string[]) => Promise<void>;
|
||||
/** Load hidden sessions from config for current project */
|
||||
loadHiddenSessions: () => Promise<void>;
|
||||
/** Toggle showing hidden sessions in sidebar */
|
||||
toggleShowHiddenSessions: () => void;
|
||||
/** Toggle one session's checkbox in sidebar multi-select */
|
||||
toggleSidebarSessionSelection: (sessionId: string) => void;
|
||||
/** Clear all selections and exit multi-select mode */
|
||||
clearSidebarSelection: () => void;
|
||||
/** Enter/exit selection mode */
|
||||
toggleSidebarMultiSelect: () => void;
|
||||
/** Bulk pin for multi-select */
|
||||
pinMultipleSessions: (sessionIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -67,6 +95,14 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
sessionsLoadingMore: false,
|
||||
// Pinned sessions
|
||||
pinnedSessionIds: [],
|
||||
// Hidden sessions
|
||||
hiddenSessionIds: [],
|
||||
showHiddenSessions: false,
|
||||
// Multi-select
|
||||
sidebarSelectedSessionIds: [],
|
||||
sidebarMultiSelectActive: false,
|
||||
// Sort mode
|
||||
sessionSortMode: 'recent' as SessionSortMode,
|
||||
|
||||
// Fetch sessions for a specific project (legacy - not paginated)
|
||||
fetchSessions: async (projectId: string) => {
|
||||
|
|
@ -109,8 +145,9 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
sessionsLoading: false,
|
||||
});
|
||||
|
||||
// Load pinned sessions after fetching session list
|
||||
// Load pinned and hidden sessions after fetching session list
|
||||
void get().loadPinnedSessions();
|
||||
void get().loadHiddenSessions();
|
||||
} catch (error) {
|
||||
set({
|
||||
sessionsError: error instanceof Error ? error.message : 'Failed to fetch sessions',
|
||||
|
|
@ -317,4 +354,155 @@ export const createSessionSlice: StateCreator<AppState, [], [], SessionSlice> =
|
|||
set({ pinnedSessionIds: [] });
|
||||
}
|
||||
},
|
||||
|
||||
// Set session sort mode
|
||||
setSessionSortMode: (mode: SessionSortMode) => {
|
||||
set({ sessionSortMode: mode });
|
||||
},
|
||||
|
||||
// Toggle hide/unhide for a session (optimistic update)
|
||||
toggleHideSession: async (sessionId: string) => {
|
||||
const state = get();
|
||||
const projectId = state.selectedProjectId;
|
||||
if (!projectId) return;
|
||||
|
||||
const isHidden = state.hiddenSessionIds.includes(sessionId);
|
||||
const previousHiddenIds = state.hiddenSessionIds;
|
||||
|
||||
// Optimistic: update UI immediately
|
||||
if (isHidden) {
|
||||
set({ hiddenSessionIds: previousHiddenIds.filter((id) => id !== sessionId) });
|
||||
} else {
|
||||
set({ hiddenSessionIds: [sessionId, ...previousHiddenIds] });
|
||||
}
|
||||
|
||||
try {
|
||||
if (isHidden) {
|
||||
await api.config.unhideSession(projectId, sessionId);
|
||||
} else {
|
||||
await api.config.hideSession(projectId, sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Rollback on failure
|
||||
set({ hiddenSessionIds: previousHiddenIds });
|
||||
logger.error('toggleHideSession error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Bulk hide sessions
|
||||
hideMultipleSessions: async (sessionIds: string[]) => {
|
||||
const state = get();
|
||||
const projectId = state.selectedProjectId;
|
||||
if (!projectId || sessionIds.length === 0) return;
|
||||
|
||||
const previousHiddenIds = state.hiddenSessionIds;
|
||||
const existingSet = new Set(previousHiddenIds);
|
||||
const newIds = sessionIds.filter((id) => !existingSet.has(id));
|
||||
|
||||
// Optimistic update
|
||||
set({ hiddenSessionIds: [...newIds, ...previousHiddenIds] });
|
||||
|
||||
try {
|
||||
await api.config.hideSessions(projectId, sessionIds);
|
||||
} catch (error) {
|
||||
set({ hiddenSessionIds: previousHiddenIds });
|
||||
logger.error('hideMultipleSessions error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Bulk unhide sessions
|
||||
unhideMultipleSessions: async (sessionIds: string[]) => {
|
||||
const state = get();
|
||||
const projectId = state.selectedProjectId;
|
||||
if (!projectId || sessionIds.length === 0) return;
|
||||
|
||||
const previousHiddenIds = state.hiddenSessionIds;
|
||||
const toRemove = new Set(sessionIds);
|
||||
|
||||
// Optimistic update
|
||||
set({ hiddenSessionIds: previousHiddenIds.filter((id) => !toRemove.has(id)) });
|
||||
|
||||
try {
|
||||
await api.config.unhideSessions(projectId, sessionIds);
|
||||
} catch (error) {
|
||||
set({ hiddenSessionIds: previousHiddenIds });
|
||||
logger.error('unhideMultipleSessions error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Load hidden sessions from config for current project
|
||||
loadHiddenSessions: async () => {
|
||||
const state = get();
|
||||
const projectId = state.selectedProjectId;
|
||||
if (!projectId) {
|
||||
set({ hiddenSessionIds: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await api.config.get();
|
||||
const hidden = config.sessions?.hiddenSessions?.[projectId] ?? [];
|
||||
const hiddenIds = hidden.map((h) => h.sessionId);
|
||||
set({ hiddenSessionIds: hiddenIds });
|
||||
} catch (error) {
|
||||
logger.error('loadHiddenSessions error:', error);
|
||||
set({ hiddenSessionIds: [] });
|
||||
}
|
||||
},
|
||||
|
||||
// Toggle showing hidden sessions in sidebar
|
||||
toggleShowHiddenSessions: () => {
|
||||
set((prev) => ({ showHiddenSessions: !prev.showHiddenSessions }));
|
||||
},
|
||||
|
||||
// Toggle one session's checkbox in sidebar multi-select
|
||||
toggleSidebarSessionSelection: (sessionId: string) => {
|
||||
set((prev) => {
|
||||
const selected = prev.sidebarSelectedSessionIds;
|
||||
if (selected.includes(sessionId)) {
|
||||
return { sidebarSelectedSessionIds: selected.filter((id) => id !== sessionId) };
|
||||
}
|
||||
return {
|
||||
sidebarSelectedSessionIds: [...selected, sessionId],
|
||||
sidebarMultiSelectActive: true,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Clear all selections and exit multi-select mode
|
||||
clearSidebarSelection: () => {
|
||||
set({ sidebarSelectedSessionIds: [], sidebarMultiSelectActive: false });
|
||||
},
|
||||
|
||||
// Enter/exit selection mode
|
||||
toggleSidebarMultiSelect: () => {
|
||||
set((prev) => {
|
||||
if (prev.sidebarMultiSelectActive) {
|
||||
return { sidebarMultiSelectActive: false, sidebarSelectedSessionIds: [] };
|
||||
}
|
||||
return { sidebarMultiSelectActive: true };
|
||||
});
|
||||
},
|
||||
|
||||
// Bulk pin for multi-select
|
||||
pinMultipleSessions: async (sessionIds: string[]) => {
|
||||
const state = get();
|
||||
const projectId = state.selectedProjectId;
|
||||
if (!projectId || sessionIds.length === 0) return;
|
||||
|
||||
const previousPinnedIds = state.pinnedSessionIds;
|
||||
const existingSet = new Set(previousPinnedIds);
|
||||
const newIds = sessionIds.filter((id) => !existingSet.has(id));
|
||||
|
||||
// Optimistic update
|
||||
set({ pinnedSessionIds: [...newIds, ...previousPinnedIds] });
|
||||
|
||||
try {
|
||||
// Pin each session individually (no bulk pin IPC)
|
||||
await Promise.all(newIds.map((sessionId) => api.config.pinSession(projectId, sessionId)));
|
||||
} catch (error) {
|
||||
set({ pinnedSessionIds: previousPinnedIds });
|
||||
logger.error('pinMultipleSessions error:', error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ export interface ToolTokenBreakdown {
|
|||
tokenCount: number;
|
||||
/** Whether the tool execution resulted in an error */
|
||||
isError: boolean;
|
||||
/** Tool use ID for deep-link navigation to specific tool in chat */
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
// Domain types
|
||||
export type {
|
||||
PhaseTokenBreakdown,
|
||||
Project,
|
||||
RepositoryGroup,
|
||||
SearchResult,
|
||||
|
|
@ -68,6 +69,13 @@ export type {
|
|||
TriggerToolName,
|
||||
} from './notifications';
|
||||
|
||||
// =============================================================================
|
||||
// Session Sort Mode
|
||||
// =============================================================================
|
||||
|
||||
/** Sort mode for session list in sidebar */
|
||||
export type SessionSortMode = 'recent' | 'most-context';
|
||||
|
||||
// =============================================================================
|
||||
// Renderer-Specific Type Guards
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -253,7 +253,15 @@ export type AIGroupDisplayItem =
|
|||
| { type: 'subagent'; subagent: Process }
|
||||
| { type: 'output'; content: string; timestamp: Date; tokenCount?: number }
|
||||
| { type: 'slash'; slash: SlashItem }
|
||||
| { type: 'teammate_message'; teammateMessage: TeammateMessage };
|
||||
| { type: 'teammate_message'; teammateMessage: TeammateMessage }
|
||||
| { type: 'subagent_input'; content: string; timestamp: Date; tokenCount?: number }
|
||||
| {
|
||||
type: 'compact_boundary';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
tokenDelta?: CompactionTokenDelta;
|
||||
phaseNumber: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The last output in an AI Group - what user sees as "the answer".
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { estimateTokens } from '@shared/utils/tokenFormatting';
|
||||
|
||||
import type { Process } from '../types/data';
|
||||
import type { ParsedMessage, PhaseTokenBreakdown, Process } from '../types/data';
|
||||
import type { LinkedToolItem } from '../types/groups';
|
||||
|
||||
const logger = createLogger('Util:aiGroupHelpers');
|
||||
|
|
@ -98,3 +98,111 @@ export function attachMainSessionImpact(
|
|||
}
|
||||
return subagents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes multi-phase context breakdown for a subagent session.
|
||||
* Mirrors the algorithm in src/main/utils/jsonl.ts:500-576.
|
||||
*
|
||||
* Tracks assistant input tokens across compaction events to compute
|
||||
* per-phase contribution and total consumption across all phases.
|
||||
*
|
||||
* @param messages - Subagent's ParsedMessages
|
||||
* @returns Phase breakdown with total consumption, or null if no usage data
|
||||
*/
|
||||
export function computeSubagentPhaseBreakdown(messages: ParsedMessage[]): {
|
||||
phases: PhaseTokenBreakdown[];
|
||||
totalConsumption: number;
|
||||
compactionCount: number;
|
||||
} | null {
|
||||
let lastMainAssistantInputTokens = 0;
|
||||
let awaitingPostCompaction = false;
|
||||
const compactionPhases: { pre: number; post: number }[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Track assistant input tokens.
|
||||
// Unlike jsonl.ts, we don't filter by isSidechain here because subagent messages
|
||||
// all have isSidechain=true (from the parent session's perspective).
|
||||
if (msg.type === 'assistant' && msg.model !== '<synthetic>') {
|
||||
const inputTokens =
|
||||
(msg.usage?.input_tokens ?? 0) +
|
||||
(msg.usage?.cache_read_input_tokens ?? 0) +
|
||||
(msg.usage?.cache_creation_input_tokens ?? 0);
|
||||
if (inputTokens > 0) {
|
||||
if (awaitingPostCompaction && compactionPhases.length > 0) {
|
||||
compactionPhases[compactionPhases.length - 1].post = inputTokens;
|
||||
awaitingPostCompaction = false;
|
||||
}
|
||||
lastMainAssistantInputTokens = inputTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect compaction events
|
||||
if (msg.isCompactSummary) {
|
||||
compactionPhases.push({ pre: lastMainAssistantInputTokens, post: 0 });
|
||||
awaitingPostCompaction = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMainAssistantInputTokens <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let phaseBreakdown: PhaseTokenBreakdown[];
|
||||
|
||||
if (compactionPhases.length === 0) {
|
||||
// No compaction: single phase
|
||||
phaseBreakdown = [
|
||||
{
|
||||
phaseNumber: 1,
|
||||
contribution: lastMainAssistantInputTokens,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
},
|
||||
];
|
||||
return {
|
||||
phases: phaseBreakdown,
|
||||
totalConsumption: lastMainAssistantInputTokens,
|
||||
compactionCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
phaseBreakdown = [];
|
||||
let total = 0;
|
||||
|
||||
// Phase 1: tokens up to first compaction
|
||||
const phase1Contribution = compactionPhases[0].pre;
|
||||
total += phase1Contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: 1,
|
||||
contribution: phase1Contribution,
|
||||
peakTokens: compactionPhases[0].pre,
|
||||
postCompaction: compactionPhases[0].post,
|
||||
});
|
||||
|
||||
// Middle phases: contribution = pre[i] - post[i-1]
|
||||
for (let i = 1; i < compactionPhases.length; i++) {
|
||||
const contribution = compactionPhases[i].pre - compactionPhases[i - 1].post;
|
||||
total += contribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: i + 1,
|
||||
contribution,
|
||||
peakTokens: compactionPhases[i].pre,
|
||||
postCompaction: compactionPhases[i].post,
|
||||
});
|
||||
}
|
||||
|
||||
// Last phase: final tokens - last post-compaction
|
||||
const lastPhase = compactionPhases[compactionPhases.length - 1];
|
||||
const lastContribution = lastMainAssistantInputTokens - lastPhase.post;
|
||||
total += lastContribution;
|
||||
phaseBreakdown.push({
|
||||
phaseNumber: compactionPhases.length + 1,
|
||||
contribution: lastContribution,
|
||||
peakTokens: lastMainAssistantInputTokens,
|
||||
});
|
||||
|
||||
return {
|
||||
phases: phaseBreakdown,
|
||||
totalConsumption: total,
|
||||
compactionCount: compactionPhases.length,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ function aggregateToolOutputs(
|
|||
toolName: displayName,
|
||||
tokenCount: toolTokenCount,
|
||||
isError: linkedTool.result?.isError ?? false,
|
||||
toolUseId: linkedTool.id,
|
||||
});
|
||||
totalTokens += toolTokenCount;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ function getDisplayItemTimestamp(item: AIGroupDisplayItem): Date {
|
|||
return toDate(item.slash.timestamp);
|
||||
case 'teammate_message':
|
||||
return toDate(item.teammateMessage.timestamp);
|
||||
case 'subagent_input':
|
||||
case 'compact_boundary':
|
||||
return toDate(item.timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,10 +323,76 @@ export function buildDisplayItemsFromMessages(
|
|||
subagents.map((s) => s.parentTaskId).filter((id): id is string => !!id)
|
||||
);
|
||||
|
||||
// Track compaction events for compact_boundary display items
|
||||
let compactionCount = 0;
|
||||
|
||||
// Helper to get the last assistant's total input tokens before a given index
|
||||
// Note: don't filter by isSidechain — subagent messages all have isSidechain=true
|
||||
function getLastAssistantInputTokens(idx: number): number {
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
|
||||
return (
|
||||
(m.usage.input_tokens ?? 0) +
|
||||
(m.usage.cache_read_input_tokens ?? 0) +
|
||||
(m.usage.cache_creation_input_tokens ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Helper to get the first assistant's total input tokens after a given index
|
||||
function getFirstAssistantInputTokens(idx: number): number {
|
||||
for (let i = idx + 1; i < messages.length; i++) {
|
||||
const m = messages[i];
|
||||
if (m.type === 'assistant' && m.usage && m.model !== '<synthetic>') {
|
||||
return (
|
||||
(m.usage.input_tokens ?? 0) +
|
||||
(m.usage.cache_read_input_tokens ?? 0) +
|
||||
(m.usage.cache_creation_input_tokens ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// First pass: collect tool calls and tool results from messages
|
||||
for (const msg of messages) {
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
||||
const msg = messages[messageIndex];
|
||||
const msgTimestamp = toDate(msg.timestamp);
|
||||
|
||||
// Detect compact boundary (before regular user message handling)
|
||||
if (msg.isCompactSummary) {
|
||||
const preTokens = getLastAssistantInputTokens(messageIndex);
|
||||
const postTokens = getFirstAssistantInputTokens(messageIndex);
|
||||
const rawText =
|
||||
typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: Array.isArray(msg.content)
|
||||
? msg.content
|
||||
.filter((b: { type: string; text?: string }) => b.type === 'text')
|
||||
.map((b: { type: string; text?: string }) => b.text ?? '')
|
||||
.join('\n\n')
|
||||
: '';
|
||||
displayItems.push({
|
||||
type: 'compact_boundary',
|
||||
content: rawText,
|
||||
timestamp: msgTimestamp,
|
||||
tokenDelta:
|
||||
preTokens > 0
|
||||
? {
|
||||
preCompactionTokens: preTokens,
|
||||
postCompactionTokens: postTokens,
|
||||
delta: postTokens - preTokens,
|
||||
}
|
||||
: undefined,
|
||||
phaseNumber: compactionCount + 2,
|
||||
});
|
||||
compactionCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for teammate messages (non-meta user messages with <teammate-message> content)
|
||||
// One user message may contain multiple <teammate-message> blocks
|
||||
if (msg.type === 'user' && !msg.isMeta) {
|
||||
|
|
@ -354,6 +423,16 @@ export function buildDisplayItemsFromMessages(
|
|||
}
|
||||
continue;
|
||||
}
|
||||
// Plain-text user message (subagent input prompt)
|
||||
if (rawText.trim()) {
|
||||
displayItems.push({
|
||||
type: 'subagent_input',
|
||||
content: rawText.trim(),
|
||||
timestamp: msgTimestamp,
|
||||
tokenCount: estimateTokens(rawText),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
|
|||
subagent: 0,
|
||||
slash: 0,
|
||||
teammate_message: 0,
|
||||
subagent_input: 0,
|
||||
compact_boundary: 0,
|
||||
};
|
||||
const teammateNames = new Set<string>();
|
||||
|
||||
|
|
@ -62,6 +64,11 @@ export function buildSummary(items: AIGroupDisplayItem[]): string {
|
|||
`${counts.teammate_message} teammate ${counts.teammate_message === 1 ? 'message' : 'messages'}`
|
||||
);
|
||||
}
|
||||
if (counts.compact_boundary > 0) {
|
||||
parts.push(
|
||||
`${counts.compact_boundary} ${counts.compact_boundary === 1 ? 'compaction' : 'compactions'}`
|
||||
);
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : 'No items';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,14 @@ export interface ConfigAPI {
|
|||
pinSession: (projectId: string, sessionId: string) => Promise<void>;
|
||||
/** Unpin a session for a project */
|
||||
unpinSession: (projectId: string, sessionId: string) => Promise<void>;
|
||||
/** Hide a session for a project */
|
||||
hideSession: (projectId: string, sessionId: string) => Promise<void>;
|
||||
/** Unhide a session for a project */
|
||||
unhideSession: (projectId: string, sessionId: string) => Promise<void>;
|
||||
/** Bulk hide sessions for a project */
|
||||
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
||||
/** Bulk unhide sessions for a project */
|
||||
unhideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ClaudeRootInfo {
|
||||
|
|
|
|||
|
|
@ -276,6 +276,8 @@ export interface AppConfig {
|
|||
sessions: {
|
||||
/** Pinned sessions per project. Key is projectId, value is array of pinned sessions */
|
||||
pinnedSessions: Record<string, { sessionId: string; pinnedAt: number }[]>;
|
||||
/** Hidden sessions per project. Key is projectId, value is array of hidden sessions */
|
||||
hiddenSessions: Record<string, { sessionId: string; hiddenAt: number }[]>;
|
||||
};
|
||||
/** SSH connection settings */
|
||||
ssh?: {
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ describe('jsonl', () => {
|
|||
|
||||
expect(result.firstUserMessage?.text).toBe('hello world');
|
||||
expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:00.000Z');
|
||||
expect(result.messageCount).toBe(1);
|
||||
expect(result.messageCount).toBe(2);
|
||||
expect(result.isOngoing).toBe(true);
|
||||
expect(result.gitBranch).toBe('feature/test');
|
||||
} finally {
|
||||
|
|
|
|||
115
vite.standalone.config.ts
Normal file
115
vite.standalone.config.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Vite build config for the standalone (non-Electron) server.
|
||||
*
|
||||
* Produces a single CJS bundle at dist-standalone/index.cjs that can be
|
||||
* run with `node dist-standalone/index.cjs`.
|
||||
*/
|
||||
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
// Node.js built-in modules that should be externalized
|
||||
const nodeBuiltins = new Set([
|
||||
'fs', 'path', 'os', 'events', 'stream', 'util', 'net', 'tls',
|
||||
'http', 'https', 'crypto', 'zlib', 'url', 'querystring',
|
||||
'child_process', 'buffer', 'dns', 'dgram', 'assert', 'constants',
|
||||
'readline', 'string_decoder', 'timers', 'tty', 'worker_threads'
|
||||
])
|
||||
|
||||
// Packages that must be externalized because they break when bundled
|
||||
// (fastify ecosystem uses internal file resolution that doesn't survive bundling)
|
||||
const externalPackages = [
|
||||
'fastify', '@fastify/cors', '@fastify/static'
|
||||
]
|
||||
|
||||
// Stub native .node addons (ssh2/cpu-features have JS fallbacks)
|
||||
function nativeModuleStub(): Plugin {
|
||||
const STUB_ID = '\0native-stub'
|
||||
return {
|
||||
name: 'native-module-stub',
|
||||
resolveId(source) {
|
||||
if (source.endsWith('.node')) return STUB_ID
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
if (id === STUB_ID) return 'export default {}'
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stub out Electron imports with empty modules
|
||||
const electronModules = new Set(['electron', 'electron-updater'])
|
||||
|
||||
function electronStub(): Plugin {
|
||||
const ELECTRON_STUB_ID = '\0electron-stub'
|
||||
// Comprehensive stub covering all electron exports used in the codebase
|
||||
const electronStubCode = `
|
||||
const noop = () => {};
|
||||
const noopClass = class {};
|
||||
const handler = { get: () => noop };
|
||||
const proxyObj = new Proxy({}, handler);
|
||||
export const app = proxyObj;
|
||||
export const BrowserWindow = noopClass;
|
||||
export const ipcMain = { handle: noop, on: noop, removeHandler: noop };
|
||||
export const shell = { openPath: noop, openExternal: noop };
|
||||
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
|
||||
export const Notification = class { show() {} };
|
||||
export default proxyObj;
|
||||
`
|
||||
return {
|
||||
name: 'electron-stub',
|
||||
// Use enforce: 'pre' to intercept before Vite's SSR externalization
|
||||
enforce: 'pre',
|
||||
resolveId(source) {
|
||||
if (electronModules.has(source)) return ELECTRON_STUB_ID
|
||||
return null
|
||||
},
|
||||
load(id) {
|
||||
if (id === ELECTRON_STUB_ID) return electronStubCode
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [nativeModuleStub(), electronStub()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@main': resolve(__dirname, 'src/main'),
|
||||
'@shared': resolve(__dirname, 'src/shared'),
|
||||
'@preload': resolve(__dirname, 'src/preload')
|
||||
}
|
||||
},
|
||||
ssr: {
|
||||
// Force Vite to bundle these instead of externalizing them
|
||||
// (SSR mode externalizes all node_modules by default)
|
||||
noExternal: true
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-standalone',
|
||||
target: 'node20',
|
||||
ssr: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/standalone.ts')
|
||||
},
|
||||
output: {
|
||||
format: 'cjs',
|
||||
entryFileNames: '[name].cjs'
|
||||
},
|
||||
external: (id) => {
|
||||
// Externalize Node.js built-ins
|
||||
if (id.startsWith('node:')) return true
|
||||
if (nodeBuiltins.has(id)) return true
|
||||
// Externalize packages that break when bundled
|
||||
if (externalPackages.some(pkg => id === pkg || id.startsWith(pkg + '/'))) return true
|
||||
return false
|
||||
}
|
||||
},
|
||||
minify: false,
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue