Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46110937b | ||
|
|
28aa5da16d | ||
|
|
03237f6f79 | ||
|
|
492c6064f9 | ||
|
|
fa8eacde53 | ||
|
|
742c2d2c29 | ||
|
|
eb279cf251 | ||
|
|
6658c0b15a | ||
|
|
12044988d6 | ||
|
|
c4e76aaac4 | ||
|
|
2b6597ad00 | ||
|
|
cce5d1aba8 | ||
|
|
04db4fcf94 | ||
|
|
cb161e57a4 | ||
|
|
b92fbd93a8 | ||
|
|
1a0ccac634 | ||
|
|
cd9d7c2a39 | ||
|
|
941052acc8 | ||
|
|
5f67a01864 | ||
|
|
b80e332021 | ||
|
|
df625e0fe7 | ||
|
|
cd2bd3c636 | ||
|
|
6e7003c57c | ||
|
|
adee1e0383 | ||
|
|
efe7af6f77 | ||
|
|
6fa41d51be | ||
|
|
8431b9f8a2 | ||
|
|
541027c93e | ||
|
|
9f2edbb9db | ||
|
|
eced9b8124 | ||
|
|
68b6793bf3 | ||
|
|
d3b194c306 | ||
|
|
467cbf4b28 | ||
|
|
756f3d68cb | ||
|
|
7354f08abe | ||
|
|
db5bd9984e | ||
|
|
6fdd4947f9 | ||
|
|
b438702092 | ||
|
|
5faa06601a |
42
.github/workflows/release.yml
vendored
42
.github/workflows/release.yml
vendored
@@ -66,6 +66,8 @@ jobs:
|
||||
build-macos:
|
||||
needs: prepare-release
|
||||
runs-on: macos-13
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -87,11 +89,25 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.dmg|*.zip)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
build-windows:
|
||||
needs: prepare-release
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -109,16 +125,22 @@ jobs:
|
||||
run: npm run build:win
|
||||
|
||||
- name: Upload release assets
|
||||
shell: bash
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
Get-ChildItem -Path "release" -File | Where-Object {
|
||||
$_.Name -match '\.(exe|zip)$'
|
||||
} | ForEach-Object {
|
||||
gh release upload $env:TAG $_.FullName --clobber
|
||||
}
|
||||
|
||||
build-linux:
|
||||
needs: prepare-release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -140,4 +162,16 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.prepare-release.outputs.tag }}
|
||||
run: |
|
||||
gh release upload "$TAG" release/* --clobber
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
for file in release/*; do
|
||||
[ -f "$file" ] || continue
|
||||
case "$file" in
|
||||
*.AppImage|*.deb|*.tar.gz)
|
||||
gh release upload "$TAG" "$file" --clobber
|
||||
;;
|
||||
*)
|
||||
echo "Skipping non-installer asset: $file"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# AGENT NOTES
|
||||
|
||||
## Styling Guidelines
|
||||
- Reuse the existing token & utility layers before introducing new CSS variables or custom properties. Extend `src/styles/tokens.css` / `src/styles/utilities.css` if a shared pattern is needed.
|
||||
- Keep aggregate entry files (e.g., `src/styles/controls.css`, `messaging.css`, `panels.css`) lean—they should only `@import` feature-specific subfiles located inside `src/styles/{components|messaging|panels}`.
|
||||
- When adding new component styles, place them beside their peers in the scoped subdirectory (e.g., `src/styles/messaging/new-part.css`) and import them from the corresponding aggregator file.
|
||||
- Prefer smaller, focused style files (≈150 lines or less) over large monoliths. Split by component or feature area if a file grows beyond that size.
|
||||
- Co-locate reusable UI patterns (buttons, selectors, dropdowns, etc.) under `src/styles/components/` and avoid redefining the same utility classes elsewhere.
|
||||
- Document any new styling conventions or directory additions in this file so future changes remain consistent.
|
||||
|
||||
## Coding Principles
|
||||
- Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need.
|
||||
- Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts.
|
||||
- Enforce single responsibility; split large files when concerns diverge (state, actions, API, events, etc.).
|
||||
- Prefer composable primitives (signals, hooks, utilities) over deep inheritance or implicit global state.
|
||||
- When adding platform integrations (SSE, IPC, SDK), isolate them in thin adapters that surface typed events/actions.
|
||||
226
README.md
226
README.md
@@ -1,219 +1,35 @@
|
||||
# CodeNomad
|
||||
## A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
|
||||
|
||||
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
|
||||
## What is CodeNomad?
|
||||
|
||||
## Overview
|
||||
CodeNomad is built for people who live inside OpenCode for hours on end and need a cockpit, not a kiosk. When terminals get unwieldy and web clients feel laggy, CodeNomad delivers a desktop-native workspace that favors speed, clarity, and direct control. It runs on macOS, Windows, and Linux using Electron + SolidJS, with prebuilt binaries so you can get started immediately.
|
||||
|
||||
CodeNomad provides a multi-instance, multi-session interface for working with AI-powered coding assistants. It manages OpenCode server processes, handles real-time message streaming, and provides an intuitive UI for coding with AI.
|
||||

|
||||
|
||||
**🎯 MVP Focus:** This project prioritizes functionality over performance. Performance optimization is intentionally deferred to post-MVP phases. See [docs/MVP-PRINCIPLES.md](docs/MVP-PRINCIPLES.md) for details.
|
||||

|
||||
|
||||
## Features
|
||||
## Highlights
|
||||
|
||||
### Core Capabilities
|
||||
- **Long-session native** – scroll through massive transcripts without hitches and keep full context visible.
|
||||
- **Multiple instances, one window** – juggle several OpenCode instances side-by-side with per-instance tabs.
|
||||
- **Deep task awareness** – jump into sub/child sessions (Tasks tool) instantly, monitor their status, and answer directly without losing your flow.
|
||||
- **Keyboard first** – the full UI is optimized for shortcuts so you can stay mouse-free when you want to.
|
||||
- **Command palette superpowers** – summon a single, global palette to jump tabs, launch tools, tweak preferences, or fire shortcuts. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It keeps your workflow predictable and fast whether you are juggling one session or ten.
|
||||
- **Developer-friendly rendering** – syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
|
||||
|
||||
- **Multi-Instance Management**: Work on multiple projects simultaneously
|
||||
- **Session Persistence**: Resume conversations across app restarts
|
||||
- **Real-time Streaming**: Live message updates via Server-Sent Events
|
||||
- **Tool Execution Visibility**: See bash commands, file edits, and other tool calls
|
||||
- **Agent & Model Switching**: Easily switch between different AI agents and models
|
||||
- **Markdown Rendering**: Beautiful code highlighting and formatting
|
||||
## Requirements
|
||||
|
||||
### Advanced Features (Planned)
|
||||
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
|
||||
|
||||
- Virtual scrolling for large conversations
|
||||
- Full-text search across sessions
|
||||
- Workspace management
|
||||
- Custom themes
|
||||
- Plugin system
|
||||
## Downloads
|
||||
|
||||
## Architecture
|
||||
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
|
||||
## Quick Start
|
||||
|
||||
### High-Level Overview
|
||||
1. Install the OpenCode CLI and confirm it is reachable via your terminal.
|
||||
2. Download the CodeNomad build for your platform and launch the app.
|
||||
3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session.
|
||||
4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping.
|
||||
|
||||
```
|
||||
Electron App
|
||||
├── Main Process (Node.js)
|
||||
│ ├── Window management
|
||||
│ ├── OpenCode server spawning
|
||||
│ └── IPC communication
|
||||
├── Renderer Process (SolidJS)
|
||||
│ ├── UI components
|
||||
│ ├── State management (stores)
|
||||
│ └── SDK client communication
|
||||
└── Multiple OpenCode Servers
|
||||
└── One per instance/project folder
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and in PATH
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Run in development mode
|
||||
bun run dev
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Build distributable binaries
|
||||
bun run build:mac # macOS (Universal)
|
||||
bun run build:win # Windows (x64)
|
||||
bun run build:linux # Linux (x64)
|
||||
bun run build:all # All platforms
|
||||
|
||||
# See BUILD.md for more build options
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
packages/opencode-client/
|
||||
├── docs/ # Documentation
|
||||
├── tasks/ # Task management
|
||||
│ ├── todo/ # Pending tasks
|
||||
│ └── done/ # Completed tasks
|
||||
├── electron/ # Electron main process
|
||||
│ ├── main/ # Main process code
|
||||
│ ├── preload/ # Preload scripts
|
||||
│ └── resources/ # App icons, etc.
|
||||
└── src/ # Renderer (UI) code
|
||||
├── components/ # UI components
|
||||
├── stores/ # State management
|
||||
├── lib/ # Utilities
|
||||
├── hooks/ # SolidJS hooks
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Electron** - Desktop wrapper
|
||||
- **SolidJS** - Reactive UI framework
|
||||
- **TypeScript** - Type safety
|
||||
- **Vite** - Build tool
|
||||
- **TailwindCSS** - Styling
|
||||
- **Kobalte** - Accessible UI primitives
|
||||
- **OpenCode SDK** - API client
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with hot reload
|
||||
bun run build # Build for production
|
||||
bun run typecheck # Run TypeScript type checking
|
||||
bun run preview # Preview production build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Starting an Instance
|
||||
|
||||
1. Launch the app
|
||||
2. Click "Select Folder" or press Cmd/Ctrl+N
|
||||
3. Choose a project folder
|
||||
4. Wait for OpenCode server to start
|
||||
5. Select an existing session or create new one
|
||||
|
||||
### Working with Sessions
|
||||
|
||||
- **Switch sessions**: Click session tab at bottom
|
||||
- **Create session**: Click "+" button or Cmd/Ctrl+T
|
||||
- **Change agent**: Use agent dropdown
|
||||
- **Change model**: Use model dropdown
|
||||
|
||||
### Sending Messages
|
||||
|
||||
- Type in the input box at bottom
|
||||
- Press Enter for new line (Cmd+Enter on macOS, Ctrl+Enter on Windows/Linux)
|
||||
- Use `/` for commands
|
||||
- Use `@` to mention files
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](docs/architecture.md) - System design and structure
|
||||
- [User Interface](docs/user-interface.md) - UI specifications
|
||||
- [Technical Implementation](docs/technical-implementation.md) - Implementation details
|
||||
- [Build Roadmap](docs/build-roadmap.md) - Development plan and phases
|
||||
- [Tasks](tasks/README.md) - Task breakdown and tracking
|
||||
|
||||
## Build Phases
|
||||
|
||||
The project is built in phases:
|
||||
|
||||
1. **Phase 1**: Foundation (Tasks 001-005)
|
||||
2. **Phase 2**: Core Chat (Tasks 006-010)
|
||||
3. **Phase 3**: Essential Features (Tasks 011-015)
|
||||
4. **Phase 4**: Multi-Instance (Tasks 016-020)
|
||||
5. **Phase 5**: Advanced Input (Tasks 021-025)
|
||||
6. **Phase 6**: Polish & UX (Tasks 026-030)
|
||||
7. **Phase 7**: System Integration (Tasks 031-035)
|
||||
8. **Phase 8**: Advanced Features (Tasks 036-040)
|
||||
|
||||
See [docs/build-roadmap.md](docs/build-roadmap.md) for detailed phase information.
|
||||
|
||||
## Contributing
|
||||
|
||||
### Getting Started
|
||||
|
||||
1. Read the documentation in `docs/`
|
||||
2. Check `tasks/todo/` for available tasks
|
||||
3. Pick a task and create a feature branch
|
||||
4. Follow the task steps
|
||||
5. Submit PR when complete
|
||||
|
||||
### Code Style
|
||||
|
||||
- Use TypeScript for all code
|
||||
- Follow existing patterns and conventions
|
||||
- Write clear, descriptive commit messages
|
||||
- Add comments for complex logic
|
||||
- Keep components small and focused
|
||||
|
||||
### Testing
|
||||
|
||||
- Test manually at minimum window size (800x600)
|
||||
- Test on multiple platforms (macOS, Windows, Linux)
|
||||
- Verify keyboard navigation works
|
||||
- Check accessibility with screen readers
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Server Won't Start
|
||||
|
||||
- Verify `opencode` is in PATH: `which opencode`
|
||||
- Check folder permissions
|
||||
- Review server logs in Logs tab
|
||||
- Try restarting the instance
|
||||
|
||||
### Connection Issues
|
||||
|
||||
- Check if server is running: `ps aux | grep opencode`
|
||||
- Verify port is correct in instance metadata
|
||||
- Check for firewall blocking localhost
|
||||
- Try killing and restarting server
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- Check number of messages in session
|
||||
- Monitor memory usage in Activity Monitor
|
||||
- Consider enabling virtual scrolling (Phase 8)
|
||||
- Close unused instances
|
||||
|
||||
## License
|
||||
|
||||
[License TBD]
|
||||
|
||||
## Credits
|
||||
|
||||
Built with ❤️ for the OpenCode project.
|
||||
|
||||
BIN
docs/screenshots/command-palette.png
Normal file
BIN
docs/screenshots/command-palette.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 835 KiB |
BIN
docs/screenshots/newSession.png
Normal file
BIN
docs/screenshots/newSession.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
@@ -1,7 +1,7 @@
|
||||
import { spawn, ChildProcess } from "child_process"
|
||||
import { spawn, execSync, ChildProcess } from "child_process"
|
||||
import { app, BrowserWindow } from "electron"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { execSync } from "child_process"
|
||||
import { buildUserShellCommand, getUserShellEnv, runUserShellCommandSync, supportsUserShell } from "./user-shell"
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
@@ -57,17 +57,13 @@ class ProcessManager {
|
||||
environmentVariables?: Record<string, string>,
|
||||
): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const actualBinaryPath =
|
||||
binaryPath && binaryPath !== "opencode" ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
|
||||
const useUserShell = supportsUserShell()
|
||||
const logAttempt = (message: string) => {
|
||||
console.info(`[ProcessManager] ${message}`)
|
||||
this.sendLog(instanceId, "debug", message)
|
||||
}
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${binaryPath || "opencode"} (${actualBinaryPath})...`,
|
||||
)
|
||||
|
||||
// Merge environment variables with process environment
|
||||
const env = { ...process.env }
|
||||
const env = useUserShell ? getUserShellEnv() : { ...process.env }
|
||||
if (environmentVariables) {
|
||||
Object.assign(env, environmentVariables)
|
||||
this.sendLog(
|
||||
@@ -82,14 +78,35 @@ class ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
let targetBinary: string
|
||||
if (!binaryPath || binaryPath === "opencode") {
|
||||
targetBinary = useUserShell ? "opencode" : this.validateOpenCodeBinary(logAttempt)
|
||||
} else {
|
||||
targetBinary = this.validateCustomBinary(binaryPath, logAttempt)
|
||||
}
|
||||
|
||||
const spawnCommand = useUserShell
|
||||
? this.buildShellServeCommand(targetBinary)
|
||||
: { command: targetBinary, args: this.buildServeArgs() }
|
||||
|
||||
const launchDetail = `${spawnCommand.command} ${spawnCommand.args.join(" ")}`.trim()
|
||||
this.sendLog(instanceId, "debug", `Launching process with: ${launchDetail}`)
|
||||
|
||||
this.sendLog(
|
||||
instanceId,
|
||||
"info",
|
||||
`Starting OpenCode server for ${folder} using ${targetBinary}...`,
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
|
||||
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
shell: false,
|
||||
})
|
||||
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL")
|
||||
this.sendLog(instanceId, "error", "Server startup timeout (10s exceeded)")
|
||||
@@ -129,7 +146,7 @@ class ProcessManager {
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
|
||||
resolve({ pid: child.pid!, port, binaryPath: targetBinary })
|
||||
}
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
@@ -236,20 +253,45 @@ class ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private validateOpenCodeBinary(): string {
|
||||
const command = process.platform === "win32" ? "where opencode" : "which opencode"
|
||||
private validateOpenCodeBinary(logAttempt?: (message: string) => void): string {
|
||||
const log = logAttempt ?? ((message: string) => console.info(`[ProcessManager] ${message}`))
|
||||
|
||||
if (process.platform === "win32") {
|
||||
log("Checking PATH via 'where opencode'")
|
||||
return this.resolveBinaryViaLocator("where opencode", log)
|
||||
}
|
||||
|
||||
const shellCheck = buildUserShellCommand("command -v opencode")
|
||||
const shellPreview = [shellCheck.command, ...shellCheck.args].join(" ")
|
||||
log(`Checking PATH via shell: ${shellPreview}`)
|
||||
|
||||
try {
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
const paths = output.trim().split("\n")
|
||||
return paths[0].trim()
|
||||
} catch {
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
const resolved = runUserShellCommandSync("command -v opencode")
|
||||
const path = this.pickFirstPath(resolved)
|
||||
if (path) {
|
||||
log(`Shell located opencode at ${path}`)
|
||||
return path
|
||||
}
|
||||
throw new Error("Empty result from shell lookup")
|
||||
} catch (shellError) {
|
||||
const message = shellError instanceof Error ? shellError.message : String(shellError)
|
||||
log(`Shell lookup failed: ${message}`)
|
||||
try {
|
||||
log("Fallback to 'which opencode'")
|
||||
return this.resolveBinaryViaLocator("which opencode", log)
|
||||
} catch (locatorError) {
|
||||
const locatorMessage = locatorError instanceof Error ? locatorError.message : String(locatorError)
|
||||
log(`Locator fallback failed: ${locatorMessage}`)
|
||||
throw new Error(
|
||||
"opencode binary not found in PATH. Please install OpenCode CLI first: npm install -g @opencode/cli",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string): string {
|
||||
private validateCustomBinary(binaryPath: string, log?: (message: string) => void): string {
|
||||
log?.(`Validating custom binary at ${binaryPath}`)
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
||||
}
|
||||
@@ -270,6 +312,36 @@ class ProcessManager {
|
||||
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
private resolveBinaryViaLocator(command: string, log?: (message: string) => void): string {
|
||||
log?.(`Running locator command: ${command}`)
|
||||
const output = execSync(command, { stdio: "pipe", encoding: "utf-8" })
|
||||
log?.(`Locator output: ${output.trim() || "<empty>"}`)
|
||||
const path = this.pickFirstPath(output)
|
||||
if (!path) {
|
||||
throw new Error("opencode binary not found in PATH")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private pickFirstPath(output: string): string | null {
|
||||
const line = output
|
||||
.split("\n")
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry.length > 0)
|
||||
return line ?? null
|
||||
}
|
||||
|
||||
private buildServeArgs(): string[] {
|
||||
return ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"]
|
||||
}
|
||||
|
||||
private buildShellServeCommand(binaryPath: string): { command: string; args: string[] } {
|
||||
const args = this.buildServeArgs()
|
||||
.map((arg) => JSON.stringify(arg))
|
||||
.join(" ")
|
||||
return buildUserShellCommand(`exec ${JSON.stringify(binaryPath)} ${args}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from "path"
|
||||
import { readFile, writeFile, mkdir, unlink, stat } from "fs/promises"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "opencode-client")
|
||||
const CONFIG_DIR = join(app.getPath("home"), ".config", "codenomad")
|
||||
const CONFIG_FILE = join(CONFIG_DIR, "config.json")
|
||||
const INSTANCES_DIR = join(CONFIG_DIR, "instances")
|
||||
|
||||
|
||||
139
electron/main/user-shell.ts
Normal file
139
electron/main/user-shell.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { spawn, spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
interface ShellCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
|
||||
function getDefaultShellPath(): string {
|
||||
if (process.env.SHELL && process.env.SHELL.trim().length > 0) {
|
||||
return process.env.SHELL
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
return "/bin/zsh"
|
||||
}
|
||||
|
||||
return "/bin/bash"
|
||||
}
|
||||
|
||||
function wrapCommandForShell(command: string, shellPath: string): string {
|
||||
const shellName = path.basename(shellPath)
|
||||
|
||||
if (shellName.includes("bash")) {
|
||||
return 'if [ -f ~/.bashrc ]; then source ~/.bashrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
if (shellName.includes("zsh")) {
|
||||
return 'if [ -f ~/.zshrc ]; then source ~/.zshrc >/dev/null 2>&1; fi; ' + command
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
function buildShellArgs(shellPath: string): string[] {
|
||||
const shellName = path.basename(shellPath)
|
||||
if (shellName.includes("zsh")) {
|
||||
return ["-l", "-i", "-c"]
|
||||
}
|
||||
return ["-l", "-c"]
|
||||
}
|
||||
|
||||
function sanitizeShellEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const cleaned = { ...env }
|
||||
delete cleaned.npm_config_prefix
|
||||
delete cleaned.NPM_CONFIG_PREFIX
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export function supportsUserShell(): boolean {
|
||||
return !isWindows
|
||||
}
|
||||
|
||||
export function buildUserShellCommand(userCommand: string): ShellCommand {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const shellPath = getDefaultShellPath()
|
||||
const script = wrapCommandForShell(userCommand, shellPath)
|
||||
const args = buildShellArgs(shellPath)
|
||||
|
||||
return {
|
||||
command: shellPath,
|
||||
args: [...args, script],
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserShellEnv(): NodeJS.ProcessEnv {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
return sanitizeShellEnv(process.env)
|
||||
}
|
||||
|
||||
export function runUserShellCommand(userCommand: string, timeoutMs = 5000): Promise<string> {
|
||||
if (!supportsUserShell()) {
|
||||
return Promise.reject(new Error("User shell invocation is only supported on POSIX platforms"))
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGTERM")
|
||||
reject(new Error(`Shell command timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
child.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout)
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim())
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `Shell command exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function runUserShellCommandSync(userCommand: string): string {
|
||||
if (!supportsUserShell()) {
|
||||
throw new Error("User shell invocation is only supported on POSIX platforms")
|
||||
}
|
||||
|
||||
const { command, args } = buildUserShellCommand(userCommand)
|
||||
const env = getUserShellEnv()
|
||||
const result = spawnSync(command, args, { encoding: "utf-8", env })
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = (result.stderr || "").toString().trim()
|
||||
throw new Error(stderr || "Shell command failed")
|
||||
}
|
||||
|
||||
return (result.stdout || "").toString().trim()
|
||||
}
|
||||
79
package-lock.json
generated
79
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "0.1.0",
|
||||
"name": "@shantur/codenomad",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "0.1.0",
|
||||
"name": "@shantur/codenomad",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "0.15.13",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"ignore": "7.0.5",
|
||||
@@ -79,6 +79,7 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1465,9 +1466,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "0.15.13",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-0.15.13.tgz",
|
||||
"integrity": "sha512-SwKCaWTZvGuGm+CLRDp/Ku476SKuMREIKrPHvKqpZM5gfO3HrVct9zk2f+NwjI0MRKCHNMdtOoEcmz/ypc+pWg=="
|
||||
"version": "1.0.68",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.0.68.tgz",
|
||||
"integrity": "sha512-QdpLZw2L/nHdPFGCz8z4du2RvlALgZTFgNeKUM+kJuZTtOWC5t425ELGg5xKIpynD0kj83Euvfn6l6uHs99g3w=="
|
||||
},
|
||||
"node_modules/@pkgjs/parseargs": {
|
||||
"version": "0.11.0",
|
||||
@@ -2289,6 +2290,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -2464,7 +2466,6 @@
|
||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -2484,7 +2485,6 @@
|
||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -2507,7 +2507,6 @@
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -2523,8 +2522,7 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
@@ -2532,7 +2530,6 @@
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -2751,7 +2748,6 @@
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
@@ -2827,6 +2823,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.19",
|
||||
"caniuse-lite": "^1.0.30001751",
|
||||
@@ -3290,7 +3287,6 @@
|
||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -3397,7 +3393,6 @@
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -3411,7 +3406,6 @@
|
||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -3644,6 +3638,7 @@
|
||||
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
@@ -3828,7 +3823,6 @@
|
||||
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -3842,7 +3836,6 @@
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -3858,7 +3851,6 @@
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -3872,7 +3864,6 @@
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -4354,8 +4345,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
@@ -5074,8 +5064,7 @@
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.6",
|
||||
@@ -5137,6 +5126,7 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -5242,7 +5232,6 @@
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -5256,7 +5245,6 @@
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -5272,8 +5260,7 @@
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
@@ -5281,7 +5268,6 @@
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -5318,40 +5304,35 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lowercase-keys": {
|
||||
"version": "2.0.0",
|
||||
@@ -6046,6 +6027,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -6194,8 +6176,7 @@
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
@@ -6344,7 +6325,6 @@
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -6360,7 +6340,6 @@
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -6578,8 +6557,7 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -6645,6 +6623,7 @@
|
||||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
@@ -6772,6 +6751,7 @@
|
||||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
|
||||
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
@@ -6891,7 +6871,6 @@
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -7149,7 +7128,6 @@
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
@@ -7533,6 +7511,7 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -8195,7 +8174,6 @@
|
||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -8211,7 +8189,6 @@
|
||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
|
||||
13
package.json
13
package.json
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"name": "@opencode-ai/client",
|
||||
"version": "0.1.0",
|
||||
"description": "CodeNomad desktop client - multi-instance, multi-session AI coding interface",
|
||||
"author": "OpenCode Team",
|
||||
"name": "@shantur/codenomad",
|
||||
"version": "0.1.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Shantur Rathore",
|
||||
"email": "codenomad@shantur.com"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
@@ -27,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/sdk": "0.15.13",
|
||||
"@opencode-ai/sdk": "1.0.68",
|
||||
"@solidjs/router": "^0.13.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"ignore": "7.0.5",
|
||||
|
||||
@@ -52,6 +52,7 @@ function run(command, args, options = {}) {
|
||||
const spawnOptions = {
|
||||
cwd: appDir,
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
...options,
|
||||
env: { ...process.env, NODE_PATH: nodeModulesPath, ...(options.env || {}) },
|
||||
}
|
||||
@@ -97,7 +98,7 @@ async function build(platform) {
|
||||
throw new Error("dist/ directory not found. Build failed.")
|
||||
}
|
||||
|
||||
await run(npxCmd, ["electron-builder", ...config.args])
|
||||
await run(npxCmd, ["electron-builder", "--publish=never", ...config.args])
|
||||
|
||||
console.log("\n✅ Build complete!")
|
||||
console.log(`📁 Binaries available in: ${join(appDir, "release")}\n`)
|
||||
|
||||
1232
src/App.tsx
1232
src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ interface CommandPaletteProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
commands: Command[]
|
||||
onExecute: (commandId: string) => void
|
||||
onExecute: (command: Command) => void
|
||||
}
|
||||
|
||||
function buildShortcutString(shortcut: Command["shortcut"]): string {
|
||||
@@ -30,7 +30,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
let inputRef: HTMLInputElement | undefined
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
const categoryOrder = ["Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||
const categoryOrder = ["Custom Commands", "Instance", "Session", "Agent & Model", "Input & Focus", "System", "Other"] as const
|
||||
|
||||
type CommandGroup = { category: string; commands: Command[]; startIndex: number }
|
||||
type ProcessedCommands = { groups: CommandGroup[]; ordered: Command[] }
|
||||
@@ -167,13 +167,15 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
e.stopPropagation()
|
||||
const index = selectedIndex()
|
||||
if (index < 0 || index >= ordered.length) return
|
||||
props.onExecute(ordered[index].id)
|
||||
const command = ordered[index]
|
||||
if (!command) return
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommandClick(commandId: string) {
|
||||
props.onExecute(commandId)
|
||||
function handleCommandClick(command: Command) {
|
||||
props.onExecute(command)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
@@ -241,7 +243,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
||||
<button
|
||||
type="button"
|
||||
data-command-index={commandIndex}
|
||||
onClick={() => handleCommandClick(command.id)}
|
||||
onClick={() => handleCommandClick(command)}
|
||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX === 0 && event.movementY === 0) return
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Component, createSignal, For, Show } from "solid-js"
|
||||
import { Plus, Trash2, Key, Globe } from "lucide-solid"
|
||||
import {
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface EnvironmentVariablesEditorProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||
const {
|
||||
preferences,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
updateEnvironmentVariables,
|
||||
} = useConfig()
|
||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||
const [newKey, setNewKey] = createSignal("")
|
||||
const [newValue, setNewValue] = createSignal("")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid"
|
||||
import { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
@@ -9,12 +9,15 @@ const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url
|
||||
interface FolderSelectionViewProps {
|
||||
onSelectFolder: (folder?: string, binaryPath?: string) => void
|
||||
isLoading?: boolean
|
||||
advancedSettingsOpen?: boolean
|
||||
onAdvancedSettingsOpen?: () => void
|
||||
onAdvancedSettingsClose?: () => void
|
||||
}
|
||||
|
||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const { recentFolders, removeRecentFolder, preferences, updateLastUsedBinary } = useConfig()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||
const [isAdvancedModalOpen, setIsAdvancedModalOpen] = createSignal(false)
|
||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -320,7 +323,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => setIsAdvancedModalOpen(true)}
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -369,8 +372,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
open={isAdvancedModalOpen()}
|
||||
onClose={() => setIsAdvancedModalOpen(false)}
|
||||
open={Boolean(props.advancedSettingsOpen)}
|
||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
||||
selectedBinary={selectedBinary()}
|
||||
onBinaryChange={handleBinaryChange}
|
||||
isLoading={props.isLoading}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, Show, For, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import type { Instance, RawMcpStatus } from "../types/instance"
|
||||
import { updateInstance } from "../stores/instances"
|
||||
import { fetchLspStatus, updateInstance } from "../stores/instances"
|
||||
|
||||
interface InstanceInfoProps {
|
||||
instance: Instance
|
||||
@@ -52,6 +52,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
const status = metadata()?.mcpStatus
|
||||
return status ? parseMcpStatus(status) : []
|
||||
}
|
||||
const lspServers = () => metadata()?.lspStatus ?? []
|
||||
|
||||
createEffect(() => {
|
||||
const instance = props.instance
|
||||
@@ -82,9 +83,10 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [projectResult, mcpResult] = await Promise.allSettled([
|
||||
const [projectResult, mcpResult, lspResult] = await Promise.allSettled([
|
||||
client.project.current(),
|
||||
client.mcp.status(),
|
||||
fetchLspStatus(instanceId),
|
||||
])
|
||||
|
||||
if (cancelled) {
|
||||
@@ -93,11 +95,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
|
||||
const project = projectResult.status === "fulfilled" ? projectResult.value.data : undefined
|
||||
const mcpStatus = mcpResult.status === "fulfilled" ? (mcpResult.value.data as RawMcpStatus) : undefined
|
||||
const lspStatus = lspResult.status === "fulfilled" ? lspResult.value ?? [] : undefined
|
||||
|
||||
const nextMetadata = {
|
||||
...(instance.metadata ?? {}),
|
||||
...(project ? { project } : {}),
|
||||
...(mcpStatus ? { mcpStatus } : {}),
|
||||
...(lspStatus ? { lspStatus } : {}),
|
||||
}
|
||||
|
||||
if (!nextMetadata.version) {
|
||||
@@ -213,6 +217,34 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && lspServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
LSP Servers
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<For each={lspServers()}>
|
||||
{(server) => (
|
||||
<div class="px-2 py-1.5 rounded border bg-surface-secondary border-base">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs text-primary font-medium truncate">{server.name ?? server.id}</span>
|
||||
<span class="text-[11px] text-secondary truncate" title={server.root}>
|
||||
{server.root}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 text-xs text-secondary">
|
||||
<div class={`status-dot ${server.status === "connected" ? "ready animate-pulse" : "error"}`} />
|
||||
<span>{server.status === "connected" ? "Connected" : "Error"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoadingMetadata() && mcpServers().length > 0}>
|
||||
<div>
|
||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1.5">
|
||||
|
||||
173
src/components/instance/instance-shell.tsx
Normal file
173
src/components/instance/instance-shell.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Show, createMemo, createSignal, type Component } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Instance } from "../../types/instance"
|
||||
import type { Command } from "../../lib/commands"
|
||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
|
||||
interface InstanceShellProps {
|
||||
instance: Instance
|
||||
escapeInDebounce: boolean
|
||||
paletteCommands: Accessor<Command[]>
|
||||
onCloseSession: (sessionId: string) => Promise<void> | void
|
||||
onNewSession: () => Promise<void> | void
|
||||
handleSidebarAgentChange: (sessionId: string, agent: string) => Promise<void>
|
||||
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
|
||||
onExecuteCommand: (command: Command) => void
|
||||
}
|
||||
|
||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
||||
|
||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const parentId = activeParentSessionId().get(props.instance.id)
|
||||
if (!parentId) return new Map<string, ReturnType<typeof getSessionFamily>[number]>()
|
||||
const sessionFamily = getSessionFamily(props.instance.id, parentId)
|
||||
return new Map(sessionFamily.map((s) => [s.id, s]))
|
||||
})
|
||||
|
||||
const activeSessionIdForInstance = createMemo(() => {
|
||||
return activeSessionMap().get(props.instance.id) || null
|
||||
})
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
return activeSessions().get(sessionId) ?? null
|
||||
})
|
||||
|
||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||
|
||||
const keyboardShortcuts = createMemo(() =>
|
||||
[keyboardRegistry.get("session-prev"), keyboardRegistry.get("session-next")].filter(
|
||||
(shortcut): shortcut is KeyboardShortcut => Boolean(shortcut),
|
||||
),
|
||||
)
|
||||
|
||||
const handleSessionSelect = (sessionId: string) => {
|
||||
setActiveSession(props.instance.id, sessionId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="session-sidebar flex flex-col bg-surface-secondary" style={{ width: `${sessionSidebarWidth()}px` }}>
|
||||
<SessionList
|
||||
instanceId={props.instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={handleSessionSelect}
|
||||
onClose={(id) => {
|
||||
const result = props.onCloseSession(id)
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to close session:", error))
|
||||
}
|
||||
}}
|
||||
onNew={() => {
|
||||
const result = props.onNewSession()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => console.error("Failed to create session:", error))
|
||||
}
|
||||
}}
|
||||
showHeader
|
||||
showFooter={false}
|
||||
headerContent={
|
||||
<div class="session-sidebar-header">
|
||||
<span class="session-sidebar-title text-sm font-semibold uppercase text-primary">Sessions</span>
|
||||
<div class="session-sidebar-shortcuts">
|
||||
{keyboardShortcuts().length ? (
|
||||
<KeyboardHint shortcuts={keyboardShortcuts()} separator=" " showDescription={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={props.instance.id} sessionId={activeSession().id} />
|
||||
<div class="session-sidebar-controls px-3 py-3 border-r border-base flex flex-col gap-3">
|
||||
<AgentSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="content-area flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
<Show
|
||||
when={activeSessionIdForInstance() === "info"}
|
||||
fallback={
|
||||
<Show
|
||||
when={activeSessionIdForInstance()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<p class="mb-2">No session selected</p>
|
||||
<p class="text-sm">Select a session to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(sessionId) => (
|
||||
<SessionView
|
||||
sessionId={sessionId}
|
||||
activeSessions={activeSessions()}
|
||||
instanceId={props.instance.id}
|
||||
instanceFolder={props.instance.folder}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InfoView instanceId={props.instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen()}
|
||||
onClose={() => hideCommandPalette(props.instance.id)}
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={props.onExecuteCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InstanceShell
|
||||
@@ -6,6 +6,8 @@ import MessagePart from "./message-part"
|
||||
interface MessageItemProps {
|
||||
message: Message
|
||||
messageInfo?: MessageInfo
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isQueued?: boolean
|
||||
parts?: ClientPart[]
|
||||
onRevert?: (messageId: string) => void
|
||||
@@ -99,7 +101,6 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||
<Show when={isUser() && props.onRevert}>
|
||||
<button
|
||||
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
|
||||
@@ -120,9 +121,10 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
Fork
|
||||
</button>
|
||||
</Show>
|
||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||
|
||||
<Show when={props.isQueued && isUser()}>
|
||||
@@ -139,7 +141,14 @@ export default function MessageItem(props: MessageItemProps) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<For each={messageParts()}>{(part) => <MessagePart part={part} messageType={props.message.type} />}</For>
|
||||
<For each={messageParts()}>{(part) => (
|
||||
<MessagePart
|
||||
part={part}
|
||||
messageType={props.message.type}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
)}</For>
|
||||
</div>
|
||||
|
||||
<Show when={props.message.status === "sending"}>
|
||||
|
||||
@@ -3,7 +3,7 @@ import ToolCall from "./tool-call"
|
||||
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -11,9 +11,12 @@ type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
interface MessagePartProps {
|
||||
part: ClientPart
|
||||
messageType?: "user" | "assistant"
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
export default function MessagePart(props: MessagePartProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { preferences } = useConfig()
|
||||
const partType = () => props.part?.type || ""
|
||||
const reasoningId = () => `reasoning-${props.part?.id || ""}`
|
||||
const isReasoningExpanded = () => isItemExpanded(reasoningId())
|
||||
@@ -70,7 +73,12 @@ export default function MessagePart(props: MessagePartProps) {
|
||||
</Match>
|
||||
|
||||
<Match when={partType() === "tool"}>
|
||||
<ToolCall toolCall={props.part as ToolCallPart} toolCallId={props.part?.id} />
|
||||
<ToolCall
|
||||
toolCall={props.part as ToolCallPart}
|
||||
toolCallId={props.part?.id}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</Match>
|
||||
|
||||
|
||||
|
||||
@@ -30,12 +30,13 @@ import MessageItem from "./message-item"
|
||||
import ToolCall from "./tool-call"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import Kbd from "./kbd"
|
||||
import { preferences } from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
||||
import { setActiveInstanceId } from "../stores/instances"
|
||||
|
||||
const codeNomadLogo = new URL("../../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
const SCROLL_OFFSET = 64
|
||||
const SCROLL_DIRECTION_THRESHOLD = 10
|
||||
|
||||
interface TaskSessionLocation {
|
||||
sessionId: string
|
||||
@@ -169,6 +170,7 @@ function getSessionCache(instanceId: string, sessionId: string): SessionCache {
|
||||
}
|
||||
|
||||
export default function MessageStream(props: MessageStreamProps) {
|
||||
const { preferences } = useConfig()
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||
@@ -265,13 +267,13 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
if (!containerRef) return
|
||||
|
||||
const currentScrollTop = containerRef.scrollTop
|
||||
const movingUp = currentScrollTop < lastKnownScrollTop - 1
|
||||
const movingUp = currentScrollTop < lastKnownScrollTop - SCROLL_DIRECTION_THRESHOLD
|
||||
lastKnownScrollTop = currentScrollTop
|
||||
|
||||
const atBottom = isNearBottom(containerRef)
|
||||
|
||||
if (isUserScroll) {
|
||||
if ((movingUp || !atBottom) && autoScroll()) {
|
||||
if (movingUp && !atBottom && autoScroll()) {
|
||||
setAutoScroll(false)
|
||||
} else if (!movingUp && atBottom && !autoScroll()) {
|
||||
setAutoScroll(true)
|
||||
@@ -380,7 +382,9 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
|
||||
const toolSignature = createToolSignature(message, toolPart, originalIndex, messageInfo)
|
||||
const contentKey = createToolContentKey(toolPart, messageInfo)
|
||||
tokenSegments.push(`tool:${toolKey}:${partVersion}`)
|
||||
const toolEntry = toolItemCache.get(toolKey)
|
||||
|
||||
if (toolEntry && toolEntry.signature === toolSignature) {
|
||||
if (toolEntry.contentKey !== contentKey) {
|
||||
const updatedItem: ToolDisplayItem = {
|
||||
@@ -611,6 +615,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
<MessageItem
|
||||
message={item.message}
|
||||
messageInfo={item.messageInfo}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isQueued={item.isQueued}
|
||||
parts={item.combinedParts}
|
||||
onRevert={props.onRevert}
|
||||
@@ -661,6 +667,8 @@ export default function MessageStream(props: MessageStreamProps) {
|
||||
messageId={item.messageId}
|
||||
messageVersion={item.messageVersion}
|
||||
partVersion={item.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-solid"
|
||||
import {
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
} from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
|
||||
interface BinaryOption {
|
||||
path: string
|
||||
@@ -23,6 +17,13 @@ interface OpenCodeBinarySelectorProps {
|
||||
}
|
||||
|
||||
const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) => {
|
||||
const {
|
||||
opencodeBinaries,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
} = useConfig()
|
||||
const [customPath, setCustomPath] = createSignal("")
|
||||
const [validating, setValidating] = createSignal(false)
|
||||
const [validationError, setValidationError] = createSignal<string | null>(null)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createSignal, Show, onMount, For, onCleanup, createEffect, on, untrack
|
||||
import UnifiedPicker from "./unified-picker"
|
||||
import { addToHistory, getHistory } from "../stores/message-history"
|
||||
import { getAttachments, addAttachment, clearAttachments, removeAttachment } from "../stores/attachments"
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { createFileAttachment, createTextAttachment, createAgentAttachment } from "../types/attachment"
|
||||
import type { Attachment } from "../types/attachment"
|
||||
import type { Agent } from "../types/session"
|
||||
@@ -15,6 +16,7 @@ interface PromptInputProps {
|
||||
instanceFolder: string
|
||||
sessionId: string
|
||||
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
|
||||
onRunShell?: (command: string) => Promise<void>
|
||||
disabled?: boolean
|
||||
escapeInDebounce?: boolean
|
||||
}
|
||||
@@ -23,6 +25,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const [prompt, setPromptInternal] = createSignal("")
|
||||
const [history, setHistory] = createSignal<string[]>([])
|
||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||
const [isFocused, setIsFocused] = createSignal(false)
|
||||
const [showPicker, setShowPicker] = createSignal(false)
|
||||
const [searchQuery, setSearchQuery] = createSignal("")
|
||||
@@ -31,6 +34,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const [ignoredAtPositions, setIgnoredAtPositions] = createSignal<Set<number>>(new Set<number>())
|
||||
const [pasteCount, setPasteCount] = createSignal(0)
|
||||
const [imageCount, setImageCount] = createSignal(0)
|
||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||
let textareaRef: HTMLTextAreaElement | undefined
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
@@ -48,6 +52,8 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const clearPrompt = () => {
|
||||
clearSessionDraftPrompt(props.instanceId, props.sessionId)
|
||||
setPromptInternal("")
|
||||
setHistoryDraft(null)
|
||||
setMode("normal")
|
||||
}
|
||||
|
||||
function syncAttachmentCounters(currentPrompt: string, sessionAttachments: Attachment[]) {
|
||||
@@ -110,6 +116,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setPromptInternal(storedPrompt)
|
||||
setSessionDraftPrompt(instanceId, sessionId, storedPrompt)
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
setShowPicker(false)
|
||||
setAtPosition(null)
|
||||
@@ -291,9 +298,40 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentText = prompt()
|
||||
const cursorAtBufferStart = textarea.selectionStart === 0 && textarea.selectionEnd === 0
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
if (!isShellMode && e.key === "!" && cursorAtBufferStart && currentText.length === 0 && !props.disabled) {
|
||||
e.preventDefault()
|
||||
setMode("shell")
|
||||
return
|
||||
}
|
||||
|
||||
if (showPicker() && e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handlePickerClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (isShellMode) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMode("normal")
|
||||
return
|
||||
}
|
||||
if (e.key === "Backspace" && cursorAtBufferStart && currentText.length === 0) {
|
||||
e.preventDefault()
|
||||
setMode("normal")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Backspace" || e.key === "Delete") {
|
||||
const cursorPos = textarea.selectionStart
|
||||
const text = prompt()
|
||||
const text = currentText
|
||||
|
||||
const pastePlaceholderRegex = /\[pasted #(\d+)\]/g
|
||||
let pasteMatch
|
||||
@@ -433,6 +471,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
|
||||
if (e.key === "ArrowUp" && !showPicker() && atStart && currentHistory.length > 0) {
|
||||
e.preventDefault()
|
||||
if (historyIndex() === -1) {
|
||||
setHistoryDraft(prompt())
|
||||
}
|
||||
const newIndex = historyIndex() === -1 ? 0 : Math.min(historyIndex() + 1, currentHistory.length - 1)
|
||||
setHistoryIndex(newIndex)
|
||||
setPrompt(currentHistory[newIndex])
|
||||
@@ -447,7 +488,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
setPrompt(currentHistory[newIndex])
|
||||
} else {
|
||||
setHistoryIndex(-1)
|
||||
setPrompt("")
|
||||
const draft = historyDraft()
|
||||
setPrompt(draft ?? "")
|
||||
setHistoryDraft(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,20 +498,32 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
async function handleSend() {
|
||||
const text = prompt().trim()
|
||||
const currentAttachments = attachments()
|
||||
if (!text || props.disabled) return
|
||||
if (props.disabled || !text) return
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
||||
const isShellMode = mode() === "shell"
|
||||
|
||||
clearPrompt()
|
||||
clearAttachments(props.instanceId, props.sessionId)
|
||||
setIgnoredAtPositions(new Set<number>())
|
||||
setPasteCount(0)
|
||||
setImageCount(0)
|
||||
setHistoryDraft(null)
|
||||
|
||||
try {
|
||||
await addToHistory(props.instanceFolder, text)
|
||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
||||
const updated = await getHistory(props.instanceFolder)
|
||||
setHistory(updated)
|
||||
setHistoryIndex(-1)
|
||||
await props.onSend(text, currentAttachments)
|
||||
if (isShellMode) {
|
||||
if (props.onRunShell) {
|
||||
await props.onRunShell(resolvedPrompt)
|
||||
} else {
|
||||
await props.onSend(resolvedPrompt, [])
|
||||
}
|
||||
} else {
|
||||
await props.onSend(text, currentAttachments)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error)
|
||||
alert("Failed to send message: " + (error instanceof Error ? error.message : String(error)))
|
||||
@@ -482,6 +537,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const value = target.value
|
||||
setPrompt(value)
|
||||
setHistoryIndex(-1)
|
||||
setHistoryDraft(null)
|
||||
|
||||
const cursorPos = target.selectionStart
|
||||
const textBeforeCursor = value.substring(0, cursorPos)
|
||||
@@ -654,7 +710,14 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
const canSend = () => (prompt().trim().length > 0 || attachments().length > 0) && !props.disabled
|
||||
const canSend = () => {
|
||||
if (props.disabled) return false
|
||||
const hasText = prompt().trim().length > 0
|
||||
if (mode() === "shell") return hasText
|
||||
return hasText || attachments().length > 0
|
||||
}
|
||||
|
||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
||||
|
||||
const instance = () => getActiveInstance()
|
||||
|
||||
@@ -663,7 +726,11 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
class={`prompt-input-wrapper relative ${isDragging() ? "border-2" : ""}`}
|
||||
style={isDragging() ? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);" : ""}
|
||||
style={
|
||||
isDragging()
|
||||
? "border-color: var(--accent-primary); background-color: rgba(0, 102, 255, 0.05);"
|
||||
: ""
|
||||
}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
@@ -755,8 +822,12 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</Show>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
class="prompt-input"
|
||||
placeholder="Type your message, @file, @agent, or paste images and text..."
|
||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
placeholder={
|
||||
mode() === "shell"
|
||||
? "Run a shell command (Esc to exit)..."
|
||||
: "Type your message, @file, @agent, or paste images and text..."
|
||||
}
|
||||
value={prompt()}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -773,22 +844,37 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="send-button" onClick={handleSend} disabled={!canSend()} aria-label="Send message">
|
||||
<span class="send-icon">▶</span>
|
||||
<button
|
||||
class={`send-button ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||
onClick={handleSend}
|
||||
disabled={!canSend()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Show
|
||||
when={mode() === "shell"}
|
||||
fallback={<span class="send-icon">▶</span>}
|
||||
>
|
||||
<svg class="shell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8l5 4-5 4" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h6" />
|
||||
</svg>
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-input-hints">
|
||||
<div class="flex justify-end">
|
||||
<div class="flex justify-between w-full gap-4">
|
||||
<HintRow>
|
||||
<Show
|
||||
when={props.escapeInDebounce}
|
||||
fallback={
|
||||
<>
|
||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents •{" "}
|
||||
<Kbd>↑↓</Kbd> for history
|
||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
||||
<Show when={attachments().length > 0}>
|
||||
<span class="ml-2 text-xs" style="color: var(--text-muted);">• {attachments().length} file(s) attached</span>
|
||||
</Show>
|
||||
<span class="ml-2">
|
||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -797,6 +883,9 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</span>
|
||||
</Show>
|
||||
</HintRow>
|
||||
<Show when={mode() === "shell"}>
|
||||
<HintRow>Shell mode active</HintRow>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,9 +208,13 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
const title = () => session()?.title || "Untitled"
|
||||
const status = () => getSessionStatus(props.instanceId, rowProps.sessionId)
|
||||
const statusLabel = () => formatSessionStatus(status())
|
||||
const pendingPermission = () => Boolean(session()?.pendingPermission)
|
||||
const statusClassName = () => (pendingPermission() ? "session-permission" : `session-${status()}`)
|
||||
const statusText = () => (pendingPermission() ? "Needs Permission" : statusLabel())
|
||||
|
||||
return (
|
||||
<div class="session-list-item group">
|
||||
<div class="session-list-item group">
|
||||
|
||||
<button
|
||||
class={`session-item-base ${isActive() ? "session-item-active" : "session-item-inactive"}`}
|
||||
onClick={() => selectSession(rowProps.sessionId)}
|
||||
@@ -239,9 +243,9 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="session-item-row session-item-meta">
|
||||
<span class={`status-indicator session-status session-status-list session-${status()}`}>
|
||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`}>
|
||||
<span class="status-dot" />
|
||||
{statusLabel()}
|
||||
{statusText()}
|
||||
</span>
|
||||
<div class="session-item-actions">
|
||||
<span
|
||||
@@ -348,7 +352,7 @@ const SessionList: Component<SessionListProps> = (props) => {
|
||||
<Show when={userSessionIds().length > 0}>
|
||||
<div class="session-section">
|
||||
<div class="session-section-header px-3 py-2 text-xs font-semibold text-primary/70 uppercase tracking-wide">
|
||||
User Sessions
|
||||
User Session
|
||||
</div>
|
||||
<For each={userSessionIds()}>{(id) => <SessionRow sessionId={id} canClose />}</For>
|
||||
</div>
|
||||
|
||||
63
src/components/session/context-usage-panel.tsx
Normal file
63
src/components/session/context-usage-panel.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { getSessionInfo } from "../../stores/sessions"
|
||||
import { formatTokenTotal } from "../../lib/formatters"
|
||||
|
||||
interface ContextUsagePanelProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||
const info = createMemo(
|
||||
() =>
|
||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: 0,
|
||||
isSubscriptionModel: false,
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: null,
|
||||
},
|
||||
)
|
||||
|
||||
const tokens = createMemo(() => info().tokens)
|
||||
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
|
||||
const contextWindow = createMemo(() => info().contextWindow)
|
||||
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
|
||||
|
||||
const costLabel = createMemo(() => {
|
||||
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
|
||||
return `$${info().cost.toFixed(2)} spent`
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="session-context-panel border-r border-base border-b px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
|
||||
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
|
||||
</div>
|
||||
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
|
||||
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
|
||||
</div>
|
||||
<div class="text-sm text-primary/90">
|
||||
{contextWindow()
|
||||
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
|
||||
: "Window size unavailable"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
|
||||
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContextUsagePanel
|
||||
150
src/components/session/session-view.tsx
Normal file
150
src/components/session/session-view.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Show, createMemo, createEffect, onCleanup, type Component } from "solid-js"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageStream from "../message-stream"
|
||||
import PromptInput from "../prompt-input"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions"
|
||||
|
||||
interface SessionViewProps {
|
||||
sessionId: string
|
||||
activeSessions: Map<string, Session>
|
||||
instanceId: string
|
||||
instanceFolder: string
|
||||
escapeInDebounce: boolean
|
||||
}
|
||||
|
||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
const session = () => props.activeSessions.get(props.sessionId)
|
||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||
|
||||
createEffect(() => {
|
||||
const currentSession = session()
|
||||
if (currentSession) {
|
||||
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||
}
|
||||
|
||||
async function handleRunShell(command: string) {
|
||||
await runShellCommand(props.instanceId, props.sessionId, command)
|
||||
}
|
||||
|
||||
function getUserMessageText(messageId: string): string | null {
|
||||
const currentSession = session()
|
||||
if (!currentSession) return null
|
||||
|
||||
const targetMessage = currentSession.messages.find((m) => m.id === messageId)
|
||||
const targetInfo = currentSession.messagesInfo.get(messageId)
|
||||
if (!targetMessage || targetInfo?.role !== "user") {
|
||||
return null
|
||||
}
|
||||
|
||||
const textParts = targetMessage.parts.filter((p): p is ClientPart & { type: "text"; text: string } => p.type === "text")
|
||||
if (textParts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return textParts.map((p) => p.text).join("\n")
|
||||
}
|
||||
|
||||
async function handleRevert(messageId: string) {
|
||||
const instance = instances().get(props.instanceId)
|
||||
if (!instance || !instance.client) return
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: props.sessionId },
|
||||
body: { messageID: messageId },
|
||||
})
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revert:", error)
|
||||
alert("Failed to revert to message")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFork(messageId?: string) {
|
||||
if (!messageId) {
|
||||
console.warn("Fork requires a user message id")
|
||||
return
|
||||
}
|
||||
|
||||
const restoredText = getUserMessageText(messageId)
|
||||
|
||||
try {
|
||||
const forkedSession = await forkSession(props.instanceId, props.sessionId, { messageId })
|
||||
|
||||
const parentToActivate = forkedSession.parentId ?? forkedSession.id
|
||||
setActiveParentSession(props.instanceId, parentToActivate)
|
||||
if (forkedSession.parentId) {
|
||||
setActiveSession(props.instanceId, forkedSession.id)
|
||||
}
|
||||
|
||||
await loadMessages(props.instanceId, forkedSession.id).catch(console.error)
|
||||
|
||||
if (restoredText) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = restoredText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fork session:", error)
|
||||
alert("Failed to fork session")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={session()}
|
||||
fallback={
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center text-gray-500">Session not found</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(s) => (
|
||||
<div class="session-view">
|
||||
<MessageStream
|
||||
instanceId={props.instanceId}
|
||||
sessionId={s().id}
|
||||
messages={s().messages || []}
|
||||
messagesInfo={s().messagesInfo}
|
||||
revert={s().revert}
|
||||
loading={messagesLoading()}
|
||||
onRevert={handleRevert}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
|
||||
<PromptInput
|
||||
instanceId={props.instanceId}
|
||||
instanceFolder={props.instanceFolder}
|
||||
sessionId={s().id}
|
||||
onSend={handleSendMessage}
|
||||
onRunShell={handleRunShell}
|
||||
escapeInDebounce={props.escapeInDebounce}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default SessionView
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, Show, For, createEffect, onCleanup } from "solid-js"
|
||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
@@ -6,7 +6,9 @@ import { useTheme } from "../lib/theme"
|
||||
import { getLanguageFromPath } from "../lib/markdown"
|
||||
import { isRenderableDiffText } from "../lib/diff-utils"
|
||||
import { getToolRenderCache, setToolRenderCache } from "../lib/tool-render-cache"
|
||||
import { preferences, setDiffViewMode, type DiffViewMode } from "../stores/preferences"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { sendPermissionResponse } from "../stores/instances"
|
||||
import type { TextPart, SDKPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -80,6 +82,8 @@ interface ToolCallProps {
|
||||
messageId?: string
|
||||
messageVersion?: number
|
||||
partVersion?: number
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
function getToolIcon(tool: string): string {
|
||||
@@ -137,6 +141,34 @@ function getRelativePath(path: string): string {
|
||||
|
||||
const diffCapableTools = new Set(["edit", "patch"])
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
interface DiffPayload {
|
||||
diffText: string
|
||||
filePath?: string
|
||||
@@ -176,17 +208,177 @@ function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | n
|
||||
return { diffText, filePath }
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function extractDiagnostics(toolName: string, state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [
|
||||
input.filePath,
|
||||
metadata.filePath,
|
||||
metadata.filepath,
|
||||
input.path,
|
||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = (() => {
|
||||
if (!normalizedPreferred) return candidateEntries
|
||||
const matched = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
if (normalized === normalizedPreferred) return true
|
||||
if (normalized.endsWith(`/${normalizedPreferred}`)) return true
|
||||
const normalizedBase = normalized.split("/").pop()
|
||||
const preferredBase = normalizedPreferred.split("/").pop()
|
||||
return normalizedBase && preferredBase ? normalizedBase === preferredBase : false
|
||||
})
|
||||
return matched.length > 0 ? matched : candidateEntries
|
||||
})()
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
|
||||
function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
toolIcon: string,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">
|
||||
:L{entry.line || "-"}:C{entry.column || "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const expanded = () => isToolCallExpanded(toolCallId())
|
||||
const [initializedId, setInitializedId] = createSignal<string | null>(null)
|
||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
|
||||
const pendingPermission = createMemo(() => props.toolCall.pendingPermission)
|
||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||
const activePermissionKey = createMemo(() => {
|
||||
const permission = permissionDetails()
|
||||
return permission && isPermissionActive() ? permission.id : ""
|
||||
})
|
||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||
const [diagnosticsExpanded, setDiagnosticsExpanded] = createSignal(diagnosticsDefaultExpanded())
|
||||
const diagnosticsEntries = createMemo(() => {
|
||||
const tool = props.toolCall?.tool || ""
|
||||
const state = props.toolCall?.state
|
||||
if (!state) return []
|
||||
return extractDiagnostics(tool, state)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const preferred = diagnosticsDefaultExpanded()
|
||||
setDiagnosticsExpanded((prev) => (prev === preferred ? prev : preferred))
|
||||
})
|
||||
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
|
||||
let toolCallRootRef: HTMLDivElement | undefined
|
||||
|
||||
const handleScrollRendered = () => {
|
||||
const id = toolCallId()
|
||||
|
||||
const id = toolCallId()
|
||||
|
||||
if (!id || !scrollContainerRef) return
|
||||
restoreScrollState(id, scrollContainerRef)
|
||||
}
|
||||
@@ -210,13 +402,35 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const id = toolCallId()
|
||||
if (!id || initializedId() === id) return
|
||||
if (!id) return
|
||||
const toolName = props.toolCall?.tool || ""
|
||||
const desiredExpansion = toolName === "read" ? false : toolOutputDefaultExpanded()
|
||||
if (appliedPreference() === desiredExpansion) return
|
||||
setToolCallExpanded(id, desiredExpansion)
|
||||
setAppliedPreference(desiredExpansion)
|
||||
})
|
||||
|
||||
const tool = props.toolCall?.tool || ""
|
||||
const shouldExpand = tool !== "read"
|
||||
createEffect(() => {
|
||||
const id = toolCallId()
|
||||
if (!id) return
|
||||
setAppliedPreference((prev) => (prev === null ? prev : null))
|
||||
})
|
||||
|
||||
setToolCallExpanded(id, shouldExpand)
|
||||
setInitializedId(id)
|
||||
createEffect(() => {
|
||||
if (!pendingPermission()) return
|
||||
const id = toolCallId()
|
||||
if (!id) return
|
||||
setToolCallExpanded(id, true)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) {
|
||||
setPermissionSubmitting(false)
|
||||
setPermissionError(null)
|
||||
} else {
|
||||
setPermissionError(null)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup cache entry when component unmounts or toolCallId changes
|
||||
@@ -243,6 +457,34 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
requestAnimationFrame(() => {
|
||||
toolCallRootRef?.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const activeKey = activePermissionKey()
|
||||
if (!activeKey) return
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("once")
|
||||
} else if (event.key === "a" || event.key === "A") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("always")
|
||||
} else if (event.key === "d" || event.key === "D") {
|
||||
event.preventDefault()
|
||||
handlePermissionResponse("reject")
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handler)
|
||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||
})
|
||||
|
||||
|
||||
const statusIcon = () => {
|
||||
const status = props.toolCall?.state?.status || ""
|
||||
switch (status) {
|
||||
@@ -264,6 +506,11 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return `tool-call-status-${status}`
|
||||
}
|
||||
|
||||
const combinedStatusClass = () => {
|
||||
const base = statusClass()
|
||||
return pendingPermission() ? `${base} tool-call-awaiting-permission` : base
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
toggleToolCallExpanded(toolCallId())
|
||||
}
|
||||
@@ -299,6 +546,24 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePermissionResponse(response: "once" | "always" | "reject") {
|
||||
const permission = permissionDetails()
|
||||
if (!permission || !isPermissionActive()) {
|
||||
return
|
||||
}
|
||||
setPermissionSubmitting(true)
|
||||
setPermissionError(null)
|
||||
try {
|
||||
const sessionId = permission.sessionID || props.sessionId
|
||||
await sendPermissionResponse(props.instanceId, sessionId, permission.id, response)
|
||||
} catch (error) {
|
||||
console.error("Failed to send permission response:", error)
|
||||
setPermissionError(error instanceof Error ? error.message : "Unable to update permission")
|
||||
} finally {
|
||||
setPermissionSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getTodoTitle = () => {
|
||||
const state = props.toolCall?.state || {}
|
||||
if (state.status !== "completed") return "Plan"
|
||||
@@ -422,10 +687,11 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return renderMarkdownTool(toolName, state)
|
||||
}
|
||||
|
||||
function renderDiffTool(payload: DiffPayload) {
|
||||
function renderDiffTool(payload: DiffPayload, options?: { cacheKeySuffix?: string; disableScrollTracking?: boolean; label?: string }) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = relativePath ? `Diff · ${relativePath}` : "Diff"
|
||||
const cacheKey = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const cacheKeyBase = makeRenderCacheKey(toolCallId(), props.messageId, props.messageVersion, props.partVersion)
|
||||
const cacheKey = options?.cacheKeySuffix ? `${cacheKeyBase}${options.cacheKeySuffix}` : cacheKeyBase
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
|
||||
@@ -451,15 +717,21 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
// Cache will be updated by the diff viewer component itself
|
||||
// We'll capture HTML from the rendered component
|
||||
}
|
||||
handleScrollRendered()
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => initializeScrollContainer(element)}
|
||||
onScroll={(event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
ref={(element) => {
|
||||
if (options?.disableScrollTracking) return
|
||||
initializeScrollContainer(element)
|
||||
}}
|
||||
onScroll={options?.disableScrollTracking ? undefined : (event) => updateScrollState(toolCallId(), event.currentTarget)}
|
||||
>
|
||||
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
@@ -800,11 +1072,103 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderPermissionBlock = () => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) return null
|
||||
const active = isPermissionActive()
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
|
||||
|
||||
return (
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{permission.type}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{permission.title}</code>
|
||||
</div>
|
||||
<Show when={diffPayload}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffTool(payload(), {
|
||||
cacheKeySuffix: "::permission",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={active}
|
||||
fallback={<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>}
|
||||
>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => handlePermissionResponse("reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={permissionError()}>
|
||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toolName = () => props.toolCall?.tool || ""
|
||||
const status = () => props.toolCall?.state?.status || ""
|
||||
|
||||
return (
|
||||
<div class={`tool-call ${statusClass()}`}>
|
||||
<div
|
||||
ref={(element) => {
|
||||
toolCallRootRef = element || undefined
|
||||
}}
|
||||
class={`tool-call ${combinedStatusClass()}`}
|
||||
>
|
||||
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
|
||||
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
|
||||
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
|
||||
@@ -815,9 +1179,12 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
<Show when={expanded()}>
|
||||
<div class="tool-call-details">
|
||||
{renderToolBody()}
|
||||
|
||||
{renderError()}
|
||||
|
||||
<Show when={status() === "pending"}>
|
||||
{renderPermissionBlock()}
|
||||
|
||||
<Show when={status() === "pending" && !pendingPermission()}>
|
||||
<div class="tool-call-pending-message">
|
||||
<span class="spinner-small"></span>
|
||||
<span>Waiting for permission...</span>
|
||||
@@ -825,6 +1192,16 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={diagnosticsEntries().length}>
|
||||
{renderDiagnosticsSection(
|
||||
diagnosticsEntries(),
|
||||
diagnosticsExpanded(),
|
||||
() => setDiagnosticsExpanded((prev) => !prev),
|
||||
getToolIcon(toolName()),
|
||||
diagnosticFileName(diagnosticsEntries()),
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@import './styles/tokens.css';
|
||||
@import './styles/components.css';
|
||||
@import './styles/utilities.css';
|
||||
@import './styles/controls.css';
|
||||
@import './styles/messaging.css';
|
||||
@import './styles/panels.css';
|
||||
@import './styles/markdown.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
51
src/lib/command-utils.ts
Normal file
51
src/lib/command-utils.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Command } from "./commands"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import { activeSessionId, executeCustomCommand } from "../stores/sessions"
|
||||
|
||||
export function commandRequiresArguments(template?: string): boolean {
|
||||
if (!template) return false
|
||||
return /\$(?:\d+|ARGUMENTS)/.test(template)
|
||||
}
|
||||
|
||||
export function promptForCommandArguments(command: SDKCommand): string | null {
|
||||
if (!commandRequiresArguments(command.template)) {
|
||||
return ""
|
||||
}
|
||||
const input = window.prompt(`Arguments for /${command.name}`, "")
|
||||
if (input === null) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function formatCommandLabel(name: string): string {
|
||||
if (!name) return ""
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
export function buildCustomCommandEntries(instanceId: string, commands: SDKCommand[]): Command[] {
|
||||
return commands.map((cmd) => ({
|
||||
id: `custom:${instanceId}:${cmd.name}`,
|
||||
label: formatCommandLabel(cmd.name),
|
||||
description: cmd.description ?? "Custom command",
|
||||
category: "Custom Commands",
|
||||
keywords: [cmd.name, ...(cmd.description ? cmd.description.split(/\s+/).filter(Boolean) : [])],
|
||||
action: async () => {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId || sessionId === "info") {
|
||||
alert("Select a session before running a custom command.")
|
||||
return
|
||||
}
|
||||
const args = promptForCommandArguments(cmd)
|
||||
if (args === null) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await executeCustomCommand(instanceId, sessionId, cmd.name, args)
|
||||
} catch (error) {
|
||||
console.error("Failed to run custom command:", error)
|
||||
alert("Failed to run custom command. Check the console for details.")
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
9
src/lib/formatters.ts
Normal file
9
src/lib/formatters.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function formatTokenTotal(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(0)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
178
src/lib/hooks/use-app-lifecycle.ts
Normal file
178
src/lib/hooks/use-app-lifecycle.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { onMount, onCleanup, type Accessor } from "solid-js"
|
||||
import { setupTabKeyboardShortcuts } from "../keyboard"
|
||||
import { registerNavigationShortcuts } from "../shortcuts/navigation"
|
||||
import { registerInputShortcuts } from "../shortcuts/input"
|
||||
import { registerAgentShortcuts } from "../shortcuts/agent"
|
||||
import { registerEscapeShortcut, setEscapeStateChangeHandler } from "../shortcuts/escape"
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
import { abortSession, getSessions, isSessionBusy } from "../../stores/sessions"
|
||||
import { showCommandPalette, hideCommandPalette } from "../../stores/command-palette"
|
||||
import { addLog, updateInstance } from "../../stores/instances"
|
||||
import type { Instance } from "../../types/instance"
|
||||
|
||||
interface UseAppLifecycleOptions {
|
||||
setEscapeInDebounce: (value: boolean) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
showFolderSelection: Accessor<boolean>
|
||||
setShowFolderSelection: (value: boolean) => void
|
||||
getActiveInstance: () => Instance | null
|
||||
getActiveSessionIdForInstance: () => string | null
|
||||
}
|
||||
|
||||
export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
onMount(() => {
|
||||
setEscapeStateChangeHandler(options.setEscapeInDebounce)
|
||||
|
||||
setupTabKeyboardShortcuts(
|
||||
options.handleNewInstanceRequest,
|
||||
options.handleCloseInstance,
|
||||
options.handleNewSession,
|
||||
options.handleCloseSession,
|
||||
() => {
|
||||
const instance = options.getActiveInstance()
|
||||
if (instance) {
|
||||
showCommandPalette(instance.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registerNavigationShortcuts()
|
||||
registerInputShortcuts(
|
||||
() => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) textarea.value = ""
|
||||
},
|
||||
() => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
textarea?.focus()
|
||||
},
|
||||
)
|
||||
|
||||
registerAgentShortcuts(
|
||||
() => {
|
||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registerEscapeShortcut(
|
||||
() => {
|
||||
if (options.showFolderSelection()) return true
|
||||
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return false
|
||||
|
||||
const sessionId = options.getActiveSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return false
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return false
|
||||
|
||||
return isSessionBusy(instance.id, sessionId)
|
||||
},
|
||||
async () => {
|
||||
if (options.showFolderSelection()) {
|
||||
options.setShowFolderSelection(false)
|
||||
return
|
||||
}
|
||||
|
||||
const instance = options.getActiveInstance()
|
||||
const sessionId = options.getActiveSessionIdForInstance()
|
||||
if (!instance || !sessionId || sessionId === "info") return
|
||||
|
||||
try {
|
||||
await abortSession(instance.id, sessionId)
|
||||
console.log("Session aborted successfully")
|
||||
} catch (error) {
|
||||
console.error("Failed to abort session:", error)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
const active = document.activeElement as HTMLElement
|
||||
active?.blur()
|
||||
},
|
||||
() => hideCommandPalette(),
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
const isInCombobox = target.closest('[role="combobox"]') !== null
|
||||
const isInListbox = target.closest('[role="listbox"]') !== null
|
||||
const isInAgentSelect = target.closest('[role="button"][data-agent-selector]') !== null
|
||||
|
||||
if (isInCombobox || isInListbox || isInAgentSelect) {
|
||||
return
|
||||
}
|
||||
|
||||
const shortcut = keyboardRegistry.findMatch(e)
|
||||
if (shortcut) {
|
||||
e.preventDefault()
|
||||
shortcut.handler()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
window.electronAPI.onNewInstance(() => {
|
||||
options.handleNewInstanceRequest()
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStarted(({ id, port, pid, binaryPath }) => {
|
||||
console.log("Instance started:", { id, port, pid, binaryPath })
|
||||
updateInstance(id, { port, pid, status: "ready", binaryPath })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceError(({ id, error }) => {
|
||||
console.error("Instance error:", { id, error })
|
||||
updateInstance(id, { status: "error", error })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceStopped(({ id }) => {
|
||||
console.log("Instance stopped:", id)
|
||||
updateInstance(id, { status: "stopped" })
|
||||
})
|
||||
|
||||
window.electronAPI.onInstanceLog(({ id, entry }) => {
|
||||
addLog(id, entry)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
})
|
||||
}
|
||||
450
src/lib/hooks/use-commands.ts
Normal file
450
src/lib/hooks/use-commands.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { Preferences, ExpansionPreference } from "../../stores/preferences"
|
||||
import { createCommandRegistry, type Command } from "../commands"
|
||||
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
|
||||
import {
|
||||
activeParentSessionId,
|
||||
activeSessionId as activeSessionMap,
|
||||
getSessionFamily,
|
||||
getSessions,
|
||||
setActiveSession,
|
||||
} from "../../stores/sessions"
|
||||
import { setSessionCompactionState } from "../../stores/session-compaction"
|
||||
import type { Instance } from "../../types/instance"
|
||||
|
||||
export interface UseCommandsOptions {
|
||||
preferences: Accessor<Preferences>
|
||||
toggleShowThinkingBlocks: () => void
|
||||
setDiffViewMode: (mode: "split" | "unified") => void
|
||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||
handleNewInstanceRequest: () => void
|
||||
handleCloseInstance: (instanceId: string) => Promise<void>
|
||||
handleNewSession: (instanceId: string) => Promise<void>
|
||||
handleCloseSession: (instanceId: string, sessionId: string) => Promise<void>
|
||||
getActiveInstance: () => Instance | null
|
||||
getActiveSessionIdForInstance: () => string | null
|
||||
}
|
||||
|
||||
export function useCommands(options: UseCommandsOptions) {
|
||||
const commandRegistry = createCommandRegistry()
|
||||
const [commands, setCommands] = createSignal<Command[]>([])
|
||||
|
||||
function refreshCommands() {
|
||||
setCommands(commandRegistry.getAll())
|
||||
}
|
||||
|
||||
function registerCommands() {
|
||||
const activeInstance = options.getActiveInstance
|
||||
const activeSessionIdForInstance = options.getActiveSessionIdForInstance
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-instance",
|
||||
label: "New Instance",
|
||||
description: "Open folder picker to create new instance",
|
||||
category: "Instance",
|
||||
keywords: ["folder", "project", "workspace"],
|
||||
shortcut: { key: "N", meta: true },
|
||||
action: options.handleNewInstanceRequest,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "close-instance",
|
||||
label: "Close Instance",
|
||||
description: "Stop current instance's server",
|
||||
category: "Instance",
|
||||
keywords: ["stop", "quit", "close"],
|
||||
shortcut: { key: "W", meta: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
await options.handleCloseInstance(instance.id)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "instance-next",
|
||||
label: "Next Instance",
|
||||
description: "Cycle to next instance tab",
|
||||
category: "Instance",
|
||||
keywords: ["switch", "navigate"],
|
||||
shortcut: { key: "]", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveInstanceId(ids[next])
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "instance-prev",
|
||||
label: "Previous Instance",
|
||||
description: "Cycle to previous instance tab",
|
||||
category: "Instance",
|
||||
keywords: ["switch", "navigate"],
|
||||
shortcut: { key: "[", meta: true },
|
||||
action: () => {
|
||||
const ids = Array.from(instances().keys())
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeInstanceId() || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveInstanceId(ids[prev])
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "new-session",
|
||||
label: "New Session",
|
||||
description: "Create a new parent session",
|
||||
category: "Session",
|
||||
keywords: ["create", "start"],
|
||||
shortcut: { key: "N", meta: true, shift: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
await options.handleNewSession(instance.id)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "close-session",
|
||||
label: "Close Session",
|
||||
description: "Close current parent session",
|
||||
category: "Session",
|
||||
keywords: ["close", "stop"],
|
||||
shortcut: { key: "W", meta: true, shift: true },
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !sessionId || sessionId === "info") return
|
||||
await options.handleCloseSession(instance.id, sessionId)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "switch-to-info",
|
||||
label: "Instance Info",
|
||||
description: "Open the instance overview for logs and status",
|
||||
category: "Instance",
|
||||
keywords: ["info", "logs", "console", "output"],
|
||||
shortcut: { key: "L", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
if (instance) setActiveSession(instance.id, "info")
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "session-next",
|
||||
label: "Next Session",
|
||||
description: "Cycle to next session tab",
|
||||
category: "Session",
|
||||
keywords: ["switch", "navigate"],
|
||||
shortcut: { key: "]", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const next = (current + 1) % ids.length
|
||||
if (ids[next]) setActiveSession(instanceId, ids[next])
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "session-prev",
|
||||
label: "Previous Session",
|
||||
description: "Cycle to previous session tab",
|
||||
category: "Session",
|
||||
keywords: ["switch", "navigate"],
|
||||
shortcut: { key: "[", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instanceId = activeInstanceId()
|
||||
if (!instanceId) return
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return
|
||||
const familySessions = getSessionFamily(instanceId, parentId)
|
||||
const ids = familySessions.map((s) => s.id).concat(["info"])
|
||||
if (ids.length <= 1) return
|
||||
const current = ids.indexOf(activeSessionMap().get(instanceId) || "")
|
||||
const prev = current <= 0 ? ids.length - 1 : current - 1
|
||||
if (ids[prev]) setActiveSession(instanceId, ids[prev])
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "compact",
|
||||
label: "Compact Session",
|
||||
description: "Summarize and compact the current session",
|
||||
category: "Session",
|
||||
keywords: ["/compact", "summarize", "compress"],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
try {
|
||||
setSessionCompactionState(instance.id, sessionId, true)
|
||||
await instance.client.session.summarize({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
setSessionCompactionState(instance.id, sessionId, false)
|
||||
console.error("Failed to compact session:", error)
|
||||
const message = error instanceof Error ? error.message : "Failed to compact session"
|
||||
alert(`Compact failed: ${message}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "undo",
|
||||
label: "Undo Last Message",
|
||||
description: "Revert the last message",
|
||||
category: "Session",
|
||||
keywords: ["/undo", "revert", "undo"],
|
||||
action: async () => {
|
||||
const instance = activeInstance()
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!instance || !instance.client || !sessionId || sessionId === "info") return
|
||||
|
||||
const sessions = getSessions(instance.id)
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
|
||||
let after = 0
|
||||
const revert = session.revert
|
||||
|
||||
if (revert?.messageID) {
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
if (info?.id === revert.messageID) {
|
||||
after = info.time?.created || 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let messageID = ""
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
const msg = session.messages[i]
|
||||
const info = session.messagesInfo.get(msg.id)
|
||||
|
||||
if (msg.type === "user" && info?.time?.created) {
|
||||
if (after > 0 && info.time.created >= after) {
|
||||
continue
|
||||
}
|
||||
messageID = msg.id
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!messageID) {
|
||||
alert("Nothing to undo")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await instance.client.session.revert({
|
||||
path: { id: sessionId },
|
||||
body: { messageID },
|
||||
})
|
||||
|
||||
const revertedMessage = session.messages.find((m) => m.id === messageID)
|
||||
const revertedInfo = session.messagesInfo.get(messageID)
|
||||
|
||||
if (revertedMessage && revertedInfo?.role === "user") {
|
||||
const textParts = revertedMessage.parts.filter((p) => p.type === "text")
|
||||
if (textParts.length > 0) {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.value = textParts.map((p: any) => p.text).join("\n")
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to revert message:", error)
|
||||
alert("Failed to revert message")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-model-selector",
|
||||
label: "Open Model Selector",
|
||||
description: "Choose a different model",
|
||||
category: "Agent & Model",
|
||||
keywords: ["model", "llm", "ai"],
|
||||
shortcut: { key: "M", meta: true, shift: true },
|
||||
action: () => {
|
||||
const modelInput = document.querySelector("[data-model-selector]") as HTMLInputElement
|
||||
if (modelInput) {
|
||||
modelInput.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "ArrowDown",
|
||||
code: "ArrowDown",
|
||||
keyCode: 40,
|
||||
which: 40,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
modelInput.dispatchEvent(event)
|
||||
}, 10)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
description: "Choose a different agent",
|
||||
category: "Agent & Model",
|
||||
keywords: ["agent", "mode"],
|
||||
shortcut: { key: "A", meta: true, shift: true },
|
||||
action: () => {
|
||||
const agentTrigger = document.querySelector("[data-agent-selector]") as HTMLElement
|
||||
if (agentTrigger) {
|
||||
agentTrigger.focus()
|
||||
setTimeout(() => {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
code: "Enter",
|
||||
keyCode: 13,
|
||||
which: 13,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
agentTrigger.dispatchEvent(event)
|
||||
}, 50)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "clear-input",
|
||||
label: "Clear Input",
|
||||
description: "Clear the prompt textarea",
|
||||
category: "Input & Focus",
|
||||
keywords: ["clear", "reset"],
|
||||
shortcut: { key: "K", meta: true },
|
||||
action: () => {
|
||||
const textarea = document.querySelector(".prompt-input") as HTMLTextAreaElement
|
||||
if (textarea) textarea.value = ""
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "thinking",
|
||||
label: () => `${options.preferences().showThinkingBlocks ? "Hide" : "Show"} Thinking Blocks`,
|
||||
description: "Show/hide AI thinking process",
|
||||
category: "System",
|
||||
keywords: ["/thinking", "toggle", "show", "hide"],
|
||||
action: options.toggleShowThinkingBlocks,
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diff-view-split",
|
||||
label: () => `${(options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""}Use Split Diff View`,
|
||||
description: "Display tool-call diffs side-by-side",
|
||||
category: "System",
|
||||
keywords: ["diff", "split", "view"],
|
||||
action: () => options.setDiffViewMode("split"),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diff-view-unified",
|
||||
label: () => `${(options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""}Use Unified Diff View`,
|
||||
description: "Display tool-call diffs inline",
|
||||
category: "System",
|
||||
keywords: ["diff", "unified", "view"],
|
||||
action: () => options.setDiffViewMode("unified"),
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "tool-output-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
||||
return `Tool Outputs Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
},
|
||||
description: "Toggle default expansion for tool outputs",
|
||||
category: "System",
|
||||
keywords: ["tool", "output", "expand", "collapse"],
|
||||
action: () => {
|
||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
options.setToolOutputExpansion(next)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "diagnostics-default-visibility",
|
||||
label: () => {
|
||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
||||
return `Diagnostics Default · ${mode === "expanded" ? "Expanded" : "Collapsed"}`
|
||||
},
|
||||
description: "Toggle default expansion for diagnostics output",
|
||||
category: "System",
|
||||
keywords: ["diagnostics", "expand", "collapse"],
|
||||
action: () => {
|
||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||
options.setDiagnosticsExpansion(next)
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "help",
|
||||
label: "Show Help",
|
||||
description: "Display keyboard shortcuts and help",
|
||||
category: "System",
|
||||
keywords: ["/help", "shortcuts", "help"],
|
||||
action: () => {
|
||||
console.log("Show help modal (not implemented)")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function executeCommand(command: Command) {
|
||||
try {
|
||||
const result = command.action?.()
|
||||
if (result instanceof Promise) {
|
||||
void result.catch((error) => {
|
||||
console.error("Command execution failed:", error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Command execution failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
registerCommands()
|
||||
refreshCommands()
|
||||
})
|
||||
|
||||
return {
|
||||
commands,
|
||||
commandRegistry,
|
||||
refreshCommands,
|
||||
executeCommand,
|
||||
}
|
||||
}
|
||||
36
src/lib/prompt-placeholders.ts
Normal file
36
src/lib/prompt-placeholders.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Attachment } from "../types/attachment"
|
||||
|
||||
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
|
||||
if (!prompt || !prompt.includes("[pasted #")) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
const lookup = new Map<string, string>()
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const source = attachment?.source
|
||||
if (!source || source.type !== "text") continue
|
||||
const display = attachment?.display
|
||||
const value = source.value
|
||||
if (typeof display !== "string" || typeof value !== "string") continue
|
||||
const match = display.match(/pasted #(\d+)/)
|
||||
if (!match) continue
|
||||
const placeholder = `[pasted #${match[1]}]`
|
||||
if (!lookup.has(placeholder)) {
|
||||
lookup.set(placeholder, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (lookup.size === 0) {
|
||||
return prompt
|
||||
}
|
||||
|
||||
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
|
||||
const replacement = lookup.get(fullMatch)
|
||||
return typeof replacement === "string" ? replacement : fullMatch
|
||||
})
|
||||
}
|
||||
@@ -6,18 +6,22 @@ import {
|
||||
MessagePartRemovedEvent
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventSessionUpdated,
|
||||
EventLspUpdated,
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventPermissionUpdated,
|
||||
EventPermissionReplied
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
interface SSEConnection {
|
||||
instanceId: string
|
||||
port: number
|
||||
eventSource: EventSource
|
||||
status: "connecting" | "connected" | "disconnected" | "error"
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface TuiToastEvent {
|
||||
@@ -41,6 +45,7 @@ type SSEEvent =
|
||||
| EventSessionIdle
|
||||
| EventPermissionUpdated
|
||||
| EventPermissionReplied
|
||||
| EventLspUpdated
|
||||
| TuiToastEvent
|
||||
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
|
||||
|
||||
@@ -50,10 +55,13 @@ const [connectionStatus, setConnectionStatus] = createSignal<
|
||||
|
||||
class SSEManager {
|
||||
private connections = new Map<string, SSEConnection>()
|
||||
private static readonly MAX_RECONNECT_ATTEMPTS = 3
|
||||
|
||||
connect(instanceId: string, port: number): void {
|
||||
if (this.connections.has(instanceId)) {
|
||||
this.disconnect(instanceId)
|
||||
connect(instanceId: string, port: number, reconnectAttempts = 0): void {
|
||||
const existing = this.connections.get(instanceId)
|
||||
if (existing) {
|
||||
this.clearReconnectTimer(existing)
|
||||
existing.eventSource.close()
|
||||
}
|
||||
|
||||
const url = `http://localhost:${port}/event`
|
||||
@@ -61,8 +69,10 @@ class SSEManager {
|
||||
|
||||
const connection: SSEConnection = {
|
||||
instanceId,
|
||||
port,
|
||||
eventSource,
|
||||
status: "connecting",
|
||||
reconnectAttempts,
|
||||
}
|
||||
|
||||
this.connections.set(instanceId, connection)
|
||||
@@ -70,6 +80,7 @@ class SSEManager {
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connection.status = "connected"
|
||||
connection.reconnectAttempts = 0
|
||||
this.updateConnectionStatus(instanceId, "connected")
|
||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
||||
}
|
||||
@@ -87,13 +98,14 @@ class SSEManager {
|
||||
connection.status = "error"
|
||||
this.updateConnectionStatus(instanceId, "error")
|
||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
||||
this.handleConnectionLost(instanceId, "Connection to instance lost")
|
||||
this.handleConnectionError(instanceId, "Connection to instance lost")
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(instanceId: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (connection) {
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
this.updateConnectionStatus(instanceId, "disconnected")
|
||||
@@ -138,15 +150,45 @@ class SSEManager {
|
||||
case "permission.replied":
|
||||
this.onPermissionReplied?.(instanceId, event as EventPermissionReplied)
|
||||
break
|
||||
case "lsp.updated":
|
||||
this.onLspUpdated?.(instanceId, event as EventLspUpdated)
|
||||
break
|
||||
default:
|
||||
console.warn("[SSE] Unknown event type:", event.type)
|
||||
}
|
||||
}
|
||||
|
||||
private handleConnectionError(instanceId: string, reason: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
connection.eventSource.close()
|
||||
|
||||
if (connection.reconnectAttempts >= SSEManager.MAX_RECONNECT_ATTEMPTS) {
|
||||
this.handleConnectionLost(instanceId, reason)
|
||||
return
|
||||
}
|
||||
|
||||
const nextAttempt = connection.reconnectAttempts + 1
|
||||
const delay = Math.min(nextAttempt * 1000, 5000)
|
||||
|
||||
connection.reconnectAttempts = nextAttempt
|
||||
connection.status = "connecting"
|
||||
this.updateConnectionStatus(instanceId, "connecting")
|
||||
|
||||
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
|
||||
|
||||
connection.reconnectTimer = setTimeout(() => {
|
||||
connection.reconnectTimer = undefined
|
||||
this.connect(instanceId, connection.port, nextAttempt)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private handleConnectionLost(instanceId: string, reason: string): void {
|
||||
const connection = this.connections.get(instanceId)
|
||||
if (!connection) return
|
||||
|
||||
this.clearReconnectTimer(connection)
|
||||
connection.eventSource.close()
|
||||
this.connections.delete(instanceId)
|
||||
connection.status = "disconnected"
|
||||
@@ -154,6 +196,13 @@ class SSEManager {
|
||||
this.onConnectionLost?.(instanceId, reason)
|
||||
}
|
||||
|
||||
private clearReconnectTimer(connection: SSEConnection): void {
|
||||
if (connection.reconnectTimer) {
|
||||
clearTimeout(connection.reconnectTimer)
|
||||
connection.reconnectTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
|
||||
setConnectionStatus((prev) => {
|
||||
const next = new Map(prev)
|
||||
@@ -173,6 +222,7 @@ class SSEManager {
|
||||
onSessionIdle?: (instanceId: string, event: EventSessionIdle) => void
|
||||
onPermissionUpdated?: (instanceId: string, event: EventPermissionUpdated) => void
|
||||
onPermissionReplied?: (instanceId: string, event: EventPermissionReplied) => void
|
||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||
|
||||
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
||||
|
||||
@@ -83,6 +83,8 @@ export class FileStorage {
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
},
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render } from "solid-js/web"
|
||||
import App from "./App"
|
||||
import { ThemeProvider } from "./lib/theme"
|
||||
import { ConfigProvider } from "./stores/preferences"
|
||||
import "./index.css"
|
||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||
|
||||
@@ -12,9 +13,11 @@ if (!root) {
|
||||
|
||||
render(
|
||||
() => (
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const [isOpen, setIsOpen] = createSignal(false)
|
||||
const [openStates, setOpenStates] = createSignal<Map<string, boolean>>(new Map())
|
||||
|
||||
export function showCommandPalette() {
|
||||
setIsOpen(true)
|
||||
function updateState(instanceId: string, open: boolean) {
|
||||
setOpenStates((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, open)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function hideCommandPalette() {
|
||||
setIsOpen(false)
|
||||
export function showCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
updateState(instanceId, true)
|
||||
}
|
||||
|
||||
export function toggleCommandPalette() {
|
||||
setIsOpen(!isOpen())
|
||||
export function hideCommandPalette(instanceId?: string) {
|
||||
if (!instanceId) {
|
||||
setOpenStates(new Map())
|
||||
return
|
||||
}
|
||||
updateState(instanceId, false)
|
||||
}
|
||||
|
||||
export { isOpen }
|
||||
export function toggleCommandPalette(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
const current = openStates().get(instanceId) ?? false
|
||||
updateState(instanceId, !current)
|
||||
}
|
||||
|
||||
export function isOpen(instanceId: string): boolean {
|
||||
return openStates().get(instanceId) ?? false
|
||||
}
|
||||
|
||||
export { openStates }
|
||||
|
||||
30
src/stores/commands.ts
Normal file
30
src/stores/commands.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
|
||||
const [commandMap, setCommandMap] = createSignal<Map<string, SDKCommand[]>>(new Map())
|
||||
|
||||
export async function fetchCommands(instanceId: string, client: OpencodeClient): Promise<void> {
|
||||
const response = await client.command.list()
|
||||
const commands = response.data ?? []
|
||||
setCommandMap((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, commands)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export function getCommands(instanceId: string): SDKCommand[] {
|
||||
return commandMap().get(instanceId) ?? []
|
||||
}
|
||||
|
||||
export function clearCommands(instanceId: string): void {
|
||||
setCommandMap((prev) => {
|
||||
if (!prev.has(instanceId)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export { commandMap as commands }
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Instance, LogEntry } from "../types/instance"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import type { LspStatus, Permission } from "@opencode-ai/sdk"
|
||||
import type { ClientPart, Message } from "../types/message"
|
||||
import { sdkManager } from "../lib/sdk-manager"
|
||||
import { sseManager } from "../lib/sse-manager"
|
||||
import {
|
||||
@@ -10,7 +11,10 @@ import {
|
||||
removeSessionIndexes,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||
import { computeDisplayParts } from "./session-messages"
|
||||
import { withSession, setSessionPendingPermission } from "./session-state"
|
||||
import { setHasInstances } from "./ui"
|
||||
|
||||
const [instances, setInstances] = createSignal<Map<string, Instance>>(new Map())
|
||||
@@ -21,6 +25,7 @@ const [logStreamingState, setLogStreamingState] = createSignal<Map<string, boole
|
||||
// Permission queue management per instance
|
||||
const [permissionQueues, setPermissionQueues] = createSignal<Map<string, Permission[]>>(new Map())
|
||||
const [activePermissionId, setActivePermissionId] = createSignal<Map<string, string | null>>(new Map())
|
||||
const permissionSessionCounts = new Map<string, Map<string, number>>()
|
||||
interface DisconnectedInstanceInfo {
|
||||
id: string
|
||||
folder: string
|
||||
@@ -139,6 +144,8 @@ function removeInstance(id: string) {
|
||||
})
|
||||
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
clearPermissionQueue(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
@@ -194,6 +201,7 @@ async function createInstance(folder: string, binaryPath?: string): Promise<stri
|
||||
await fetchSessions(id)
|
||||
await fetchAgents(id)
|
||||
await fetchProviders(id)
|
||||
await fetchCommands(id, client)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch initial data:", error)
|
||||
}
|
||||
@@ -225,6 +233,26 @@ async function stopInstance(id: string) {
|
||||
removeInstance(id)
|
||||
}
|
||||
|
||||
async function fetchLspStatus(instanceId: string): Promise<LspStatus[] | undefined> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} not found`)
|
||||
return undefined
|
||||
}
|
||||
if (!instance.client) {
|
||||
console.warn(`[LSP] Skipping fetch; instance ${instanceId} client not ready`)
|
||||
return undefined
|
||||
}
|
||||
const lsp = instance.client.lsp
|
||||
if (!lsp?.status) {
|
||||
console.warn(`[LSP] Skipping fetch; lsp.status API unavailable for instance ${instanceId}`)
|
||||
return undefined
|
||||
}
|
||||
console.log(`[HTTP] GET /lsp.status for instance ${instanceId}`)
|
||||
const response = await lsp.status()
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
function getActiveInstance(): Instance | null {
|
||||
const id = activeInstanceId()
|
||||
return id ? instances().get(id) || null : null
|
||||
@@ -257,30 +285,73 @@ function clearLogs(id: string) {
|
||||
|
||||
// Permission management functions
|
||||
function getPermissionQueue(instanceId: string): Permission[] {
|
||||
return permissionQueues().get(instanceId) ?? []
|
||||
const queue = permissionQueues().get(instanceId)
|
||||
if (!queue) {
|
||||
return []
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
function getPermissionQueueLength(instanceId: string): number {
|
||||
return getPermissionQueue(instanceId).length
|
||||
}
|
||||
|
||||
function incrementSessionPendingCount(instanceId: string, sessionId: string): void {
|
||||
let sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) {
|
||||
sessionCounts = new Map()
|
||||
permissionSessionCounts.set(instanceId, sessionCounts)
|
||||
}
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
sessionCounts.set(sessionId, current + 1)
|
||||
}
|
||||
|
||||
function decrementSessionPendingCount(instanceId: string, sessionId: string): number {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return 0
|
||||
const current = sessionCounts.get(sessionId) ?? 0
|
||||
if (current <= 1) {
|
||||
sessionCounts.delete(sessionId)
|
||||
if (sessionCounts.size === 0) {
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
const nextValue = current - 1
|
||||
sessionCounts.set(sessionId, nextValue)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function clearSessionPendingCounts(instanceId: string): void {
|
||||
const sessionCounts = permissionSessionCounts.get(instanceId)
|
||||
if (!sessionCounts) return
|
||||
for (const sessionId of sessionCounts.keys()) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
}
|
||||
permissionSessionCounts.delete(instanceId)
|
||||
}
|
||||
|
||||
function addPermissionToQueue(instanceId: string, permission: Permission): void {
|
||||
let inserted = false
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
|
||||
// Check if permission already exists
|
||||
if (queue.some(p => p.id === permission.id)) {
|
||||
return next // Don't add duplicate
|
||||
if (queue.some((p) => p.id === permission.id)) {
|
||||
return next
|
||||
}
|
||||
|
||||
// Add to queue and sort by creation time to maintain order
|
||||
const updatedQueue = [...queue, permission].sort((a, b) => a.time.created - b.time.created)
|
||||
next.set(instanceId, updatedQueue)
|
||||
inserted = true
|
||||
return next
|
||||
})
|
||||
|
||||
// Set as active if no active permission
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!next.get(instanceId)) {
|
||||
@@ -288,6 +359,13 @@ function addPermissionToQueue(instanceId: string, permission: Permission): void
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const sessionId = getPermissionSessionId(permission)
|
||||
incrementSessionPendingCount(instanceId, sessionId)
|
||||
setSessionPendingPermission(instanceId, sessionId, true)
|
||||
|
||||
const isActive = getActivePermission(instanceId)?.id === permission.id
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
function getActivePermission(instanceId: string): Permission | null {
|
||||
@@ -299,30 +377,53 @@ function getActivePermission(instanceId: string): Permission | null {
|
||||
}
|
||||
|
||||
function removePermissionFromQueue(instanceId: string, permissionId: string): void {
|
||||
let updatedQueue: Permission[] = []
|
||||
let removedPermission: Permission | null = null
|
||||
|
||||
setPermissionQueues((prev) => {
|
||||
const next = new Map(prev)
|
||||
const queue = next.get(instanceId) ?? []
|
||||
updatedQueue = queue.filter(p => p.id !== permissionId)
|
||||
if (updatedQueue.length > 0) {
|
||||
next.set(instanceId, updatedQueue)
|
||||
const filtered: Permission[] = []
|
||||
|
||||
for (const item of queue) {
|
||||
if (item.id === permissionId) {
|
||||
removedPermission = item
|
||||
continue
|
||||
}
|
||||
filtered.push(item)
|
||||
}
|
||||
|
||||
if (filtered.length > 0) {
|
||||
next.set(instanceId, filtered)
|
||||
} else {
|
||||
next.delete(instanceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const updatedQueue = getPermissionQueue(instanceId)
|
||||
|
||||
setActivePermissionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
const activeId = next.get(instanceId)
|
||||
if (activeId === permissionId) {
|
||||
// Set the next permission in queue as active, or null if queue is empty
|
||||
const nextPermission = updatedQueue.length > 0 ? updatedQueue[0] : null
|
||||
const nextPermission = updatedQueue.length > 0 ? (updatedQueue[0] as Permission) : null
|
||||
next.set(instanceId, nextPermission?.id ?? null)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
const removed = removedPermission
|
||||
if (removed) {
|
||||
clearPermissionFromToolPart(instanceId, removed)
|
||||
const removedSessionId = getPermissionSessionId(removed)
|
||||
const remaining = decrementSessionPendingCount(instanceId, removedSessionId)
|
||||
setSessionPendingPermission(instanceId, removedSessionId, remaining > 0)
|
||||
}
|
||||
|
||||
const nextActivePermission = getActivePermission(instanceId)
|
||||
if (nextActivePermission) {
|
||||
attachPermissionToToolPart(instanceId, nextActivePermission, true)
|
||||
}
|
||||
}
|
||||
|
||||
function clearPermissionQueue(instanceId: string): void {
|
||||
@@ -336,6 +437,95 @@ function clearPermissionQueue(instanceId: string): void {
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
clearSessionPendingCounts(instanceId)
|
||||
}
|
||||
|
||||
function getPermissionSessionId(permission: Permission): string {
|
||||
return (permission as any).sessionID
|
||||
}
|
||||
|
||||
function findToolPartForPermission(message: Message, permission: Permission): ClientPart | null {
|
||||
const expectedCallId = permission.callID
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== "tool") continue
|
||||
const toolCallId = (part as any).callID
|
||||
if (expectedCallId) {
|
||||
if (toolCallId === expectedCallId) {
|
||||
return part as ClientPart
|
||||
}
|
||||
if (!toolCallId && (part.id === expectedCallId || part.messageID === permission.messageID)) {
|
||||
return part as ClientPart
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ((toolCallId && toolCallId === permission.id) || part.id === permission.id || part.messageID === permission.messageID) {
|
||||
return part as ClientPart
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function mutateToolPartPermission(
|
||||
instanceId: string,
|
||||
permission: Permission,
|
||||
mutator: (part: ClientPart, message: Message) => boolean,
|
||||
): void {
|
||||
const permissionSessionId = getPermissionSessionId(permission)
|
||||
withSession(instanceId, permissionSessionId, (session) => {
|
||||
const message = session.messages.find((msg) => msg.id === permission.messageID)
|
||||
if (!message) return
|
||||
const targetPart = findToolPartForPermission(message, permission)
|
||||
if (!targetPart) return
|
||||
|
||||
const changed = mutator(targetPart, message)
|
||||
if (!changed) return
|
||||
|
||||
const nextPartVersion = typeof targetPart.version === "number" ? targetPart.version + 1 : 1
|
||||
targetPart.version = nextPartVersion
|
||||
message.version = (message.version ?? 0) + 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
})
|
||||
}
|
||||
|
||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
const existing = part.pendingPermission
|
||||
if (existing && existing.permission.id === permission.id && existing.active === active) {
|
||||
return false
|
||||
}
|
||||
part.pendingPermission = { permission, active }
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function clearPermissionFromToolPart(instanceId: string, permission: Permission): void {
|
||||
mutateToolPartPermission(instanceId, permission, (part) => {
|
||||
if (!part.pendingPermission || part.pendingPermission.permission.id !== permission.id) {
|
||||
return false
|
||||
}
|
||||
delete part.pendingPermission
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function refreshPermissionsForSession(instanceId: string, sessionId: string): void {
|
||||
const queue = getPermissionQueue(instanceId)
|
||||
if (queue.length === 0) {
|
||||
setSessionPendingPermission(instanceId, sessionId, false)
|
||||
return
|
||||
}
|
||||
|
||||
const activeId = activePermissionId().get(instanceId)
|
||||
|
||||
for (const permission of queue) {
|
||||
if (getPermissionSessionId(permission) !== sessionId) continue
|
||||
const isActive = permission.id === activeId
|
||||
attachPermissionToToolPart(instanceId, permission, isActive)
|
||||
}
|
||||
|
||||
const pendingCount = permissionSessionCounts.get(instanceId)?.get(sessionId) ?? 0
|
||||
setSessionPendingPermission(instanceId, sessionId, pendingCount > 0)
|
||||
}
|
||||
|
||||
async function sendPermissionResponse(
|
||||
@@ -376,6 +566,29 @@ sseManager.onConnectionLost = (instanceId, reason) => {
|
||||
})
|
||||
}
|
||||
|
||||
sseManager.onLspUpdated = async (instanceId) => {
|
||||
console.log(`[LSP] Received lsp.updated event for instance ${instanceId}`)
|
||||
try {
|
||||
const lspStatus = await fetchLspStatus(instanceId)
|
||||
if (!lspStatus) {
|
||||
return
|
||||
}
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance) {
|
||||
console.warn(`[LSP] Instance ${instanceId} disappeared before metadata update`)
|
||||
return
|
||||
}
|
||||
updateInstance(instanceId, {
|
||||
metadata: {
|
||||
...(instance.metadata ?? {}),
|
||||
lspStatus,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh LSP status:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function acknowledgeDisconnectedInstance(): Promise<void> {
|
||||
const pending = disconnectedInstance()
|
||||
if (!pending) {
|
||||
@@ -419,7 +632,9 @@ export {
|
||||
getActivePermission,
|
||||
removePermissionFromQueue,
|
||||
clearPermissionQueue,
|
||||
refreshPermissionsForSession,
|
||||
sendPermissionResponse,
|
||||
disconnectedInstance,
|
||||
acknowledgeDisconnectedInstance,
|
||||
fetchLspStatus,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { createContext, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { Accessor, ParentComponent } from "solid-js"
|
||||
import { storage, type ConfigData } from "../lib/storage"
|
||||
|
||||
export interface ModelPreference {
|
||||
@@ -11,6 +12,7 @@ export interface AgentModelSelections {
|
||||
}
|
||||
|
||||
export type DiffViewMode = "split" | "unified"
|
||||
export type ExpansionPreference = "expanded" | "collapsed"
|
||||
|
||||
export interface Preferences {
|
||||
showThinkingBlocks: boolean
|
||||
@@ -19,6 +21,8 @@ export interface Preferences {
|
||||
modelRecents?: ModelPreference[]
|
||||
agentModelSelections?: AgentModelSelections
|
||||
diffViewMode?: DiffViewMode
|
||||
toolOutputExpansion?: ExpansionPreference
|
||||
diagnosticsExpansion?: ExpansionPreference
|
||||
}
|
||||
|
||||
export interface OpenCodeBinary {
|
||||
@@ -32,7 +36,7 @@ export interface RecentFolder {
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
const MAX_RECENT_FOLDERS = 10
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
@@ -40,16 +44,20 @@ const defaultPreferences: Preferences = {
|
||||
modelRecents: [],
|
||||
agentModelSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
}
|
||||
|
||||
const [preferences, setPreferences] = createSignal<Preferences>(defaultPreferences)
|
||||
const [recentFolders, setRecentFolders] = createSignal<RecentFolder[]>([])
|
||||
const [opencodeBinaries, setOpenCodeBinaries] = createSignal<OpenCodeBinary[]>([])
|
||||
const [isConfigLoaded, setIsConfigLoaded] = createSignal(false)
|
||||
let cachedConfig: ConfigData = {
|
||||
preferences: defaultPreferences,
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
async function loadConfig(): Promise<void> {
|
||||
try {
|
||||
@@ -60,16 +68,25 @@ async function loadConfig(): Promise<void> {
|
||||
recentFolders: config.recentFolders || [],
|
||||
opencodeBinaries: config.opencodeBinaries || [],
|
||||
}
|
||||
setPreferences(cachedConfig.preferences)
|
||||
setRecentFolders(cachedConfig.recentFolders)
|
||||
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error)
|
||||
cachedConfig = {
|
||||
...cachedConfig,
|
||||
preferences: { ...defaultPreferences },
|
||||
recentFolders: [],
|
||||
opencodeBinaries: [],
|
||||
}
|
||||
}
|
||||
|
||||
setPreferences(cachedConfig.preferences)
|
||||
setRecentFolders(cachedConfig.recentFolders)
|
||||
setOpenCodeBinaries(cachedConfig.opencodeBinaries)
|
||||
setIsConfigLoaded(true)
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
try {
|
||||
await ensureConfigLoaded()
|
||||
const config: ConfigData = {
|
||||
...cachedConfig,
|
||||
preferences: preferences(),
|
||||
@@ -83,6 +100,17 @@ async function saveConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureConfigLoaded(): Promise<void> {
|
||||
if (isConfigLoaded()) return
|
||||
if (!loadPromise) {
|
||||
loadPromise = loadConfig().finally(() => {
|
||||
loadPromise = null
|
||||
})
|
||||
}
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
|
||||
function updatePreferences(updates: Partial<Preferences>): void {
|
||||
const updated = { ...preferences(), ...updates }
|
||||
setPreferences(updated)
|
||||
@@ -94,6 +122,16 @@ function setDiffViewMode(mode: DiffViewMode): void {
|
||||
updatePreferences({ diffViewMode: mode })
|
||||
}
|
||||
|
||||
function setToolOutputExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().toolOutputExpansion === mode) return
|
||||
updatePreferences({ toolOutputExpansion: mode })
|
||||
}
|
||||
|
||||
function setDiagnosticsExpansion(mode: ExpansionPreference): void {
|
||||
if (preferences().diagnosticsExpansion === mode) return
|
||||
updatePreferences({ diagnosticsExpansion: mode })
|
||||
}
|
||||
|
||||
function toggleShowThinkingBlocks(): void {
|
||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||
}
|
||||
@@ -196,20 +234,89 @@ function getAgentModelPreference(instanceId: string, agent: string): ModelPrefer
|
||||
return preferences().agentModelSelections?.[instanceId]?.[agent]
|
||||
}
|
||||
|
||||
// Load config on mount and listen for changes from other instances
|
||||
onMount(() => {
|
||||
loadConfig()
|
||||
|
||||
// Reload config when changed by another instance
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadConfig()
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
return unsubscribe
|
||||
void ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
interface ConfigContextValue {
|
||||
isLoaded: Accessor<boolean>
|
||||
preferences: typeof preferences
|
||||
recentFolders: typeof recentFolders
|
||||
opencodeBinaries: typeof opencodeBinaries
|
||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||
setDiffViewMode: typeof setDiffViewMode
|
||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||
addRecentFolder: typeof addRecentFolder
|
||||
removeRecentFolder: typeof removeRecentFolder
|
||||
addOpenCodeBinary: typeof addOpenCodeBinary
|
||||
removeOpenCodeBinary: typeof removeOpenCodeBinary
|
||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||
updatePreferences: typeof updatePreferences
|
||||
updateEnvironmentVariables: typeof updateEnvironmentVariables
|
||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||
addRecentModelPreference: typeof addRecentModelPreference
|
||||
setAgentModelPreference: typeof setAgentModelPreference
|
||||
getAgentModelPreference: typeof getAgentModelPreference
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextValue>()
|
||||
|
||||
const configContextValue: ConfigContextValue = {
|
||||
isLoaded: isConfigLoaded,
|
||||
preferences,
|
||||
recentFolders,
|
||||
opencodeBinaries,
|
||||
toggleShowThinkingBlocks,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
addRecentFolder,
|
||||
removeRecentFolder,
|
||||
addOpenCodeBinary,
|
||||
removeOpenCodeBinary,
|
||||
updateLastUsedBinary,
|
||||
updatePreferences,
|
||||
updateEnvironmentVariables,
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
}
|
||||
|
||||
const ConfigProvider: ParentComponent = (props) => {
|
||||
onMount(() => {
|
||||
ensureConfigLoaded().catch((error) => {
|
||||
console.error("Failed to initialize config:", error)
|
||||
})
|
||||
|
||||
const unsubscribe = storage.onConfigChanged(() => {
|
||||
loadConfig().catch((error) => {
|
||||
console.error("Failed to refresh config:", error)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
}
|
||||
})
|
||||
|
||||
return <ConfigContext.Provider value={configContextValue}>{props.children}</ConfigContext.Provider>
|
||||
}
|
||||
|
||||
function useConfig(): ConfigContextValue {
|
||||
const context = useContext(ConfigContext)
|
||||
if (!context) {
|
||||
throw new Error("useConfig must be used within ConfigProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export {
|
||||
ConfigProvider,
|
||||
useConfig,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
toggleShowThinkingBlocks,
|
||||
@@ -227,4 +334,6 @@ export {
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
setToolOutputExpansion,
|
||||
setDiagnosticsExpansion,
|
||||
}
|
||||
352
src/stores/session-actions.ts
Normal file
352
src/stores/session-actions.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import {
|
||||
addRecentModelPreference,
|
||||
preferences,
|
||||
setAgentModelPreference,
|
||||
} from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
let lastTimestamp = 0
|
||||
let localCounter = 0
|
||||
|
||||
function randomBase62(length: number): string {
|
||||
let result = ""
|
||||
const cryptoObj = (globalThis as unknown as { crypto?: Crypto }).crypto
|
||||
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(length)
|
||||
cryptoObj.getRandomValues(bytes)
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += BASE62_CHARS[bytes[i] % BASE62_CHARS.length]
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < length; i++) {
|
||||
const idx = Math.floor(Math.random() * BASE62_CHARS.length)
|
||||
result += BASE62_CHARS[idx]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function createId(prefix: string): string {
|
||||
const timestamp = Date.now()
|
||||
if (timestamp !== lastTimestamp) {
|
||||
lastTimestamp = timestamp
|
||||
localCounter = 0
|
||||
}
|
||||
localCounter++
|
||||
|
||||
const value = (BigInt(timestamp) << BigInt(12)) + BigInt(localCounter)
|
||||
const bytes = new Array<number>(6)
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const shift = BigInt(8 * (5 - i))
|
||||
bytes[i] = Number((value >> shift) & BigInt(0xff))
|
||||
}
|
||||
const hex = bytes.map((b) => b.toString(16).padStart(2, "0")).join("")
|
||||
const random = randomBase62(ID_LENGTH - 12)
|
||||
|
||||
return `${prefix}_${hex}${random}`
|
||||
}
|
||||
|
||||
async function sendMessage(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
attachments: any[] = [],
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const messageId = createId("msg")
|
||||
const textPartId = createId("part")
|
||||
|
||||
const resolvedPrompt = resolvePastedPlaceholders(prompt, attachments)
|
||||
|
||||
const optimisticParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
const optimisticMessage: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: "user",
|
||||
parts: optimisticParts,
|
||||
timestamp: Date.now(),
|
||||
status: "sending",
|
||||
version: 0,
|
||||
}
|
||||
|
||||
optimisticParts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
optimisticMessage.displayParts = computeDisplayParts(optimisticMessage, preferences().showThinkingBlocks)
|
||||
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
session.messages.push(optimisticMessage)
|
||||
const index = getSessionIndex(instanceId, sessionId)
|
||||
index.messageIndex.set(optimisticMessage.id, session.messages.length - 1)
|
||||
})
|
||||
|
||||
const requestParts: any[] = [
|
||||
{
|
||||
id: textPartId,
|
||||
type: "text" as const,
|
||||
text: resolvedPrompt,
|
||||
},
|
||||
]
|
||||
|
||||
if (attachments.length > 0) {
|
||||
for (const att of attachments) {
|
||||
const source = att.source
|
||||
if (source.type === "file") {
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "file" as const,
|
||||
url: att.url,
|
||||
mime: source.mime,
|
||||
filename: att.filename,
|
||||
synthetic: true,
|
||||
})
|
||||
} else if (source.type === "text") {
|
||||
const display: string | undefined = att.display
|
||||
const value: unknown = source.value
|
||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||
|
||||
if (isPastedPlaceholder || typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
|
||||
const partId = createId("part")
|
||||
requestParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
})
|
||||
optimisticParts.push({
|
||||
id: partId,
|
||||
type: "text" as const,
|
||||
text: value,
|
||||
synthetic: true,
|
||||
renderCache: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
messageID: messageId,
|
||||
parts: requestParts,
|
||||
...(session.agent && { agent: session.agent }),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId && {
|
||||
model: {
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
console.log("[sendMessage] Sending prompt:", {
|
||||
sessionId,
|
||||
requestBody,
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.prompt for instance ${instanceId}`, { sessionId, requestBody })
|
||||
const response = await instance.client.session.prompt({
|
||||
path: { id: sessionId },
|
||||
body: requestBody,
|
||||
})
|
||||
|
||||
console.log("[sendMessage] Response:", response)
|
||||
|
||||
if (response.error) {
|
||||
console.error("[sendMessage] Server returned error:", response.error)
|
||||
throw new Error(JSON.stringify(response.error) || "Failed to send message")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[sendMessage] Failed to send prompt:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCustomCommand(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
commandName: string,
|
||||
args: string,
|
||||
): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const body: {
|
||||
command: string
|
||||
arguments: string
|
||||
messageID: string
|
||||
agent?: string
|
||||
model?: string
|
||||
} = {
|
||||
command: commandName,
|
||||
arguments: args,
|
||||
messageID: createId("msg"),
|
||||
}
|
||||
|
||||
if (session.agent) {
|
||||
body.agent = session.agent
|
||||
}
|
||||
|
||||
if (session.model.providerId && session.model.modelId) {
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
}
|
||||
|
||||
await instance.client.session.command({
|
||||
path: { id: sessionId },
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
async function runShellCommand(instanceId: string, sessionId: string, command: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const session = sessions().get(instanceId)?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const agent = session.agent || "build"
|
||||
|
||||
await instance.client.session.shell({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
agent,
|
||||
command,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function abortSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
console.log("[abortSession] Aborting session:", { instanceId, sessionId })
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.abort for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.abort({
|
||||
path: { id: sessionId },
|
||||
})
|
||||
console.log("[abortSession] Session aborted successfully")
|
||||
} catch (error) {
|
||||
console.error("[abortSession] Failed to abort session:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionAgent(instanceId: string, sessionId: string, agent: string): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
const nextModel = await getDefaultModel(instanceId, agent)
|
||||
const shouldApplyModel = isModelValid(instanceId, nextModel)
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.agent = agent
|
||||
if (shouldApplyModel) {
|
||||
current.model = nextModel
|
||||
}
|
||||
})
|
||||
|
||||
if (agent && shouldApplyModel) {
|
||||
setAgentModelPreference(instanceId, agent, nextModel)
|
||||
}
|
||||
|
||||
if (shouldApplyModel) {
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSessionModel(
|
||||
instanceId: string,
|
||||
sessionId: string,
|
||||
model: { providerId: string; modelId: string },
|
||||
): Promise<void> {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
if (!isModelValid(instanceId, model)) {
|
||||
console.warn("Invalid model selection", model)
|
||||
return
|
||||
}
|
||||
|
||||
withSession(instanceId, sessionId, (current) => {
|
||||
current.model = model
|
||||
})
|
||||
|
||||
if (session.agent) {
|
||||
setAgentModelPreference(instanceId, session.agent, model)
|
||||
}
|
||||
addRecentModelPreference(model)
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
abortSession,
|
||||
executeCustomCommand,
|
||||
runShellCommand,
|
||||
sendMessage,
|
||||
updateSessionAgent,
|
||||
updateSessionModel,
|
||||
}
|
||||
625
src/stores/session-api.ts
Normal file
625
src/stores/session-api.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import type { Session } from "../types/session"
|
||||
import type { Message } from "../types/message"
|
||||
|
||||
import { instances, refreshPermissionsForSession } from "./instances"
|
||||
import { preferences, setAgentModelPreference } from "./preferences"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
import {
|
||||
activeSessionId,
|
||||
agents,
|
||||
clearSessionDraftPrompt,
|
||||
messagesLoaded,
|
||||
providers,
|
||||
pruneDraftPrompts,
|
||||
setActiveSessionId,
|
||||
setAgents,
|
||||
setMessagesLoaded,
|
||||
setProviders,
|
||||
setSessionInfoByInstance,
|
||||
setSessions,
|
||||
sessions,
|
||||
loading,
|
||||
setLoading,
|
||||
} from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import {
|
||||
computeDisplayParts,
|
||||
clearSessionIndex,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
|
||||
interface SessionForkResponse {
|
||||
id: string
|
||||
title?: string
|
||||
parentID?: string | null
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
time?: {
|
||||
created?: number
|
||||
updated?: number
|
||||
}
|
||||
revert?: {
|
||||
messageID?: string
|
||||
partID?: string
|
||||
snapshot?: string
|
||||
diff?: string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSessions(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.list for instance ${instanceId}`)
|
||||
const response = await instance.client.session.list()
|
||||
|
||||
const sessionMap = new Map<string, Session>()
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingSessions = sessions().get(instanceId)
|
||||
|
||||
for (const apiSession of response.data) {
|
||||
const existingSession = existingSessions?.get(apiSession.id)
|
||||
|
||||
sessionMap.set(apiSession.id, {
|
||||
id: apiSession.id,
|
||||
instanceId,
|
||||
title: apiSession.title || "Untitled",
|
||||
parentId: apiSession.parentID || null,
|
||||
agent: existingSession?.agent ?? "",
|
||||
model: existingSession?.model ?? { providerId: "", modelId: "" },
|
||||
version: apiSession.version,
|
||||
time: {
|
||||
...apiSession.time,
|
||||
},
|
||||
revert: apiSession.revert
|
||||
? {
|
||||
messageID: apiSession.revert.messageID,
|
||||
partID: apiSession.revert.partID,
|
||||
snapshot: apiSession.revert.snapshot,
|
||||
diff: apiSession.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: existingSession?.messages ?? [],
|
||||
messagesInfo: existingSession?.messagesInfo ?? new Map(),
|
||||
})
|
||||
}
|
||||
|
||||
const validSessionIds = new Set(sessionMap.keys())
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionMap)
|
||||
return next
|
||||
})
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
const filtered = new Set<string>()
|
||||
for (const id of loadedSet) {
|
||||
if (validSessionIds.has(id)) {
|
||||
filtered.add(id)
|
||||
}
|
||||
}
|
||||
next.set(instanceId, filtered)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
for (const session of sessionMap.values()) {
|
||||
const flag = (session.time as (Session["time"] & { compacting?: number | boolean }) | undefined)?.compacting
|
||||
const active = typeof flag === "number" ? flag > 0 : Boolean(flag)
|
||||
setSessionCompactionState(instanceId, session.id, active)
|
||||
}
|
||||
|
||||
pruneDraftPrompts(instanceId, new Set(sessionMap.keys()))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch sessions:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.fetchingSessions.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(instanceId: string, agent?: string): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
const nonSubagents = instanceAgents.filter((a) => a.mode !== "subagent")
|
||||
const selectedAgent = agent || (nonSubagents.length > 0 ? nonSubagents[0].name : "")
|
||||
|
||||
const defaultModel = await getDefaultModel(instanceId, selectedAgent)
|
||||
|
||||
if (selectedAgent && isModelValid(instanceId, defaultModel)) {
|
||||
setAgentModelPreference(instanceId, selectedAgent, defaultModel)
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, true)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] POST /session.create for instance ${instanceId}`)
|
||||
const response = await instance.client.session.create()
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to create session: No data returned")
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
id: response.data.id,
|
||||
instanceId,
|
||||
title: response.data.title || "New Session",
|
||||
parentId: null,
|
||||
agent: selectedAgent,
|
||||
model: defaultModel,
|
||||
version: response.data.version,
|
||||
time: {
|
||||
...response.data.time,
|
||||
},
|
||||
revert: response.data.revert
|
||||
? {
|
||||
messageID: response.data.revert.messageID,
|
||||
partID: response.data.revert.partID,
|
||||
snapshot: response.data.revert.snapshot,
|
||||
diff: response.data.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(session.id, session)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
|
||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||
const initialContextPercent = initialContextWindow > 0 ? 0 : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(session.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: initialContextWindow,
|
||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: initialContextPercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, session.id)
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
console.error("Failed to create session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
next.creatingSession.set(instanceId, false)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function forkSession(
|
||||
instanceId: string,
|
||||
sourceSessionId: string,
|
||||
options?: { messageId?: string },
|
||||
): Promise<Session> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const request: {
|
||||
path: { id: string }
|
||||
body?: { messageID: string }
|
||||
} = {
|
||||
path: { id: sourceSessionId },
|
||||
}
|
||||
|
||||
if (options?.messageId) {
|
||||
request.body = { messageID: options.messageId }
|
||||
}
|
||||
|
||||
console.log(`[HTTP] POST /session.fork for instance ${instanceId}`, request)
|
||||
const response = await instance.client.session.fork(request)
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error("Failed to fork session: No data returned")
|
||||
}
|
||||
|
||||
const info = response.data as SessionForkResponse
|
||||
const forkedSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Forked Session",
|
||||
parentId: info.parentID || null,
|
||||
agent: info.agent || "",
|
||||
model: {
|
||||
providerId: info.model?.providerID || "",
|
||||
modelId: info.model?.modelID || "",
|
||||
},
|
||||
version: "0",
|
||||
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: undefined,
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as unknown as Session
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId) || new Map()
|
||||
instanceSessions.set(forkedSession.id, forkedSession)
|
||||
next.set(instanceId, instanceSessions)
|
||||
return next
|
||||
})
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
|
||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||
const forkContextPercent = forkContextWindow > 0 ? 0 : null
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(forkedSession.id, {
|
||||
tokens: 0,
|
||||
cost: 0,
|
||||
contextWindow: forkContextWindow,
|
||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||
contextUsageTokens: 0,
|
||||
contextUsagePercent: forkContextPercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
|
||||
getSessionIndex(instanceId, forkedSession.id)
|
||||
|
||||
return forkedSession
|
||||
}
|
||||
|
||||
async function deleteSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId) || new Set()
|
||||
deleting.add(sessionId)
|
||||
next.deletingSession.set(instanceId, deleting)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] DELETE /session.delete for instance ${instanceId}`, { sessionId })
|
||||
await instance.client.session.delete({ path: { id: sessionId } })
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceSessions = next.get(instanceId)
|
||||
if (instanceSessions) {
|
||||
instanceSessions.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
setSessionCompactionState(instanceId, sessionId, false)
|
||||
clearSessionDraftPrompt(instanceId, sessionId)
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = next.get(instanceId)
|
||||
if (instanceInfo) {
|
||||
const updatedInstanceInfo = new Map(instanceInfo)
|
||||
updatedInstanceInfo.delete(sessionId)
|
||||
if (updatedInstanceInfo.size === 0) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.set(instanceId, updatedInstanceInfo)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
clearSessionIndex(instanceId, sessionId)
|
||||
|
||||
if (activeSessionId().get(instanceId) === sessionId) {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const deleting = next.deletingSession.get(instanceId)
|
||||
if (deleting) {
|
||||
deleting.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgents(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /app.agents for instance ${instanceId}`)
|
||||
const response = await instance.client.app.agents()
|
||||
const agentList = (response.data ?? []).map((agent) => ({
|
||||
name: agent.name,
|
||||
description: agent.description || "",
|
||||
mode: agent.mode,
|
||||
model: agent.model?.modelID
|
||||
? {
|
||||
providerId: agent.model.providerID || "",
|
||||
modelId: agent.model.modelID,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
setAgents((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, agentList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agents:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviders(instanceId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /config.providers for instance ${instanceId}`)
|
||||
const response = await instance.client.config.providers()
|
||||
if (!response.data) return
|
||||
|
||||
const providerList = response.data.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
defaultModelId: response.data?.default?.[provider.id],
|
||||
models: Object.entries(provider.models).map(([id, model]) => ({
|
||||
id,
|
||||
name: model.name,
|
||||
providerId: provider.id,
|
||||
limit: model.limit,
|
||||
cost: model.cost,
|
||||
})),
|
||||
}))
|
||||
|
||||
setProviders((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, providerList)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch providers:", error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(instanceId: string, sessionId: string, force = false): Promise<void> {
|
||||
if (force) {
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId)
|
||||
if (loadedSet) {
|
||||
loadedSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const alreadyLoaded = messagesLoaded().get(instanceId)?.has(sessionId)
|
||||
if (alreadyLoaded && !force) {
|
||||
return
|
||||
}
|
||||
|
||||
const isLoading = loading().loadingMessages.get(instanceId)?.has(sessionId)
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
throw new Error("Instance not ready")
|
||||
}
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionId)
|
||||
if (!session) {
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId) || new Set()
|
||||
loadingSet.add(sessionId)
|
||||
next.loadingMessages.set(instanceId, loadingSet)
|
||||
return next
|
||||
})
|
||||
|
||||
try {
|
||||
console.log(`[HTTP] GET /session.messages for instance ${instanceId}`, { sessionId })
|
||||
const response = await instance.client.session.messages({ path: { id: sessionId } })
|
||||
|
||||
if (!response.data || !Array.isArray(response.data)) {
|
||||
return
|
||||
}
|
||||
|
||||
const messagesInfo = new Map<string, any>()
|
||||
const messages: Message[] = response.data.map((apiMessage: any) => {
|
||||
const info = apiMessage.info || apiMessage
|
||||
const role = info.role || "assistant"
|
||||
const messageId = info.id || String(Date.now())
|
||||
|
||||
messagesInfo.set(messageId, info)
|
||||
|
||||
const parts: any[] = (apiMessage.parts || []).map((part: any) => normalizeMessagePart(part))
|
||||
|
||||
const message: Message = {
|
||||
id: messageId,
|
||||
sessionId,
|
||||
type: role === "user" ? "user" : "assistant",
|
||||
parts,
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
parts.forEach((part: any) => initializePartVersion(part))
|
||||
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
return message
|
||||
})
|
||||
|
||||
let agentName = ""
|
||||
let providerID = ""
|
||||
let modelID = ""
|
||||
|
||||
for (let i = response.data.length - 1; i >= 0; i--) {
|
||||
const apiMessage = response.data[i]
|
||||
const info = apiMessage.info || apiMessage
|
||||
|
||||
if (info.role === "assistant") {
|
||||
agentName = (info as any).mode || (info as any).agent || ""
|
||||
providerID = (info as any).providerID || ""
|
||||
modelID = (info as any).modelID || ""
|
||||
if (agentName && providerID && modelID) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentName && !providerID && !modelID) {
|
||||
const defaultModel = await getDefaultModel(instanceId, session.agent)
|
||||
agentName = session.agent
|
||||
providerID = defaultModel.providerId
|
||||
modelID = defaultModel.modelId
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const nextInstanceSessions = next.get(instanceId)
|
||||
if (nextInstanceSessions) {
|
||||
const existingSession = nextInstanceSessions.get(sessionId)
|
||||
if (existingSession) {
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
messages,
|
||||
messagesInfo,
|
||||
agent: agentName || existingSession.agent,
|
||||
model: providerID && modelID ? { providerId: providerID, modelId: modelID } : existingSession.model,
|
||||
}
|
||||
const updatedInstanceSessions = new Map(nextInstanceSessions)
|
||||
updatedInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, updatedInstanceSessions)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
rebuildSessionIndex(instanceId, sessionId, messages)
|
||||
|
||||
setMessagesLoaded((prev) => {
|
||||
const next = new Map(prev)
|
||||
const loadedSet = next.get(instanceId) || new Set()
|
||||
loadedSet.add(sessionId)
|
||||
next.set(instanceId, loadedSet)
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load messages:", error)
|
||||
throw error
|
||||
} finally {
|
||||
setLoading((prev) => {
|
||||
const next = { ...prev }
|
||||
const loadingSet = next.loadingMessages.get(instanceId)
|
||||
if (loadingSet) {
|
||||
loadingSet.delete(sessionId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
updateSessionInfo(instanceId, sessionId)
|
||||
refreshPermissionsForSession(instanceId, sessionId)
|
||||
}
|
||||
|
||||
|
||||
export {
|
||||
createSession,
|
||||
deleteSession,
|
||||
fetchAgents,
|
||||
fetchProviders,
|
||||
fetchSessions,
|
||||
forkSession,
|
||||
loadMessages,
|
||||
}
|
||||
507
src/stores/session-events.ts
Normal file
507
src/stores/session-events.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
import type {
|
||||
MessagePartRemovedEvent,
|
||||
MessagePartUpdatedEvent,
|
||||
MessageRemovedEvent,
|
||||
MessageUpdateEvent,
|
||||
} from "../types/message"
|
||||
import type {
|
||||
EventPermissionReplied,
|
||||
EventPermissionUpdated,
|
||||
EventSessionCompacted,
|
||||
EventSessionError,
|
||||
EventSessionIdle,
|
||||
EventSessionUpdated,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
import { showToastNotification, ToastVariant } from "../lib/notifications"
|
||||
import { preferences } from "./preferences"
|
||||
import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances"
|
||||
import {
|
||||
sessions,
|
||||
setSessions,
|
||||
withSession,
|
||||
} from "./session-state"
|
||||
import {
|
||||
bumpPartVersion,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
updateSessionInfo,
|
||||
} from "./session-messages"
|
||||
import { loadMessages } from "./session-api"
|
||||
import { setSessionCompactionState } from "./session-compaction"
|
||||
|
||||
interface TuiToastEvent {
|
||||
type: "tui.toast.show"
|
||||
properties: {
|
||||
title?: string
|
||||
message: string
|
||||
variant: "info" | "success" | "warning" | "error"
|
||||
duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_TOAST_VARIANTS = new Set<ToastVariant>(["info", "success", "warning", "error"])
|
||||
|
||||
function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | MessagePartUpdatedEvent): void {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
if (event.type === "message.part.updated") {
|
||||
const rawPart = event.properties?.part
|
||||
if (!rawPart) return
|
||||
|
||||
const part = normalizeMessagePart(rawPart)
|
||||
|
||||
const session = instanceSessions.get(part.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, part.sessionID)
|
||||
let messageIndex = index.messageIndex.get(part.messageID)
|
||||
let replacedTemp = false
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === part.sessionID && msg.status === "sending") {
|
||||
messageIndex = i
|
||||
replacedTemp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
const newMessage: any = {
|
||||
id: part.messageID,
|
||||
sessionId: part.sessionID,
|
||||
type: "assistant" as const,
|
||||
parts: [part],
|
||||
timestamp: Date.now(),
|
||||
status: "streaming" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
initializePartVersion(part)
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, part.sessionID, session.messages)
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
let filteredSynthetics = false
|
||||
if (message.parts.some((partItem: any) => partItem.synthetic === true)) {
|
||||
message.parts = message.parts.filter((partItem: any) => partItem.synthetic !== true)
|
||||
filteredSynthetics = true
|
||||
message.parts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let baseParts: any[]
|
||||
if (replacedTemp) {
|
||||
baseParts = message.parts.filter((partItem: any) => partItem.type !== "text")
|
||||
message.parts = baseParts
|
||||
baseParts.forEach((partItem: any) => {
|
||||
if (partItem.type === "text") {
|
||||
partItem.renderCache = undefined
|
||||
}
|
||||
})
|
||||
} else {
|
||||
baseParts = message.parts
|
||||
}
|
||||
|
||||
let partMap = index.partIndex.get(message.id)
|
||||
if (!partMap) {
|
||||
partMap = new Map()
|
||||
index.partIndex.set(message.id, partMap)
|
||||
}
|
||||
|
||||
let shouldIncrementVersion = filteredSynthetics || replacedTemp
|
||||
const partIndex = partMap.get(part.id)
|
||||
|
||||
if (partIndex === undefined) {
|
||||
initializePartVersion(part)
|
||||
baseParts.push(part)
|
||||
if (part.id && typeof part.id === "string") {
|
||||
partMap.set(part.id, baseParts.length - 1)
|
||||
}
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
} else {
|
||||
const previousPart = baseParts[partIndex]
|
||||
const textUnchanged =
|
||||
!filteredSynthetics &&
|
||||
!replacedTemp &&
|
||||
part.type === "text" &&
|
||||
previousPart?.type === "text" &&
|
||||
previousPart.text === part.text
|
||||
|
||||
if (textUnchanged) {
|
||||
return
|
||||
}
|
||||
|
||||
bumpPartVersion(previousPart, part)
|
||||
baseParts[partIndex] = part
|
||||
if (part.type !== "text" || !previousPart || previousPart.text !== part.text) {
|
||||
shouldIncrementVersion = true
|
||||
if (part.type === "text") {
|
||||
part.renderCache = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = replacedTemp ? part.messageID : message.id
|
||||
message.status = message.status === "sending" ? "streaming" : message.status
|
||||
message.parts = baseParts
|
||||
|
||||
if (shouldIncrementVersion) {
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
} else if (
|
||||
!message.displayParts ||
|
||||
message.displayParts.showThinking !== preferences().showThinkingBlocks ||
|
||||
message.displayParts.version !== message.version
|
||||
) {
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, messageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredSynthetics || replacedTemp) {
|
||||
const refreshed = new Map<string, number>()
|
||||
message.parts.forEach((partItem, idx) => {
|
||||
if (partItem.id && typeof partItem.id === "string") {
|
||||
refreshed.set(partItem.id, idx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
withSession(instanceId, part.sessionID, () => {
|
||||
/* mutations already applied above */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, part.sessionID)
|
||||
refreshPermissionsForSession(instanceId, part.sessionID)
|
||||
} else if (event.type === "message.updated") {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const session = instanceSessions.get(info.sessionID)
|
||||
if (!session) return
|
||||
|
||||
const index = getSessionIndex(instanceId, info.sessionID)
|
||||
let messageIndex = index.messageIndex.get(info.id)
|
||||
|
||||
if (messageIndex === undefined) {
|
||||
let tempMessageIndex = -1
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (
|
||||
msg.sessionId === info.sessionID &&
|
||||
msg.type === (info.role === "user" ? "user" : "assistant") &&
|
||||
msg.status === "sending"
|
||||
) {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (tempMessageIndex === -1) {
|
||||
for (let i = 0; i < session.messages.length; i++) {
|
||||
const msg = session.messages[i]
|
||||
if (msg.sessionId === info.sessionID && msg.status === "sending") {
|
||||
tempMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tempMessageIndex > -1) {
|
||||
const message = session.messages[tempMessageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
|
||||
const oldId = message.id
|
||||
message.id = info.id
|
||||
message.type = (info.role === "user" ? "user" : "assistant") as "user" | "assistant"
|
||||
message.timestamp = info.time?.created || Date.now()
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
|
||||
if (oldId !== message.id) {
|
||||
index.messageIndex.delete(oldId)
|
||||
index.messageIndex.set(message.id, tempMessageIndex)
|
||||
const existingPartMap = index.partIndex.get(oldId)
|
||||
if (existingPartMap) {
|
||||
index.partIndex.delete(oldId)
|
||||
index.partIndex.set(message.id, existingPartMap)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newMessage: any = {
|
||||
id: info.id,
|
||||
sessionId: info.sessionID,
|
||||
type: (info.role === "user" ? "user" : "assistant") as "user" | "assistant",
|
||||
parts: [],
|
||||
timestamp: info.time?.created || Date.now(),
|
||||
status: "complete" as const,
|
||||
version: 0,
|
||||
}
|
||||
|
||||
newMessage.displayParts = computeDisplayParts(newMessage, preferences().showThinkingBlocks)
|
||||
|
||||
let insertIndex = session.messages.length
|
||||
for (let i = session.messages.length - 1; i >= 0; i--) {
|
||||
if (session.messages[i].id < newMessage.id) {
|
||||
insertIndex = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
session.messages.splice(insertIndex, 0, newMessage)
|
||||
rebuildSessionIndex(instanceId, info.sessionID, session.messages)
|
||||
}
|
||||
} else {
|
||||
const message = session.messages[messageIndex]
|
||||
if (typeof message.version !== "number") {
|
||||
message.version = 0
|
||||
}
|
||||
message.status = "complete" as const
|
||||
message.version += 1
|
||||
message.displayParts = computeDisplayParts(message, preferences().showThinkingBlocks)
|
||||
}
|
||||
|
||||
session.messagesInfo.set(info.id, info)
|
||||
withSession(instanceId, info.sessionID, () => {
|
||||
/* ensure reactivity */
|
||||
})
|
||||
|
||||
updateSessionInfo(instanceId, info.sessionID)
|
||||
refreshPermissionsForSession(instanceId, info.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||
const info = event.properties?.info
|
||||
if (!info) return
|
||||
|
||||
const compactingFlag = info.time?.compacting
|
||||
const isCompacting = typeof compactingFlag === "number" ? compactingFlag > 0 : Boolean(compactingFlag)
|
||||
setSessionCompactionState(instanceId, info.id, isCompacting)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const existingSession = instanceSessions.get(info.id)
|
||||
|
||||
if (!existingSession) {
|
||||
const newSession = {
|
||||
id: info.id,
|
||||
instanceId,
|
||||
title: info.title || "Untitled",
|
||||
parentId: info.parentID || null,
|
||||
agent: "",
|
||||
model: {
|
||||
providerId: "",
|
||||
modelId: "",
|
||||
},
|
||||
version: info.version || "0",
|
||||
time: info.time
|
||||
? { ...info.time }
|
||||
: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
messages: [],
|
||||
messagesInfo: new Map(),
|
||||
} as any
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(newSession.id, newSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
|
||||
console.log(`[SSE] New session created: ${info.id}`, newSession)
|
||||
} else {
|
||||
const mergedTime = {
|
||||
...existingSession.time,
|
||||
...(info.time ?? {}),
|
||||
}
|
||||
if (!info.time?.updated) {
|
||||
mergedTime.updated = Date.now()
|
||||
}
|
||||
|
||||
const updatedSession = {
|
||||
...existingSession,
|
||||
title: info.title || existingSession.title,
|
||||
time: mergedTime,
|
||||
revert: info.revert
|
||||
? {
|
||||
messageID: info.revert.messageID,
|
||||
partID: info.revert.partID,
|
||||
snapshot: info.revert.snapshot,
|
||||
diff: info.revert.diff,
|
||||
}
|
||||
: existingSession.revert,
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const updated = new Map(prev.get(instanceId))
|
||||
updated.set(existingSession.id, updatedSession)
|
||||
next.set(instanceId, updated)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleSessionIdle(_instanceId: string, event: EventSessionIdle): void {
|
||||
const sessionId = event.properties?.sessionID
|
||||
if (!sessionId) return
|
||||
|
||||
console.log(`[SSE] Session idle: ${sessionId}`)
|
||||
}
|
||||
|
||||
function handleSessionCompacted(instanceId: string, event: EventSessionCompacted): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Session compacted: ${sessionID}`)
|
||||
|
||||
setSessionCompactionState(instanceId, sessionID, false)
|
||||
|
||||
withSession(instanceId, sessionID, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = 0
|
||||
session.time = time
|
||||
})
|
||||
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
const session = instanceSessions?.get(sessionID)
|
||||
const label = session?.title?.trim() ? session.title : sessionID
|
||||
const instanceFolder = instances().get(instanceId)?.folder ?? instanceId
|
||||
const instanceName = instanceFolder.split(/[\\/]/).filter(Boolean).pop() ?? instanceFolder
|
||||
|
||||
showToastNotification({
|
||||
title: instanceName,
|
||||
message: `Session ${label ? `"${label}"` : sessionID} was compacted`,
|
||||
variant: "info",
|
||||
duration: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
function handleSessionError(_instanceId: string, event: EventSessionError): void {
|
||||
const error = event.properties?.error
|
||||
console.error(`[SSE] Session error:`, error)
|
||||
|
||||
let message = "Unknown error"
|
||||
|
||||
if (error) {
|
||||
if ("data" in error && error.data && typeof error.data === "object" && "message" in error.data) {
|
||||
message = error.data.message as string
|
||||
} else if ("message" in error && typeof error.message === "string") {
|
||||
message = error.message
|
||||
}
|
||||
}
|
||||
|
||||
alert(`Error: ${message}`)
|
||||
}
|
||||
|
||||
function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
}
|
||||
|
||||
function handleMessagePartRemoved(instanceId: string, event: MessagePartRemovedEvent): void {
|
||||
const sessionID = event.properties?.sessionID
|
||||
if (!sessionID) return
|
||||
|
||||
console.log(`[SSE] Message part removed from session ${sessionID}, reloading messages`)
|
||||
loadMessages(instanceId, sessionID, true).catch(console.error)
|
||||
}
|
||||
|
||||
function handleTuiToast(_instanceId: string, event: TuiToastEvent): void {
|
||||
const payload = event?.properties
|
||||
if (!payload || typeof payload.message !== "string" || typeof payload.variant !== "string") return
|
||||
if (!payload.message.trim()) return
|
||||
|
||||
const variant: ToastVariant = ALLOWED_TOAST_VARIANTS.has(payload.variant as ToastVariant)
|
||||
? (payload.variant as ToastVariant)
|
||||
: "info"
|
||||
|
||||
showToastNotification({
|
||||
title: typeof payload.title === "string" ? payload.title : undefined,
|
||||
message: payload.message,
|
||||
variant,
|
||||
duration: typeof payload.duration === "number" ? payload.duration : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function handlePermissionUpdated(instanceId: string, event: EventPermissionUpdated): void {
|
||||
const permission = event.properties
|
||||
if (!permission) return
|
||||
|
||||
console.log(`[SSE] Permission updated: ${permission.id} (${permission.type})`)
|
||||
addPermissionToQueue(instanceId, permission)
|
||||
}
|
||||
|
||||
function handlePermissionReplied(instanceId: string, event: EventPermissionReplied): void {
|
||||
const { permissionID } = event.properties
|
||||
if (!permissionID) return
|
||||
|
||||
console.log(`[SSE] Permission replied: ${permissionID}`)
|
||||
removePermissionFromQueue(instanceId, permissionID)
|
||||
}
|
||||
|
||||
export {
|
||||
handleMessagePartRemoved,
|
||||
handleMessageRemoved,
|
||||
handleMessageUpdate,
|
||||
handlePermissionReplied,
|
||||
handlePermissionUpdated,
|
||||
handleSessionCompacted,
|
||||
handleSessionError,
|
||||
handleSessionIdle,
|
||||
handleSessionUpdate,
|
||||
handleTuiToast,
|
||||
}
|
||||
295
src/stores/session-messages.ts
Normal file
295
src/stores/session-messages.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { Message, MessageDisplayParts } from "../types/message"
|
||||
import { partHasRenderableText } from "../types/message"
|
||||
import type { Provider } from "../types/session"
|
||||
|
||||
import { decodeHtmlEntities } from "../lib/markdown"
|
||||
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
|
||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
||||
|
||||
interface SessionIndexCache {
|
||||
messageIndex: Map<string, number>
|
||||
partIndex: Map<string, Map<string, number>>
|
||||
}
|
||||
|
||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||
|
||||
function decodeTextSegment(segment: any): any {
|
||||
if (typeof segment === "string") {
|
||||
return decodeHtmlEntities(segment)
|
||||
}
|
||||
|
||||
if (segment && typeof segment === "object") {
|
||||
const updated: Record<string, any> = { ...segment }
|
||||
|
||||
if (typeof updated.text === "string") {
|
||||
updated.text = decodeHtmlEntities(updated.text)
|
||||
}
|
||||
|
||||
if (typeof updated.value === "string") {
|
||||
updated.value = decodeHtmlEntities(updated.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(updated.content)) {
|
||||
updated.content = updated.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
return segment
|
||||
}
|
||||
|
||||
function normalizeMessagePart(part: any): any {
|
||||
if (!part || typeof part !== "object") {
|
||||
return part
|
||||
}
|
||||
|
||||
if (part.type !== "text") {
|
||||
return part
|
||||
}
|
||||
|
||||
const normalized: Record<string, any> = { ...part, renderCache: undefined }
|
||||
|
||||
if (typeof normalized.text === "string") {
|
||||
normalized.text = decodeHtmlEntities(normalized.text)
|
||||
} else if (normalized.text && typeof normalized.text === "object") {
|
||||
const textObject: Record<string, any> = { ...normalized.text }
|
||||
|
||||
if (typeof textObject.value === "string") {
|
||||
textObject.value = decodeHtmlEntities(textObject.value)
|
||||
}
|
||||
|
||||
if (Array.isArray(textObject.content)) {
|
||||
textObject.content = textObject.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (typeof textObject.text === "string") {
|
||||
textObject.text = decodeHtmlEntities(textObject.text)
|
||||
}
|
||||
|
||||
normalized.text = textObject
|
||||
}
|
||||
|
||||
if (Array.isArray(normalized.content)) {
|
||||
normalized.content = normalized.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
|
||||
if (normalized.thinking && typeof normalized.thinking === "object") {
|
||||
const thinking: Record<string, any> = { ...normalized.thinking }
|
||||
if (Array.isArray(thinking.content)) {
|
||||
thinking.content = thinking.content.map((item: any) => decodeTextSegment(item))
|
||||
}
|
||||
normalized.thinking = thinking
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function computeDisplayParts(message: Message, showThinking: boolean): MessageDisplayParts {
|
||||
const text: any[] = []
|
||||
const tool: any[] = []
|
||||
const reasoning: any[] = []
|
||||
|
||||
for (const part of message.parts) {
|
||||
if (part.type === "text" && !part.synthetic && partHasRenderableText(part)) {
|
||||
text.push(part)
|
||||
} else if (part.type === "tool") {
|
||||
tool.push(part)
|
||||
} else if (part.type === "reasoning" && showThinking && partHasRenderableText(part)) {
|
||||
reasoning.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
const combined = reasoning.length > 0 ? [...text, ...reasoning] : [...text]
|
||||
const version = typeof message.version === "number" ? message.version : 0
|
||||
|
||||
return { text, tool, reasoning, combined, showThinking, version }
|
||||
}
|
||||
|
||||
function initializePartVersion(part: any, version = 0) {
|
||||
if (!part || typeof part !== "object") return
|
||||
const partAny = part as any
|
||||
if (typeof partAny.version !== "number") {
|
||||
partAny.version = version
|
||||
}
|
||||
}
|
||||
|
||||
function bumpPartVersion(previousPart: any, nextPart: any): number {
|
||||
const prevVersion = typeof previousPart?.version === "number" ? previousPart.version : -1
|
||||
const nextVersion = prevVersion + 1
|
||||
nextPart.version = nextVersion
|
||||
return nextVersion
|
||||
}
|
||||
|
||||
function getSessionIndex(instanceId: string, sessionId: string) {
|
||||
let instanceMap = sessionIndexes.get(instanceId)
|
||||
if (!instanceMap) {
|
||||
instanceMap = new Map()
|
||||
sessionIndexes.set(instanceId, instanceMap)
|
||||
}
|
||||
|
||||
let sessionMap = instanceMap.get(sessionId)
|
||||
if (!sessionMap) {
|
||||
sessionMap = { messageIndex: new Map(), partIndex: new Map() }
|
||||
instanceMap.set(sessionId, sessionMap)
|
||||
}
|
||||
|
||||
return sessionMap
|
||||
}
|
||||
|
||||
function rebuildSessionIndex(instanceId: string, sessionId: string, messages: Message[]) {
|
||||
const index = getSessionIndex(instanceId, sessionId)
|
||||
index.messageIndex.clear()
|
||||
index.partIndex.clear()
|
||||
|
||||
messages.forEach((message, messageIdx) => {
|
||||
index.messageIndex.set(message.id, messageIdx)
|
||||
|
||||
const partMap = new Map<string, number>()
|
||||
message.parts.forEach((part, partIdx) => {
|
||||
if (part.id && typeof part.id === "string") {
|
||||
partMap.set(part.id, partIdx)
|
||||
}
|
||||
})
|
||||
index.partIndex.set(message.id, partMap)
|
||||
})
|
||||
}
|
||||
|
||||
function clearSessionIndex(instanceId: string, sessionId: string) {
|
||||
const instanceMap = sessionIndexes.get(instanceId)
|
||||
if (instanceMap) {
|
||||
instanceMap.delete(sessionId)
|
||||
if (instanceMap.size === 0) {
|
||||
sessionIndexes.delete(instanceId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeSessionIndexes(instanceId: string) {
|
||||
sessionIndexes.delete(instanceId)
|
||||
}
|
||||
|
||||
function updateSessionInfo(instanceId: string, sessionId: string) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
let tokens = 0
|
||||
let cost = 0
|
||||
let contextWindow = 0
|
||||
let isSubscriptionModel = false
|
||||
let modelID = ""
|
||||
let providerID = ""
|
||||
let actualUsageTokens = 0
|
||||
let contextUsagePercent: number | null = null
|
||||
let hasContextUsage = false
|
||||
|
||||
if (session.messagesInfo.size > 0) {
|
||||
const messageArray = Array.from(session.messagesInfo.values()).reverse()
|
||||
|
||||
for (const info of messageArray) {
|
||||
if (info.role === "assistant" && info.tokens) {
|
||||
const usage = info.tokens
|
||||
|
||||
if (usage.output > 0) {
|
||||
const inputTokens = usage.input || 0
|
||||
const reasoningTokens = usage.reasoning || 0
|
||||
const cacheReadTokens = usage.cache?.read || 0
|
||||
const cacheWriteTokens = usage.cache?.write || 0
|
||||
const outputTokens = usage.output || 0
|
||||
|
||||
if (info.summary) {
|
||||
tokens = outputTokens
|
||||
} else {
|
||||
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||
}
|
||||
|
||||
cost = info.cost || 0
|
||||
actualUsageTokens = tokens
|
||||
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
||||
|
||||
modelID = info.modelID || ""
|
||||
providerID = info.providerID || ""
|
||||
isSubscriptionModel = cost === 0
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
|
||||
const sessionModel = session.model
|
||||
let selectedModel: Provider["models"][number] | undefined
|
||||
|
||||
if (sessionModel?.providerId && sessionModel?.modelId) {
|
||||
const provider = instanceProviders.find((p) => p.id === sessionModel.providerId)
|
||||
selectedModel = provider?.models.find((m) => m.id === sessionModel.modelId)
|
||||
}
|
||||
|
||||
if (!selectedModel && modelID && providerID) {
|
||||
const provider = instanceProviders.find((p) => p.id === providerID)
|
||||
selectedModel = provider?.models.find((m) => m.id === modelID)
|
||||
}
|
||||
|
||||
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
|
||||
|
||||
if (selectedModel) {
|
||||
if (selectedModel.limit?.context) {
|
||||
contextWindow = selectedModel.limit.context
|
||||
}
|
||||
|
||||
if (selectedModel.limit?.output && selectedModel.limit.output > 0) {
|
||||
modelOutputLimit = selectedModel.limit.output
|
||||
}
|
||||
|
||||
if (selectedModel.cost?.input === 0 && selectedModel.cost?.output === 0) {
|
||||
isSubscriptionModel = true
|
||||
}
|
||||
}
|
||||
|
||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||
let contextUsageTokens = 0
|
||||
|
||||
if (hasContextUsage && actualUsageTokens > 0) {
|
||||
contextUsageTokens = actualUsageTokens + outputBudget
|
||||
if (contextWindow > 0) {
|
||||
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
|
||||
contextUsagePercent = Math.min(100, Math.max(0, percent))
|
||||
} else {
|
||||
contextUsagePercent = null
|
||||
}
|
||||
} else {
|
||||
contextUsagePercent = contextWindow > 0 ? 0 : null
|
||||
}
|
||||
|
||||
setSessionInfoByInstance((prev) => {
|
||||
const next = new Map(prev)
|
||||
const instanceInfo = new Map(prev.get(instanceId))
|
||||
instanceInfo.set(sessionId, {
|
||||
tokens,
|
||||
cost,
|
||||
contextWindow,
|
||||
isSubscriptionModel,
|
||||
contextUsageTokens,
|
||||
contextUsagePercent,
|
||||
})
|
||||
next.set(instanceId, instanceInfo)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
bumpPartVersion,
|
||||
clearSessionIndex,
|
||||
computeDisplayParts,
|
||||
getSessionIndex,
|
||||
initializePartVersion,
|
||||
normalizeMessagePart,
|
||||
rebuildSessionIndex,
|
||||
removeSessionIndexes,
|
||||
updateSessionInfo,
|
||||
}
|
||||
83
src/stores/session-models.ts
Normal file
83
src/stores/session-models.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { agents, providers } from "./session-state"
|
||||
import { preferences, getAgentModelPreference } from "./preferences"
|
||||
|
||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||
|
||||
function isModelValid(
|
||||
instanceId: string,
|
||||
model?: { providerId: string; modelId: string } | null,
|
||||
): model is { providerId: string; modelId: string } {
|
||||
if (!model?.providerId || !model.modelId) return false
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const provider = instanceProviders.find((p) => p.id === model.providerId)
|
||||
if (!provider) return false
|
||||
return provider.models.some((item) => item.id === model.modelId)
|
||||
}
|
||||
|
||||
function getRecentModelPreferenceForInstance(
|
||||
instanceId: string,
|
||||
): { providerId: string; modelId: string } | undefined {
|
||||
const recents = preferences().modelRecents ?? []
|
||||
for (const item of recents) {
|
||||
if (isModelValid(instanceId, item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getDefaultModel(
|
||||
instanceId: string,
|
||||
agentName?: string,
|
||||
): Promise<{ providerId: string; modelId: string }> {
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const instanceAgents = agents().get(instanceId) || []
|
||||
|
||||
if (agentName) {
|
||||
const stored = getAgentModelPreference(instanceId, agentName)
|
||||
if (isModelValid(instanceId, stored)) {
|
||||
return stored
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
const agent = instanceAgents.find((a) => a.name === agentName)
|
||||
if (agent && agent.model && isModelValid(instanceId, agent.model)) {
|
||||
return {
|
||||
providerId: agent.model.providerId,
|
||||
modelId: agent.model.modelId,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const recent = getRecentModelPreferenceForInstance(instanceId)
|
||||
if (recent) {
|
||||
return recent
|
||||
}
|
||||
|
||||
for (const provider of instanceProviders) {
|
||||
if (provider.defaultModelId) {
|
||||
const model = provider.models.find((m) => m.id === provider.defaultModelId)
|
||||
if (model) {
|
||||
return {
|
||||
providerId: provider.id,
|
||||
modelId: model.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (instanceProviders.length > 0) {
|
||||
const firstProvider = instanceProviders[0]
|
||||
const firstModel = firstProvider.models[0]
|
||||
if (firstModel) {
|
||||
return {
|
||||
providerId: firstProvider.id,
|
||||
modelId: firstModel.id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { providerId: "", modelId: "" }
|
||||
}
|
||||
|
||||
export { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, getRecentModelPreferenceForInstance, isModelValid }
|
||||
261
src/stores/session-state.ts
Normal file
261
src/stores/session-state.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
import type { Session, Agent, Provider } from "../types/session"
|
||||
|
||||
export interface SessionInfo {
|
||||
tokens: number
|
||||
cost: number
|
||||
contextWindow: number
|
||||
isSubscriptionModel: boolean
|
||||
contextUsageTokens: number
|
||||
contextUsagePercent: number | null
|
||||
}
|
||||
|
||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||
const [activeSessionId, setActiveSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [activeParentSessionId, setActiveParentSessionId] = createSignal<Map<string, string>>(new Map())
|
||||
const [agents, setAgents] = createSignal<Map<string, Agent[]>>(new Map())
|
||||
const [providers, setProviders] = createSignal<Map<string, Provider[]>>(new Map())
|
||||
const [sessionDraftPrompts, setSessionDraftPrompts] = createSignal<Map<string, string>>(new Map())
|
||||
|
||||
const [loading, setLoading] = createSignal({
|
||||
fetchingSessions: new Map<string, boolean>(),
|
||||
creatingSession: new Map<string, boolean>(),
|
||||
deletingSession: new Map<string, Set<string>>(),
|
||||
loadingMessages: new Map<string, Set<string>>(),
|
||||
})
|
||||
|
||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||
|
||||
function getDraftKey(instanceId: string, sessionId: string): string {
|
||||
return `${instanceId}:${sessionId}`
|
||||
}
|
||||
|
||||
function getSessionDraftPrompt(instanceId: string, sessionId: string): string {
|
||||
if (!instanceId || !sessionId) return ""
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
return sessionDraftPrompts().get(key) ?? ""
|
||||
}
|
||||
|
||||
function setSessionDraftPrompt(instanceId: string, sessionId: string, value: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (!value) {
|
||||
next.delete(key)
|
||||
} else {
|
||||
next.set(key, value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearSessionDraftPrompt(instanceId: string, sessionId: string) {
|
||||
const key = getDraftKey(instanceId, sessionId)
|
||||
setSessionDraftPrompts((prev) => {
|
||||
if (!prev.has(key)) return prev
|
||||
const next = new Map(prev)
|
||||
next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function clearInstanceDraftPrompts(instanceId: string) {
|
||||
if (!instanceId) return
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function pruneDraftPrompts(instanceId: string, validSessionIds: Set<string>) {
|
||||
setSessionDraftPrompts((prev) => {
|
||||
let changed = false
|
||||
const next = new Map(prev)
|
||||
const prefix = `${instanceId}:`
|
||||
for (const key of Array.from(next.keys())) {
|
||||
if (key.startsWith(prefix)) {
|
||||
const sessionId = key.slice(prefix.length)
|
||||
if (!validSessionIds.has(sessionId)) {
|
||||
next.delete(key)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}
|
||||
|
||||
function withSession(instanceId: string, sessionId: string, updater: (session: Session) => void) {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return
|
||||
|
||||
const session = instanceSessions.get(sessionId)
|
||||
if (!session) return
|
||||
|
||||
updater(session)
|
||||
|
||||
const updatedSession = {
|
||||
...session,
|
||||
messages: [...session.messages],
|
||||
messagesInfo: new Map(session.messagesInfo),
|
||||
}
|
||||
|
||||
setSessions((prev) => {
|
||||
const next = new Map(prev)
|
||||
const newInstanceSessions = new Map(instanceSessions)
|
||||
newInstanceSessions.set(sessionId, updatedSession)
|
||||
next.set(instanceId, newInstanceSessions)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionCompactionState(instanceId: string, sessionId: string, isCompacting: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
const time = { ...(session.time ?? {}) }
|
||||
time.compacting = isCompacting ? Date.now() : 0
|
||||
session.time = time
|
||||
})
|
||||
}
|
||||
|
||||
function setSessionPendingPermission(instanceId: string, sessionId: string, pending: boolean): void {
|
||||
withSession(instanceId, sessionId, (session) => {
|
||||
if (session.pendingPermission === pending) return
|
||||
session.pendingPermission = pending
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveSession(instanceId: string, sessionId: string): void {
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, sessionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function setActiveParentSession(instanceId: string, parentSessionId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(instanceId, parentSessionId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSession(instanceId, parentSessionId)
|
||||
}
|
||||
|
||||
function clearActiveParentSession(instanceId: string): void {
|
||||
setActiveParentSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
|
||||
setActiveSessionId((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(instanceId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function getActiveParentSession(instanceId: string): Session | null {
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(parentId) || null
|
||||
}
|
||||
|
||||
function getActiveSession(instanceId: string): Session | null {
|
||||
const sessionId = activeSessionId().get(instanceId)
|
||||
if (!sessionId) return null
|
||||
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions?.get(sessionId) || null
|
||||
}
|
||||
|
||||
function getSessions(instanceId: string): Session[] {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
return instanceSessions ? Array.from(instanceSessions.values()) : []
|
||||
}
|
||||
|
||||
function getParentSessions(instanceId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === null)
|
||||
}
|
||||
|
||||
function getChildSessions(instanceId: string, parentId: string): Session[] {
|
||||
const allSessions = getSessions(instanceId)
|
||||
return allSessions.filter((s) => s.parentId === parentId)
|
||||
}
|
||||
|
||||
function getSessionFamily(instanceId: string, parentId: string): Session[] {
|
||||
const parent = sessions().get(instanceId)?.get(parentId)
|
||||
if (!parent) return []
|
||||
|
||||
const children = getChildSessions(instanceId, parentId)
|
||||
return [parent, ...children]
|
||||
}
|
||||
|
||||
function isSessionBusy(instanceId: string, sessionId: string): boolean {
|
||||
const instanceSessions = sessions().get(instanceId)
|
||||
if (!instanceSessions) return false
|
||||
if (!instanceSessions.has(sessionId)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isSessionMessagesLoading(instanceId: string, sessionId: string): boolean {
|
||||
return Boolean(loading().loadingMessages.get(instanceId)?.has(sessionId))
|
||||
}
|
||||
|
||||
function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | undefined {
|
||||
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||
}
|
||||
|
||||
export {
|
||||
sessions,
|
||||
setSessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
activeParentSessionId,
|
||||
setActiveParentSessionId,
|
||||
agents,
|
||||
setAgents,
|
||||
providers,
|
||||
setProviders,
|
||||
loading,
|
||||
setLoading,
|
||||
messagesLoaded,
|
||||
setMessagesLoaded,
|
||||
sessionInfoByInstance,
|
||||
setSessionInfoByInstance,
|
||||
getSessionDraftPrompt,
|
||||
setSessionDraftPrompt,
|
||||
clearSessionDraftPrompt,
|
||||
clearInstanceDraftPrompts,
|
||||
pruneDraftPrompts,
|
||||
withSession,
|
||||
setSessionCompactionState,
|
||||
setSessionPendingPermission,
|
||||
setActiveSession,
|
||||
|
||||
setActiveParentSession,
|
||||
clearActiveParentSession,
|
||||
getActiveSession,
|
||||
getActiveParentSession,
|
||||
getSessions,
|
||||
getParentSessions,
|
||||
getChildSessions,
|
||||
getSessionFamily,
|
||||
isSessionBusy,
|
||||
isSessionMessagesLoading,
|
||||
getSessionInfo,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
46
src/styles/components/badges.css
Normal file
46
src/styles/components/badges.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/* Badge + status utilities */
|
||||
.neutral-badge {
|
||||
@apply inline-flex items-center rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--badge-neutral-text);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@apply inline-flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-badge.ready {
|
||||
background-color: var(--status-ready-bg);
|
||||
color: var(--status-ready-fg);
|
||||
}
|
||||
|
||||
.status-badge.starting {
|
||||
background-color: var(--status-starting-bg);
|
||||
color: var(--status-starting-fg);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.status-badge.stopped {
|
||||
background-color: var(--status-stopped-bg);
|
||||
color: var(--status-stopped-fg);
|
||||
}
|
||||
|
||||
.status-dot.ready {
|
||||
background-color: var(--status-ready-fg);
|
||||
}
|
||||
|
||||
.status-dot.starting {
|
||||
background-color: var(--status-starting-fg);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background-color: var(--status-error-fg);
|
||||
}
|
||||
|
||||
.status-dot.stopped {
|
||||
background-color: var(--status-stopped-fg);
|
||||
}
|
||||
56
src/styles/components/buttons.css
Normal file
56
src/styles/components/buttons.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* Button component styles */
|
||||
.button-primary,
|
||||
button.button-primary {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--button-primary-bg);
|
||||
color: var(--button-primary-text);
|
||||
border-color: var(--button-primary-bg);
|
||||
}
|
||||
|
||||
.button-primary:hover:not(:disabled),
|
||||
button.button-primary:hover:not(:disabled) {
|
||||
background-color: var(--button-primary-hover-bg);
|
||||
border-color: var(--button-primary-hover-bg);
|
||||
}
|
||||
|
||||
.button-primary:focus-visible,
|
||||
button.button-primary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-secondary,
|
||||
button.button-secondary {
|
||||
@apply px-6 py-3 text-base rounded-lg;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.button-secondary:hover:not(:disabled),
|
||||
button.button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.button-secondary:focus-visible,
|
||||
button.button-secondary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
.button-tertiary,
|
||||
button.button-tertiary {
|
||||
@apply px-4 py-2 text-sm rounded-lg;
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.button-tertiary:hover:not(:disabled),
|
||||
button.button-tertiary:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.button-tertiary:focus-visible,
|
||||
button.button-tertiary:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
72
src/styles/components/dropdown.css
Normal file
72
src/styles/components/dropdown.css
Normal file
@@ -0,0 +1,72 @@
|
||||
/* Dropdown utilities */
|
||||
.dropdown-surface {
|
||||
@apply absolute w-full rounded-md shadow-lg z-50;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
@apply px-3 py-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.dropdown-header-title {
|
||||
@apply text-xs font-medium;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-section-header {
|
||||
@apply px-3 py-1.5 text-xs font-semibold;
|
||||
color: var(--text-muted);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
@apply overflow-y-auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
@apply cursor-pointer px-3 py-2 transition-colors;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.dropdown-item-highlight {
|
||||
background-color: var(--dropdown-highlight-bg);
|
||||
color: var(--dropdown-highlight-text);
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
@apply px-3 py-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-loading {
|
||||
@apply p-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-footer {
|
||||
@apply border-t px-3 py-2 text-xs;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-badge {
|
||||
@apply rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dropdown-icon-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
29
src/styles/components/env-vars.css
Normal file
29
src/styles/components/env-vars.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/* Environment variables display */
|
||||
.env-vars-container {
|
||||
@apply px-4 py-3 border-b;
|
||||
background-color: var(--env-vars-bg);
|
||||
border-color: var(--env-vars-border);
|
||||
}
|
||||
|
||||
.env-vars-title {
|
||||
@apply text-xs font-medium mb-2;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-item {
|
||||
@apply flex items-center gap-2 text-xs;
|
||||
}
|
||||
|
||||
.env-var-key {
|
||||
@apply font-mono font-medium min-w-0 flex-1;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-separator {
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
|
||||
.env-var-value {
|
||||
@apply font-mono min-w-0 flex-1;
|
||||
color: var(--env-vars-text);
|
||||
}
|
||||
32
src/styles/components/folder-loading.css
Normal file
32
src/styles/components/folder-loading.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Folder loading overlay */
|
||||
.folder-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--folder-overlay-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.folder-loading-indicator {
|
||||
@apply flex flex-col items-center gap-3 text-center;
|
||||
padding: 24px 32px;
|
||||
border-radius: var(--folder-card-radius);
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: var(--folder-card-shadow);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.folder-loading-text {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.folder-loading-subtext {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
252
src/styles/components/selector.css
Normal file
252
src/styles/components/selector.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* Selector component utilities */
|
||||
.selector-trigger {
|
||||
@apply inline-flex items-center justify-between gap-2 px-2 py-1 border rounded outline-none transition-colors text-xs min-w-[180px];
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-trigger:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-trigger:focus {
|
||||
@apply ring-2;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-trigger-disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.selector-trigger-label {
|
||||
@apply flex flex-col min-w-0;
|
||||
}
|
||||
|
||||
.selector-trigger-label--stacked {
|
||||
@apply items-start;
|
||||
}
|
||||
|
||||
.selector-trigger-primary {
|
||||
@apply text-sm font-medium truncate;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-trigger-primary--align-left {
|
||||
@apply text-left w-full;
|
||||
}
|
||||
|
||||
.selector-trigger-secondary {
|
||||
@apply text-xs text-left truncate;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-icon {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-popover {
|
||||
@apply rounded-md shadow-lg overflow-hidden z-50 min-w-[300px];
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.selector-search-container {
|
||||
@apply p-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.selector-search-input {
|
||||
@apply w-full px-3 py-1.5 text-xs border rounded outline-none transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-search-input:focus {
|
||||
@apply ring-2;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-listbox {
|
||||
@apply max-h-64 overflow-auto p-1;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.selector-option {
|
||||
@apply px-3 py-2 cursor-pointer rounded outline-none transition-colors flex items-start gap-2 w-full;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-option:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-option[data-highlighted],
|
||||
.selector-option[data-focused] {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.selector-option[data-selected],
|
||||
.selector-option-selected {
|
||||
background-color: var(--selection-highlight-strong-bg);
|
||||
}
|
||||
|
||||
.selector-option-content {
|
||||
@apply flex flex-col flex-1 min-w-0;
|
||||
}
|
||||
|
||||
.selector-option-label {
|
||||
@apply font-medium text-sm;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-option-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-option .remove-binary-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.selector-option:hover .remove-binary-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.remove-binary-button {
|
||||
@apply p-1 rounded transition-all;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.remove-binary-button:hover {
|
||||
background-color: var(--danger-soft-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-option-indicator {
|
||||
@apply flex-shrink-0 mt-0.5;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-section {
|
||||
@apply px-3 py-2 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.selector-section-title {
|
||||
@apply text-xs font-medium;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-badge {
|
||||
@apply rounded px-1.5 py-0.5 text-xs font-normal;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.selector-badge-version {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-badge-time {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-validation-error {
|
||||
@apply p-2 rounded border;
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-validation-error-content {
|
||||
@apply flex items-start gap-2;
|
||||
}
|
||||
|
||||
.selector-validation-error-icon {
|
||||
@apply w-4 h-4 mt-0.5 flex-shrink-0;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.selector-validation-error-text {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.selector-input-group {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
|
||||
.selector-input {
|
||||
@apply flex-1 px-2 py-1.5 text-sm border rounded outline-none transition-colors;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-input:focus {
|
||||
@apply ring-1;
|
||||
ring-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.selector-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-button {
|
||||
@apply px-3 py-1.5 text-sm rounded transition-colors cursor-pointer w-full inline-flex items-center justify-center font-medium;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.selector-button-primary {
|
||||
background-color: var(--accent-primary) !important;
|
||||
color: var(--text-inverted) !important;
|
||||
border: 1px solid var(--accent-primary) !important;
|
||||
}
|
||||
|
||||
.selector-button-primary:hover:not(:disabled) {
|
||||
background-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.selector-button-primary:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
background-color: var(--surface-muted);
|
||||
}
|
||||
|
||||
.selector-button-secondary {
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.selector-button-secondary:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.selector-button-secondary:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.selector-empty-state {
|
||||
@apply p-4 text-center text-sm;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-loading {
|
||||
@apply flex items-center gap-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-loading-spinner {
|
||||
@apply w-4 h-4 animate-spin;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
6
src/styles/controls.css
Normal file
6
src/styles/controls.css
Normal file
@@ -0,0 +1,6 @@
|
||||
@import "./components/buttons.css";
|
||||
@import "./components/badges.css";
|
||||
@import "./components/folder-loading.css";
|
||||
@import "./components/dropdown.css";
|
||||
@import "./components/selector.css";
|
||||
@import "./components/env-vars.css";
|
||||
@@ -189,8 +189,8 @@
|
||||
padding: 4px 8px;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
/* border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px; */
|
||||
}
|
||||
|
||||
.code-block-language {
|
||||
@@ -236,8 +236,7 @@
|
||||
padding: 12px !important;
|
||||
overflow-x: auto;
|
||||
background-color: transparent !important;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.markdown-code-block code {
|
||||
|
||||
166
src/styles/messaging.css
Normal file
166
src/styles/messaging.css
Normal file
@@ -0,0 +1,166 @@
|
||||
@import "./messaging/message-base.css";
|
||||
@import "./messaging/prompt-input.css";
|
||||
@import "./messaging/message-stream.css";
|
||||
@import "./messaging/tool-call.css";
|
||||
@import "./messaging/log-view.css";
|
||||
|
||||
/* Message item base styles */
|
||||
|
||||
.message-item-base {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
|
||||
/* Message state badges */
|
||||
.message-queued-badge {
|
||||
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
/* Message error block */
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
/* Message state indicators */
|
||||
.message-generating {
|
||||
@apply text-sm italic py-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-sending {
|
||||
@apply text-xs italic mt-1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
@apply text-xs mt-1;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.generating-spinner {
|
||||
@apply inline-block;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Message stream component utilities */
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-text-assistant {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-code);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Message error part */
|
||||
.message-error-part {
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-base);
|
||||
padding: 8px;
|
||||
background-color: var(--message-error-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Message reasoning */
|
||||
.message-reasoning {
|
||||
@apply my-2 border rounded;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.reasoning-container {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
@apply text-xs;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
74
src/styles/messaging/log-view.css
Normal file
74
src/styles/messaging/log-view.css
Normal file
@@ -0,0 +1,74 @@
|
||||
/* Log view utilities */
|
||||
.log-container {
|
||||
@apply flex flex-col h-full;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.log-header {
|
||||
@apply flex items-center justify-between px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.log-content {
|
||||
@apply flex-1 overflow-y-auto p-4 font-mono text-xs leading-relaxed;
|
||||
background-color: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
@apply flex gap-3 py-0.5 px-2 -mx-2 rounded transition-colors;
|
||||
}
|
||||
|
||||
.log-entry:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
@apply select-none shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-message {
|
||||
@apply break-all;
|
||||
}
|
||||
|
||||
.log-level-error {
|
||||
color: var(--log-level-error);
|
||||
}
|
||||
|
||||
.log-level-warn {
|
||||
color: var(--log-level-warn);
|
||||
}
|
||||
|
||||
.log-level-debug {
|
||||
color: var(--log-level-debug);
|
||||
}
|
||||
|
||||
.log-level-default {
|
||||
color: var(--log-level-default);
|
||||
}
|
||||
|
||||
.log-empty-state {
|
||||
@apply text-center py-8;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.log-paused-state {
|
||||
@apply flex flex-col items-center justify-center gap-3 text-center py-10 px-6;
|
||||
border: 1px dashed var(--border-base);
|
||||
border-radius: 12px;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.log-paused-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.log-paused-description {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
max-width: 320px;
|
||||
}
|
||||
98
src/styles/messaging/message-base.css
Normal file
98
src/styles/messaging/message-base.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* Message item base styles */
|
||||
.message-item-base {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
}
|
||||
|
||||
.assistant-message {
|
||||
/* gap: 0.25rem; */
|
||||
padding: 0.6rem 0.65rem;
|
||||
}
|
||||
|
||||
.message-queued-badge {
|
||||
@apply inline-block font-bold px-3 py-1 rounded mb-3 text-xs tracking-wide;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.message-error-block {
|
||||
@apply text-sm p-3 rounded border-l-[3px] my-2;
|
||||
color: var(--status-error);
|
||||
background-color: var(--message-error-bg);
|
||||
border-color: var(--status-error);
|
||||
}
|
||||
|
||||
.message-generating {
|
||||
@apply text-sm italic py-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-sending {
|
||||
@apply text-xs italic mt-1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-error {
|
||||
@apply text-xs mt-1;
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.generating-spinner {
|
||||
@apply inline-block;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-normal);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-text-assistant {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.message-text pre {
|
||||
overflow-x: auto;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-code);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-error-part {
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-base);
|
||||
padding: 8px;
|
||||
background-color: var(--message-error-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message-reasoning {
|
||||
@apply my-2 border rounded;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.reasoning-container {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.reasoning-header {
|
||||
@apply flex items-center gap-1.5 text-xs cursor-pointer select-none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
@apply text-xs;
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
71
src/styles/messaging/message-stream.css
Normal file
71
src/styles/messaging/message-stream.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/* Message stream container + status */
|
||||
.message-stream-container {
|
||||
@apply relative flex-1 min-h-0 flex flex-col overflow-hidden;
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
@apply grid items-center px-4 py-2 gap-4;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.connection-status-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.message-scroll-button-wrapper {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-scroll-button {
|
||||
@apply inline-flex items-center justify-center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--scroll-elevation-shadow);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.message-scroll-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.message-scroll-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||
}
|
||||
|
||||
.message-scroll-icon {
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
112
src/styles/messaging/prompt-input.css
Normal file
112
src/styles/messaging/prompt-input.css
Normal file
@@ -0,0 +1,112 @@
|
||||
/* Prompt input & attachment styles */
|
||||
.prompt-input-container {
|
||||
@apply flex flex-col border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.prompt-input-wrapper {
|
||||
@apply flex items-end gap-2 p-3;
|
||||
}
|
||||
|
||||
|
||||
.prompt-input {
|
||||
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
border-color: var(--border-base);
|
||||
line-height: var(--line-height-normal);
|
||||
}
|
||||
|
||||
.prompt-input.shell-mode {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.prompt-input:focus {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.prompt-input.shell-mode:focus {
|
||||
border-color: var(--status-success);
|
||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.prompt-input:disabled {
|
||||
@apply opacity-60 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.prompt-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.send-button.shell-mode {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.send-button.shell-mode:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.send-button.shell-mode:active:not(:disabled) {
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.send-button:hover:not(:disabled) {
|
||||
@apply opacity-90 scale-105;
|
||||
}
|
||||
|
||||
.send-button:active:not(:disabled) {
|
||||
@apply scale-95;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.shell-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.prompt-input-hints {
|
||||
@apply px-4 pb-2 flex justify-between items-center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hint kbd {
|
||||
@apply inline-block px-1.5 py-0.5 text-xs font-mono rounded mx-0.5;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.attachment-chip {
|
||||
@apply px-2.5 py-1 text-xs font-medium ring-1 ring-inset;
|
||||
background-color: var(--attachment-chip-bg);
|
||||
color: var(--attachment-chip-text);
|
||||
ring-color: var(--attachment-chip-ring);
|
||||
}
|
||||
|
||||
.attachment-remove {
|
||||
@apply ml-0.5 flex h-4 w-4 items-center justify-center rounded transition-colors;
|
||||
}
|
||||
|
||||
.attachment-remove:hover {
|
||||
background-color: var(--attachment-chip-ring);
|
||||
}
|
||||
789
src/styles/messaging/tool-call.css
Normal file
789
src/styles/messaging/tool-call.css
Normal file
@@ -0,0 +1,789 @@
|
||||
/* Tool call rendering */
|
||||
.tool-call-message {
|
||||
@apply flex flex-col gap-2 p-3 w-full;
|
||||
background-color: var(--message-tool-bg);
|
||||
border-left: 4px solid var(--message-tool-border);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-header-label {
|
||||
@apply flex items-center justify-between gap-2 font-semibold text-sm;
|
||||
color: var(--message-tool-border);
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.tool-call-header-meta {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
.tool-call-header-button {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--message-tool-border);
|
||||
padding: 0.15rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-call-header-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-header-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.tool-call-header-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-header-label .tool-call-icon {
|
||||
@apply text-base;
|
||||
}
|
||||
|
||||
.tool-call-header-label .tool-name {
|
||||
font-family: var(--font-family-mono);
|
||||
color: inherit;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
@apply border overflow-hidden;
|
||||
border-color: var(--border-base);
|
||||
color: inherit;
|
||||
--tool-call-line-unit: 1.4em;
|
||||
--tool-call-lines-compact: 15;
|
||||
--tool-call-lines-large: 30;
|
||||
--tool-call-max-height-compact: calc(var(--tool-call-lines-compact) * var(--tool-call-line-unit));
|
||||
--tool-call-max-height-large: calc(var(--tool-call-lines-large) * var(--tool-call-line-unit));
|
||||
}
|
||||
|
||||
.tool-call-message .tool-call {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
@apply flex items-center gap-2 p-2 w-full bg-transparent border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tool-call-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
@apply text-xs;
|
||||
}
|
||||
|
||||
.tool-call-summary {
|
||||
@apply flex-1 text-left;
|
||||
}
|
||||
|
||||
.tool-call-status {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.tool-call-status-success {
|
||||
border-left: 3px solid var(--status-success);
|
||||
}
|
||||
|
||||
.tool-call-status-error {
|
||||
border-left: 3px solid var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-status-running {
|
||||
border-left: 3px solid var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-status-running .tool-call-status {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-call-status-pending {
|
||||
border-left: 3px solid var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-status-pending .tool-call-summary {
|
||||
animation: shimmer 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tool-call-preview {
|
||||
@apply p-2 flex flex-col gap-1.5;
|
||||
background-color: var(--surface-code);
|
||||
border-top: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-preview-label {
|
||||
@apply text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tool-call-preview-text {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tool-call-details {
|
||||
@apply flex flex-col;
|
||||
background-color: var(--surface-code);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-markdown {
|
||||
background-color: var(--surface-code);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-base) transparent;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tool-call-markdown-large {
|
||||
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
|
||||
}
|
||||
|
||||
.tool-call-diff-shell {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer {
|
||||
max-height: var(--tool-call-max-height-large, calc(48 * 1.4em));
|
||||
overflow: auto;
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar {
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tool-call-diff-toggle {
|
||||
@apply inline-flex items-center gap-1;
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-all duration-150;
|
||||
border-color: var(--border-base);
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-diff-mode-button.active {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-tailwindcss-wrapper {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-view-wrapper {
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.tool-call-diff-fallback {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--surface-code);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.tool-call-awaiting-permission {
|
||||
border-left-color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-permission {
|
||||
@apply flex flex-col gap-3;
|
||||
border: 2px solid var(--status-warning);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
padding: 1rem 1.25rem;
|
||||
background-color: var(--message-tool-bg);
|
||||
}
|
||||
|
||||
.tool-call-permission-header {
|
||||
@apply flex items-center justify-between gap-3;
|
||||
}
|
||||
|
||||
.tool-call-permission-label {
|
||||
@apply font-semibold text-sm;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-permission-type {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-permission-title code {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--surface-code);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-call-permission-actions {
|
||||
@apply flex items-center justify-between gap-3 flex-wrap;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-buttons {
|
||||
@apply flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.tool-call-permission-button {
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--status-warning);
|
||||
color: var(--text-secondary);
|
||||
padding: 0.4rem 1.05rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1.15;
|
||||
transition: transform 0.15s ease, color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
border-color: var(--status-warning);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-permission-button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.tool-call-permission-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-permission-shortcuts {
|
||||
@apply flex items-center gap-2 text-xs text-muted;
|
||||
}
|
||||
|
||||
.tool-call-permission-shortcuts .kbd {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-queued-text {
|
||||
@apply text-sm text-muted;
|
||||
}
|
||||
|
||||
.tool-call-permission-error {
|
||||
@apply text-sm;
|
||||
color: var(--status-error);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-diff {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-permission-diff .tool-call-diff-shell {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-diff-viewer .diff-line-old-num,
|
||||
.tool-call-diff-viewer .diff-line-new-num,
|
||||
.tool-call-diff-viewer .diff-line-num {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-markdown .markdown-code-block {
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-markdown .code-block-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: auto;
|
||||
box-shadow: 0 1px 0 var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-markdown .markdown-code-block pre {
|
||||
margin: 0 !important;
|
||||
min-height: auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tool-call-markdown::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-base);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-top: 1px solid var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-wrapper {
|
||||
border-top: 1px solid var(--border-base);
|
||||
background-color: var(--surface-base);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-file {
|
||||
@apply inline-flex items-center;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
|
||||
.tool-call-diagnostics-heading {
|
||||
@apply flex items-center gap-2 p-2 w-full border-none cursor-pointer text-left;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: var(--message-tool-border);
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-heading:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-title {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-base);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-file {
|
||||
@apply inline-flex items-center;
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-caret {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md) var(--space-sm) var(--space-md);
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tool-call-diagnostics-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
max-height: calc(4 * var(--tool-call-line-unit, 1.4em));
|
||||
overflow-y: scroll;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 var(--space-sm);
|
||||
min-height: 20px;
|
||||
border-radius: var(--pill-radius);
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-error {
|
||||
background-color: var(--status-error-bg);
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-warning {
|
||||
background-color: var(--status-starting-bg);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-info {
|
||||
background-color: var(--badge-neutral-bg);
|
||||
color: var(--badge-neutral-text);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-path {
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2xs);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-coords {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-diagnostic-message {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-section pre {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
border-radius: 0px;
|
||||
overflow-x: auto;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tool-call-section code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-track {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb {
|
||||
background: var(--border-base);
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-pending-message {
|
||||
@apply flex items-center gap-2 p-3 text-xs italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-emoji {
|
||||
@apply text-base mr-1;
|
||||
}
|
||||
|
||||
.tool-call-action-button {
|
||||
@apply border text-xs font-semibold px-3 py-1 rounded transition-colors h-8 flex items-center;
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-action-button:hover:not(:disabled) {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tool-call-bash,
|
||||
.tool-call-diff {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
.tool-call-content {
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-x: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tool-call-content code {
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.tool-call-todos {
|
||||
@apply my-2 flex flex-col gap-2;
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.tool-call-todo-item {
|
||||
@apply flex items-start gap-3;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-completed {
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-active {
|
||||
border-color: var(--accent-primary);
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-cancelled {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid var(--border-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-muted);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox::after {
|
||||
content: "";
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="completed"] {
|
||||
background-color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="completed"]::after {
|
||||
content: "✓";
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="in_progress"]::after {
|
||||
content: "…";
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.tool-call-todo-checkbox[data-status="cancelled"]::after {
|
||||
content: "×";
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.tool-call-todo-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-call-todo-text {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-cancelled .tool-call-todo-text {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-todo-tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
border-radius: 9999px;
|
||||
padding: 2px 8px;
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-todo-item-active .tool-call-todo-tag {
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.tool-call-task-container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
@apply my-2 flex flex-col gap-1.5;
|
||||
}
|
||||
|
||||
.tool-call-task-item {
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-normal);
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--border-base);
|
||||
}
|
||||
|
||||
.tool-call-task-item::before {
|
||||
content: "∟ ";
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-error-content {
|
||||
background-color: var(--message-error-bg);
|
||||
border-left: 3px solid var(--status-error);
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
border-radius: 4px;
|
||||
color: var(--status-error);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.tool-call-error-content strong {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.dropdown-diff-added {
|
||||
@apply text-xs;
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.dropdown-diff-removed {
|
||||
@apply text-xs;
|
||||
color: var(--status-error);
|
||||
}
|
||||
676
src/styles/panels.css
Normal file
676
src/styles/panels.css
Normal file
@@ -0,0 +1,676 @@
|
||||
@import "./panels/tabs.css";
|
||||
@import "./panels/empty-loading.css";
|
||||
@import "./panels/modal.css";
|
||||
@import "./panels/panel-shell.css";
|
||||
@import "./panels/session-layout.css";
|
||||
|
||||
|
||||
.tab-bar-instance {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tab-bar-session {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
|
||||
}
|
||||
|
||||
.tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--tab-active-bg);
|
||||
color: var(--tab-active-text);
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
background-color: var(--tab-inactive-bg);
|
||||
color: var(--tab-inactive-text);
|
||||
}
|
||||
|
||||
.tab-inactive:hover {
|
||||
background-color: var(--tab-inactive-hover-bg);
|
||||
}
|
||||
|
||||
.tab-active:hover {
|
||||
background-color: var(--tab-active-hover-bg);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
|
||||
}
|
||||
|
||||
.tab-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
|
||||
background-color: var(--new-tab-bg);
|
||||
color: var(--new-tab-text);
|
||||
}
|
||||
|
||||
.new-tab-button:hover {
|
||||
background-color: var(--new-tab-hover-bg);
|
||||
}
|
||||
|
||||
.new-tab-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
/* Session tab specific styles */
|
||||
.session-tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.session-tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.session-tab-active {
|
||||
background-color: var(--session-tab-active-bg);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
color: var(--session-tab-active-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.session-tab-inactive {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.session-tab-inactive:hover {
|
||||
background-color: var(--session-tab-hover-bg);
|
||||
}
|
||||
|
||||
.session-tab-special {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.connection-status-info {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.connection-status-shortcut {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.connection-status-meta {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.connection-status-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-header-hints {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.session-sidebar-controls .selector-trigger,
|
||||
.session-sidebar-controls [data-model-selector-control],
|
||||
.session-sidebar-controls .selector-trigger-label,
|
||||
.session-sidebar-controls .selector-trigger-primary {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator.connected .status-dot {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.status-indicator.connecting .status-dot {
|
||||
background-color: var(--status-warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected .status-dot {
|
||||
background-color: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status {
|
||||
--session-status-dot: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working {
|
||||
color: var(--session-status-working-fg);
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle {
|
||||
color: var(--session-status-idle-fg);
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission {
|
||||
color: var(--session-status-permission-fg);
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status .status-dot {
|
||||
background-color: var(--session-status-dot);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working .status-dot,
|
||||
.status-indicator.session-status.session-compacting .status-dot {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working.session-status-list {
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle.session-status-list {
|
||||
background-color: var(--session-status-idle-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission.session-status-list {
|
||||
background-color: var(--session-status-permission-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status-list {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center p-12;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
@apply text-center max-w-sm;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--surface-code);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
|
||||
border-color: var(--border-base);
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Modal utilities */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background-color: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.modal-surface {
|
||||
@apply rounded-lg shadow-2xl flex flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
@apply p-4 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.modal-search-input {
|
||||
@apply flex-1 bg-transparent outline-none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-search-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-list-container {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-section-header {
|
||||
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-item {
|
||||
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.modal-item-highlight {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.modal-item-label {
|
||||
@apply font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-item-description {
|
||||
@apply text-sm mt-0.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-empty-state {
|
||||
@apply p-8 text-center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Panel component utilities */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-footer-hints {
|
||||
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-base font-semibold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-xs mt-0.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
@apply p-4;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-section-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-section-content {
|
||||
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-list-item {
|
||||
@apply border-b last:border-b-0 transition-colors w-full;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-list-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-list-item-highlight {
|
||||
background-color: var(--list-item-highlight-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.panel-list-item-content:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-list-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-list-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-empty-state {
|
||||
@apply p-6 text-center;
|
||||
}
|
||||
|
||||
.panel-empty-state-icon {
|
||||
@apply text-gray-400 dark:text-gray-600 mb-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-empty-state-title {
|
||||
@apply font-medium text-sm mb-1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-empty-state-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply px-4 py-3 border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Session view utility */
|
||||
.session-view {
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Session list component */
|
||||
.session-list-container {
|
||||
@apply flex flex-col flex-1 min-h-0 relative;
|
||||
background-color: var(--surface-secondary);
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
@apply flex flex-col min-h-0;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-shortcuts {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.session-sidebar-new {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls {
|
||||
@apply flex flex-col gap-3;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-controls > * {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-separator {
|
||||
background-color: var(--border-base);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@apply border-b relative;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-list-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.session-list-item {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-item-row {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-item-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@apply justify-between items-center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-item-active .session-item-meta {
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.session-item-active {
|
||||
background-color: var(--list-item-highlight-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-inactive:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
@apply flex-1 min-w-0;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
.session-item-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.session-list-footer {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-new-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
/* Responsive behavior for session list */
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
49
src/styles/panels/empty-loading.css
Normal file
49
src/styles/panels/empty-loading.css
Normal file
@@ -0,0 +1,49 @@
|
||||
/* Empty + loading panels */
|
||||
.empty-state {
|
||||
@apply flex-1 flex items-center justify-center p-12;
|
||||
}
|
||||
|
||||
.empty-state-content {
|
||||
@apply text-center max-w-sm;
|
||||
}
|
||||
|
||||
.empty-state-content h3 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state-content p {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state-content ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.empty-state-content li {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state-content code {
|
||||
background-color: var(--surface-code);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
@apply flex-1 flex flex-col items-center justify-center gap-4 p-12;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
@apply w-8 h-8 border-2 border-t-transparent rounded-full;
|
||||
border-color: var(--border-base);
|
||||
border-top-color: var(--accent-primary);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
70
src/styles/panels/modal.css
Normal file
70
src/styles/panels/modal.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/* Modal utilities */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 z-50;
|
||||
background-color: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.modal-surface {
|
||||
@apply rounded-lg shadow-2xl flex flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-container {
|
||||
@apply p-4 border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.modal-search-input {
|
||||
@apply flex-1 bg-transparent outline-none;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-search-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-search-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-list-container {
|
||||
@apply flex-1 overflow-y-auto;
|
||||
}
|
||||
|
||||
.modal-section-header {
|
||||
@apply px-4 py-2 text-xs font-semibold uppercase tracking-wide;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-item {
|
||||
@apply w-full px-4 py-3 flex items-start gap-3 transition-colors cursor-pointer border-none text-left;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.modal-list-container[data-pointer-mode="keyboard"] .modal-item:hover:not(.modal-item-highlight) {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.modal-item-highlight {
|
||||
background-color: var(--selection-highlight-bg);
|
||||
}
|
||||
|
||||
.modal-item-label {
|
||||
@apply font-medium;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-item-description {
|
||||
@apply text-sm mt-0.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-empty-state {
|
||||
@apply p-8 text-center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
121
src/styles/panels/panel-shell.css
Normal file
121
src/styles/panels/panel-shell.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/* Panel component shells */
|
||||
.panel {
|
||||
@apply rounded-lg shadow-sm border overflow-hidden;
|
||||
background-color: var(--surface-base);
|
||||
border-color: var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-footer-hints {
|
||||
@apply flex items-center justify-center flex-wrap gap-3 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply px-4 py-3 border-b;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
@apply text-base font-semibold;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
@apply text-xs mt-0.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
@apply p-4;
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-section-header {
|
||||
@apply w-full px-4 py-3 flex items-center justify-center transition-colors cursor-pointer gap-2;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-section-header:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-section-content {
|
||||
@apply px-4 py-3 border-t overflow-visible space-y-4 w-full;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
@apply max-h-[400px] overflow-y-auto;
|
||||
}
|
||||
|
||||
.panel-list--fill {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.panel-list-item {
|
||||
@apply border-b last:border-b-0 transition-colors w-full;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.panel-list-item:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.panel-list-item-highlight {
|
||||
background-color: var(--list-item-highlight-bg) !important;
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.panel-list-item-content {
|
||||
@apply flex-1 text-left px-4 py-3 flex items-center justify-between gap-3 outline-none transition-colors w-full;
|
||||
}
|
||||
|
||||
.panel-list-item-content:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.panel-list-item-content:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-list-item button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.panel-list-item-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.panel-empty-state {
|
||||
@apply p-6 text-center;
|
||||
}
|
||||
|
||||
.panel-empty-state-icon {
|
||||
@apply text-gray-400 dark:text-gray-600 mb-2;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-empty-state-title {
|
||||
@apply font-medium text-sm mb-1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.panel-empty-state-description {
|
||||
@apply text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
@apply px-4 py-3 border-t;
|
||||
border-color: var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
301
src/styles/panels/session-layout.css
Normal file
301
src/styles/panels/session-layout.css
Normal file
@@ -0,0 +1,301 @@
|
||||
/* Session view + sidebar */
|
||||
.session-view {
|
||||
@apply flex flex-1 min-h-0 flex-col;
|
||||
background-color: var(--surface-base);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.session-list-container {
|
||||
@apply flex flex-col flex-1 min-h-0 relative;
|
||||
background-color: var(--surface-secondary);
|
||||
min-width: 200px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.session-sidebar {
|
||||
@apply flex flex-col min-h-0;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-header {
|
||||
@apply flex flex-col gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-sidebar-shortcuts {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.session-sidebar-new {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls {
|
||||
@apply flex flex-col gap-3;
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-sidebar-controls > * {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.session-sidebar-controls .selector-trigger,
|
||||
.session-sidebar-controls [data-model-selector-control],
|
||||
.session-sidebar-controls .selector-trigger-label,
|
||||
session-sidebar-controls .selector-trigger-primary {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.session-header-hints {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
.session-sidebar-separator {
|
||||
background-color: var(--border-base);
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-resize-handle {
|
||||
@apply absolute top-0 right-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.session-resize-handle:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.session-resize-handle::before {
|
||||
content: "";
|
||||
@apply absolute top-0 left-0 w-2 h-full -translate-x-1/2;
|
||||
}
|
||||
|
||||
.session-list-header {
|
||||
@apply border-b relative;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-list-header h3 {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.session-list {
|
||||
@apply flex-1;
|
||||
}
|
||||
|
||||
.session-list-item {
|
||||
@apply border-b last:border-b-0;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply w-full flex flex-col gap-1 px-3 py-2.5 text-left transition-colors outline-none;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.session-item-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.session-item-row {
|
||||
@apply flex items-center gap-2 w-full;
|
||||
}
|
||||
|
||||
.session-item-header {
|
||||
@apply justify-between;
|
||||
}
|
||||
|
||||
.session-item-title-row {
|
||||
@apply flex items-center gap-2 min-w-0 flex-1;
|
||||
}
|
||||
|
||||
.session-item-meta {
|
||||
@apply justify-between items-center;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-item-active .session-item-meta {
|
||||
color: var(--text-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-item-actions {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.session-item-active {
|
||||
background-color: var(--list-item-highlight-bg);
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
box-shadow: inset 0 0 0 1px var(--list-item-highlight-border);
|
||||
}
|
||||
|
||||
.session-item-inactive {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.session-item-inactive:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-active .session-item-close:hover {
|
||||
background-color: var(--surface-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-item-title {
|
||||
@apply flex-1 min-w-0;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.session-item-close {
|
||||
@apply flex-shrink-0 p-0.5 rounded transition-all;
|
||||
}
|
||||
|
||||
.session-item-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.session-list-footer {
|
||||
@apply border-t;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button {
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.session-new-button:hover {
|
||||
background-color: var(--surface-hover);
|
||||
}
|
||||
|
||||
.session-new-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply flex items-center gap-1.5 text-xs;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator .status-dot {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-indicator.connected .status-dot {
|
||||
background-color: var(--status-success);
|
||||
}
|
||||
|
||||
.status-indicator.connecting .status-dot {
|
||||
background-color: var(--status-warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.disconnected .status-dot {
|
||||
background-color: var(--status-error);
|
||||
}
|
||||
|
||||
.status-indicator.session-status {
|
||||
--session-status-dot: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working,
|
||||
.status-indicator.session-status.session-compacting,
|
||||
.status-indicator.session-status.session-idle {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working {
|
||||
color: var(--session-status-working-fg);
|
||||
--session-status-dot: var(--session-status-working-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting {
|
||||
color: var(--session-status-compacting-fg);
|
||||
--session-status-dot: var(--session-status-compacting-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle {
|
||||
color: var(--session-status-idle-fg);
|
||||
--session-status-dot: var(--session-status-idle-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission {
|
||||
color: var(--session-status-permission-fg);
|
||||
--session-status-dot: var(--session-status-permission-fg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status .status-dot {
|
||||
background-color: var(--session-status-dot);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working .status-dot,
|
||||
.status-indicator.session-status.session-compacting .status-dot {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-working.session-status-list {
|
||||
background-color: var(--session-status-working-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-compacting.session-status-list {
|
||||
background-color: var(--session-status-compacting-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-idle.session-status-list {
|
||||
background-color: var(--session-status-idle-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status.session-permission.session-status-list {
|
||||
background-color: var(--session-status-permission-bg);
|
||||
}
|
||||
|
||||
.status-indicator.session-status-list {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.session-list-container {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.session-item-base {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
}
|
||||
110
src/styles/panels/tabs.css
Normal file
110
src/styles/panels/tabs.css
Normal file
@@ -0,0 +1,110 @@
|
||||
/* Primary tab strip */
|
||||
.tab-bar {
|
||||
@apply border-b;
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.tab-bar-instance {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.tab-bar-session {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
@apply flex items-center justify-between gap-1 px-2 py-1 overflow-x-auto;
|
||||
}
|
||||
|
||||
.tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-2 rounded-t-md max-w-[200px] transition-colors text-sm font-medium;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
background-color: var(--tab-active-bg);
|
||||
color: var(--tab-active-text);
|
||||
}
|
||||
|
||||
.tab-inactive {
|
||||
background-color: var(--tab-inactive-bg);
|
||||
color: var(--tab-inactive-text);
|
||||
}
|
||||
|
||||
.tab-inactive:hover {
|
||||
background-color: var(--tab-inactive-hover-bg);
|
||||
}
|
||||
|
||||
.tab-active:hover {
|
||||
background-color: var(--tab-active-hover-bg);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
@apply truncate;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
@apply opacity-0 group-hover:opacity-100 hover:bg-red-500 hover:text-white rounded p-0.5 transition-all cursor-pointer;
|
||||
}
|
||||
|
||||
.tab-close:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: inherit;
|
||||
}
|
||||
|
||||
.new-tab-button {
|
||||
@apply inline-flex items-center justify-center w-8 h-8 rounded-md transition-colors;
|
||||
background-color: var(--new-tab-bg);
|
||||
color: var(--new-tab-text);
|
||||
}
|
||||
|
||||
.new-tab-button:hover {
|
||||
background-color: var(--new-tab-hover-bg);
|
||||
}
|
||||
|
||||
.new-tab-button:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
/* Session tabs */
|
||||
.session-tab-base {
|
||||
@apply inline-flex items-center gap-2 px-3 py-1.5 rounded-t-md max-w-[150px] transition-colors text-sm;
|
||||
font-family: var(--font-family-sans);
|
||||
outline: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.session-tab-base:focus-visible {
|
||||
@apply ring-2 ring-offset-1;
|
||||
ring-color: var(--accent-primary);
|
||||
ring-offset-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.session-tab-active {
|
||||
background-color: var(--session-tab-active-bg);
|
||||
border-bottom-color: var(--accent-primary);
|
||||
color: var(--session-tab-active-text);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.session-tab-inactive {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
|
||||
.session-tab-inactive:hover {
|
||||
background-color: var(--session-tab-hover-bg);
|
||||
}
|
||||
|
||||
.session-tab-special {
|
||||
color: var(--session-tab-inactive-text);
|
||||
}
|
||||
@@ -34,6 +34,92 @@
|
||||
|
||||
--message-tool-bg: #f8f9fa;
|
||||
--message-tool-border: #6c757d;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #b45309;
|
||||
--session-status-working-bg: rgba(245, 158, 11, 0.16);
|
||||
--session-status-compacting-fg: #6d28d9;
|
||||
--session-status-compacting-bg: rgba(109, 40, 217, 0.18);
|
||||
--session-status-idle-fg: #15803d;
|
||||
--session-status-idle-bg: rgba(22, 163, 74, 0.16);
|
||||
--session-status-permission-fg: #c2410c;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
||||
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
||||
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
||||
--attachment-chip-text: #0066ff;
|
||||
--attachment-chip-ring: rgba(0, 102, 255, 0.1);
|
||||
--badge-neutral-bg: rgba(0, 102, 255, 0.05);
|
||||
--badge-neutral-text: #0066ff;
|
||||
--status-ready-fg: #16a34a;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.1);
|
||||
--status-starting-fg: #ca8a04;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.1);
|
||||
--status-error-fg: #dc2626;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.1);
|
||||
--status-stopped-fg: #6b7280;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.1);
|
||||
--env-vars-bg: rgba(0, 102, 255, 0.1);
|
||||
--env-vars-border: rgba(0, 102, 255, 0.2);
|
||||
--env-vars-text: #0066ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.35);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||
--dropdown-highlight-text: var(--text-inverted);
|
||||
--selection-highlight-bg: rgba(0, 102, 255, 0.12);
|
||||
--selection-highlight-strong-bg: rgba(0, 102, 255, 0.18);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.5);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
--message-error-bg: rgba(244, 67, 54, 0.1);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.15);
|
||||
--danger-soft-bg: rgba(239, 68, 68, 0.1);
|
||||
--danger-soft-bg-strong: rgba(244, 67, 54, 0.15);
|
||||
--log-level-error: var(--status-error);
|
||||
--log-level-warn: var(--status-warning);
|
||||
--log-level-debug: var(--text-muted);
|
||||
--log-level-default: var(--text-primary);
|
||||
--focus-ring-color: var(--accent-primary);
|
||||
--focus-ring-offset: var(--surface-base);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--button-primary-bg: var(--accent-primary);
|
||||
--button-primary-hover-bg: var(--accent-hover);
|
||||
--button-primary-text: var(--text-inverted);
|
||||
--tab-active-bg: var(--accent-primary);
|
||||
--tab-active-hover-bg: var(--accent-hover);
|
||||
--tab-active-text: var(--text-inverted);
|
||||
--tab-inactive-bg: var(--surface-muted);
|
||||
--tab-inactive-hover-bg: var(--surface-hover);
|
||||
--tab-inactive-text: var(--text-secondary);
|
||||
--new-tab-bg: var(--surface-secondary);
|
||||
--new-tab-hover-bg: var(--surface-hover);
|
||||
--new-tab-text: var(--text-muted);
|
||||
--session-tab-active-bg: var(--surface-base);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: var(--surface-hover);
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
@@ -96,17 +182,106 @@
|
||||
|
||||
--message-tool-bg: #212529;
|
||||
--message-tool-border: #adb5bd;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #facc15;
|
||||
--session-status-working-bg: rgba(250, 204, 21, 0.25);
|
||||
--session-status-compacting-fg: #c084fc;
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||
--attachment-chip-text: #0080ff;
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.2);
|
||||
--status-error-fg: #ef4444;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.2);
|
||||
--status-stopped-fg: #9ca3af;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.2);
|
||||
--env-vars-bg: rgba(0, 128, 255, 0.2);
|
||||
--env-vars-border: rgba(0, 128, 255, 0.3);
|
||||
--env-vars-text: #0080ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--dropdown-highlight-text: var(--text-primary);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2a2a31;
|
||||
--tab-inactive-hover-bg: #3f3f46;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: #3f3f46;
|
||||
|
||||
--button-primary-bg: #3f3f46;
|
||||
--button-primary-hover-bg: #52525b;
|
||||
--button-primary-text: #f5f6f8;
|
||||
--tab-active-bg: #3f3f46;
|
||||
--tab-active-hover-bg: #52525b;
|
||||
--tab-active-text: #f5f6f8;
|
||||
--tab-inactive-bg: #2f2f36;
|
||||
--tab-inactive-hover-bg: #3d3d45;
|
||||
--tab-inactive-text: #d4d4d8;
|
||||
--new-tab-bg: #3f3f46;
|
||||
--new-tab-hover-bg: #52525b;
|
||||
--new-tab-text: #f5f6f8;
|
||||
--session-tab-active-bg: var(--surface-muted);
|
||||
--session-tab-active-text: var(--text-primary);
|
||||
--session-tab-inactive-text: var(--text-muted);
|
||||
--session-tab-hover-bg: #3f3f46;
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens (same as light theme) */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
@@ -119,6 +294,7 @@
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,17 +334,87 @@
|
||||
|
||||
--message-tool-bg: #212529;
|
||||
--message-tool-border: #adb5bd;
|
||||
|
||||
/* Semantic component colors */
|
||||
--session-status-working-fg: #facc15;
|
||||
--session-status-working-bg: rgba(250, 204, 21, 0.25);
|
||||
--session-status-compacting-fg: #c084fc;
|
||||
--session-status-compacting-bg: rgba(192, 132, 252, 0.28);
|
||||
--session-status-idle-fg: #4ade80;
|
||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||
--session-status-permission-fg: #fbbf24;
|
||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||
--attachment-chip-text: #0080ff;
|
||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||
--badge-neutral-bg: rgba(0, 128, 255, 0.15);
|
||||
--badge-neutral-text: #0080ff;
|
||||
--status-ready-fg: #22c55e;
|
||||
--status-ready-bg: rgba(34, 197, 94, 0.2);
|
||||
--status-starting-fg: #facc15;
|
||||
--status-starting-bg: rgba(250, 204, 21, 0.2);
|
||||
--status-error-fg: #ef4444;
|
||||
--status-error-bg: rgba(239, 68, 68, 0.2);
|
||||
--status-stopped-fg: #9ca3af;
|
||||
--status-stopped-bg: rgba(107, 114, 128, 0.2);
|
||||
--env-vars-bg: rgba(0, 128, 255, 0.2);
|
||||
--env-vars-border: rgba(0, 128, 255, 0.3);
|
||||
--env-vars-text: #0080ff;
|
||||
--folder-overlay-bg: rgba(0, 0, 0, 0.45);
|
||||
--folder-card-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
--folder-card-radius: 16px;
|
||||
--dropdown-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||
--dropdown-highlight-text: var(--text-primary);
|
||||
--selection-highlight-bg: rgba(0, 128, 255, 0.18);
|
||||
--selection-highlight-strong-bg: rgba(0, 128, 255, 0.28);
|
||||
--overlay-scrim: rgba(0, 0, 0, 0.6);
|
||||
--scroll-elevation-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
|
||||
--message-error-bg: rgba(244, 67, 54, 0.12);
|
||||
--message-error-bg-strong: rgba(244, 67, 54, 0.2);
|
||||
--danger-soft-bg: rgba(244, 67, 54, 0.16);
|
||||
--danger-soft-bg-strong: rgba(244, 67, 54, 0.28);
|
||||
--log-level-error: var(--status-error);
|
||||
--log-level-warn: var(--status-warning);
|
||||
--log-level-debug: var(--text-secondary);
|
||||
--log-level-default: var(--text-primary);
|
||||
--focus-ring-color: var(--accent-primary);
|
||||
--focus-ring-offset: var(--surface-base);
|
||||
--kbd-bg: var(--surface-secondary);
|
||||
--kbd-border: var(--border-base);
|
||||
--kbd-text: var(--text-primary);
|
||||
|
||||
/* Layout & spacing tokens */
|
||||
--space-2xs: 2px;
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
--space-xl: 24px;
|
||||
--radius-xs: 3px;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
--button-padding-y: 0.75rem;
|
||||
--button-padding-x: 1.25rem;
|
||||
--button-radius: 0.5rem;
|
||||
--chip-radius: 0.375rem;
|
||||
--pill-radius: 9999px;
|
||||
|
||||
/* Typography tokens (same as light theme) */
|
||||
--font-family-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
|
||||
|
||||
/* Font weights */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
|
||||
/* Font sizes */
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 12px;
|
||||
@@ -181,4 +427,5 @@
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.6;
|
||||
|
||||
}
|
||||
|
||||
135
src/styles/utilities.css
Normal file
135
src/styles/utilities.css
Normal file
@@ -0,0 +1,135 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
/* Reusable component utilities using tokens */
|
||||
|
||||
/* Base token utility helpers */
|
||||
.text-primary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-inverted {
|
||||
color: var(--text-inverted);
|
||||
}
|
||||
|
||||
.text-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.bg-surface-base {
|
||||
background-color: var(--surface-base);
|
||||
}
|
||||
|
||||
.bg-surface-secondary {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.bg-surface-muted {
|
||||
background-color: var(--surface-muted);
|
||||
}
|
||||
|
||||
.border-base {
|
||||
border-color: var(--border-base);
|
||||
}
|
||||
|
||||
.icon-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.icon-accent {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.icon-danger-hover:hover {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
.icon-accent-hover:hover {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.ring-accent-inset {
|
||||
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared button + chip helpers */
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary) {
|
||||
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
|
||||
}
|
||||
|
||||
:is(.button-primary,
|
||||
button.button-primary,
|
||||
.button-secondary,
|
||||
button.button-secondary,
|
||||
.button-tertiary,
|
||||
button.button-tertiary):disabled {
|
||||
@apply cursor-not-allowed opacity-50;
|
||||
}
|
||||
|
||||
:is(.attachment-chip,
|
||||
.neutral-badge,
|
||||
.status-badge) {
|
||||
@apply inline-flex items-center gap-1;
|
||||
border-radius: var(--chip-radius);
|
||||
}
|
||||
|
||||
/* Focus helpers */
|
||||
.focus-ring-accent:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Shared layout helpers */
|
||||
.status-dot {
|
||||
@apply w-1 h-1 rounded-full;
|
||||
}
|
||||
|
||||
.kbd {
|
||||
@apply inline-flex items-center gap-0.5 font-mono text-xs px-1.5 py-0.5 rounded;
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.kbd-separator {
|
||||
@apply opacity-50;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpencodeClient } from "@opencode-ai/sdk/client"
|
||||
import type { Project as SDKProject } from "@opencode-ai/sdk"
|
||||
import type { LspStatus, Project as SDKProject } from "@opencode-ai/sdk"
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: number
|
||||
@@ -24,6 +24,7 @@ export type RawMcpStatus = Record<string, {
|
||||
export interface InstanceMetadata {
|
||||
project?: ProjectInfo
|
||||
mcpStatus?: RawMcpStatus
|
||||
lspStatus?: LspStatus[]
|
||||
version?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
EventMessagePartUpdated as MessagePartUpdatedEvent,
|
||||
EventMessagePartRemoved as MessagePartRemovedEvent,
|
||||
Part as SDKPart,
|
||||
Message as SDKMessage
|
||||
Message as SDKMessage,
|
||||
Permission,
|
||||
} from "@opencode-ai/sdk"
|
||||
|
||||
// Re-export for other modules
|
||||
@@ -25,6 +26,11 @@ export interface RenderCache {
|
||||
mode?: string
|
||||
}
|
||||
|
||||
export interface PendingPermissionState {
|
||||
permission: Permission
|
||||
active: boolean
|
||||
}
|
||||
|
||||
// Client-specific part extensions (using intersection type since SDKPart is a union)
|
||||
export type ClientPart = SDKPart & {
|
||||
sessionID?: string
|
||||
@@ -32,6 +38,7 @@ export type ClientPart = SDKPart & {
|
||||
synthetic?: boolean
|
||||
version?: number
|
||||
renderCache?: RenderCache
|
||||
pendingPermission?: PendingPermissionState
|
||||
}
|
||||
|
||||
export interface MessageDisplayParts {
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface Session extends Omit<import("@opencode-ai/sdk").Session, 'proje
|
||||
messages: Message[] // Client-specific field
|
||||
messagesInfo: Map<string, MessageInfo> // Client-specific field
|
||||
version: string // Include version from SDK Session
|
||||
pendingPermission?: boolean // Indicates if session is waiting on user permission
|
||||
}
|
||||
|
||||
// Adapter function to convert SDK Session to client Session
|
||||
|
||||
Reference in New Issue
Block a user