Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
|
||||
226
README.md
226
README.md
@@ -1,219 +1,41 @@
|
||||
# CodeNomad
|
||||
|
||||
A cross-platform desktop application for interacting with OpenCode servers, built with Electron and SolidJS.
|
||||
> A fast, multi-instance desktop client for running OpenCode sessions the way long-haul builders actually work.
|
||||
|
||||
## Overview
|
||||
## What is CodeNomad?
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
**🎯 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
|
||||

|
||||
|
||||
### Core Capabilities
|
||||
## Highlights
|
||||
|
||||
- **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
|
||||
- **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.
|
||||
- **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 faster than the UI can animate.
|
||||
- **Direct model messaging** – keep an eye on child sessions and send targeted replies without losing your flow.
|
||||
- **Developer-friendly rendering** – syntax highlighting, inline diffs, and thoughtful presentation keep the signal high.
|
||||
|
||||
### Advanced Features (Planned)
|
||||
## Command Palette
|
||||
|
||||
- Virtual scrolling for large conversations
|
||||
- Full-text search across sessions
|
||||
- Workspace management
|
||||
- Custom themes
|
||||
- Plugin system
|
||||
The palette is the nerve center of CodeNomad: hit the shortcut once and you can search commands, switch instances, start a new task, open attachments, or tweak settings without taking your hands off the keyboard. Every action is categorized, fuzzy searchable, and previewed so you can chain moves together in seconds. It is the single interface for command execution, which keeps the workflow predictable and fast whether you are juggling one session or ten.
|
||||
|
||||
## Architecture
|
||||
## Requirements
|
||||
|
||||
See [docs/architecture.md](docs/architecture.md) for detailed architecture documentation.
|
||||
- [OpenCode CLI](https://opencode.ai) installed and available in your `PATH`, or point CodeNomad to a local binary through Advanced Settings.
|
||||
|
||||
### High-Level Overview
|
||||
## Downloads
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases page](https://github.com/shantur/CodeNomad/releases).
|
||||
|
||||
## Prerequisites
|
||||
## Quick Start
|
||||
|
||||
- Node.js 18+
|
||||
- Bun package manager
|
||||
- OpenCode CLI installed and in PATH
|
||||
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.
|
||||
|
||||
## 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 |
@@ -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")
|
||||
|
||||
|
||||
11
package.json
11
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.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Shantur Rathore",
|
||||
"email": "codenomad@shantur.com"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/main/main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
435
src/App.tsx
435
src/App.tsx
@@ -1,9 +1,10 @@
|
||||
import { Component, onMount, onCleanup, Show, createMemo, createEffect, createSignal } from "solid-js"
|
||||
import { Dialog } from "@kobalte/core/dialog"
|
||||
import { Toaster } from "solid-toast"
|
||||
import type { Session } from "./types/session"
|
||||
import type { Attachment } from "./types/attachment"
|
||||
import type { SDKPart, ClientPart } from "./types/message"
|
||||
import type { Permission } from "@opencode-ai/sdk"
|
||||
import type { Permission, Command as SDKCommand } from "@opencode-ai/sdk"
|
||||
import FolderSelectionView from "./components/folder-selection-view"
|
||||
import InstanceWelcomeView from "./components/instance-welcome-view"
|
||||
import CommandPalette from "./components/command-palette"
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
showFolderSelection,
|
||||
setShowFolderSelection,
|
||||
} from "./stores/ui"
|
||||
import { toggleShowThinkingBlocks, preferences, addRecentFolder, setDiffViewMode } from "./stores/preferences"
|
||||
import { useConfig } from "./stores/preferences"
|
||||
import {
|
||||
createInstance,
|
||||
instances,
|
||||
@@ -64,10 +65,12 @@ import {
|
||||
getSessionInfo,
|
||||
isSessionMessagesLoading,
|
||||
fetchSessions,
|
||||
executeCustomCommand,
|
||||
} from "./stores/sessions"
|
||||
import { isSessionBusy } from "./stores/session-status"
|
||||
import { setupTabKeyboardShortcuts } from "./lib/keyboard"
|
||||
import { isOpen as isCommandPaletteOpen, showCommandPalette, hideCommandPalette } from "./stores/command-palette"
|
||||
import { getCommands as getInstanceCommands } from "./stores/commands"
|
||||
import { registerNavigationShortcuts } from "./lib/shortcuts/navigation"
|
||||
import { registerInputShortcuts } from "./lib/shortcuts/input"
|
||||
import { registerAgentShortcuts } from "./lib/shortcuts/agent"
|
||||
@@ -349,9 +352,33 @@ const ContextUsagePanel: Component<{ instanceId: string; sessionId: string }> =
|
||||
|
||||
const App: Component = () => {
|
||||
const { isDark } = useTheme()
|
||||
const { preferences, addRecentFolder, toggleShowThinkingBlocks, setDiffViewMode } = useConfig()
|
||||
const commandRegistry = createCommandRegistry()
|
||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||
const [paletteCommands, setPaletteCommands] = createSignal<Command[]>([])
|
||||
const [launchErrorBinary, setLaunchErrorBinary] = createSignal<string | null>(null)
|
||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
||||
|
||||
const launchErrorPath = () => {
|
||||
const value = launchErrorBinary()
|
||||
if (!value) return "opencode"
|
||||
return value.trim() || "opencode"
|
||||
}
|
||||
|
||||
const isMissingBinaryError = (error: unknown): boolean => {
|
||||
if (!error) return false
|
||||
const message = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
|
||||
const normalized = message.toLowerCase()
|
||||
return (
|
||||
normalized.includes("opencode binary not found") ||
|
||||
normalized.includes("binary not found") ||
|
||||
normalized.includes("no such file or directory") ||
|
||||
normalized.includes("binary is not executable") ||
|
||||
normalized.includes("enoent")
|
||||
)
|
||||
}
|
||||
|
||||
const clearLaunchError = () => setLaunchErrorBinary(null)
|
||||
|
||||
const refreshCommandPalette = () => {
|
||||
setPaletteCommands(commandRegistry.getAll())
|
||||
@@ -361,15 +388,12 @@ const App: Component = () => {
|
||||
void initMarkdown(isDark()).catch(console.error)
|
||||
})
|
||||
|
||||
|
||||
|
||||
const activeInstance = createMemo(() => getActiveInstance())
|
||||
|
||||
const activeSessions = createMemo(() => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return new Map()
|
||||
const instanceId = instance.id
|
||||
|
||||
const parentId = activeParentSessionId().get(instanceId)
|
||||
if (!parentId) return new Map()
|
||||
|
||||
@@ -383,6 +407,7 @@ const App: Component = () => {
|
||||
return activeSessionId().get(instance.id) || null
|
||||
})
|
||||
|
||||
|
||||
const activeSessionForInstance = createMemo(() => {
|
||||
const sessionId = activeSessionIdForInstance()
|
||||
if (!sessionId || sessionId === "info") return null
|
||||
@@ -408,6 +433,7 @@ const App: Component = () => {
|
||||
|
||||
async function handleSelectFolder(folderPath?: string, binaryPath?: string) {
|
||||
setIsSelectingFolder(true)
|
||||
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||
try {
|
||||
let folder: string | null | undefined = folderPath
|
||||
|
||||
@@ -418,19 +444,38 @@ const App: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!folder) {
|
||||
return
|
||||
}
|
||||
|
||||
addRecentFolder(folder)
|
||||
const instanceId = await createInstance(folder, binaryPath)
|
||||
clearLaunchError()
|
||||
const instanceId = await createInstance(folder, selectedBinary)
|
||||
setHasInstances(true)
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
|
||||
console.log("Created instance:", instanceId, "Port:", instances().get(instanceId)?.port)
|
||||
} catch (error) {
|
||||
clearLaunchError()
|
||||
if (isMissingBinaryError(error)) {
|
||||
setLaunchErrorBinary(selectedBinary)
|
||||
}
|
||||
console.error("Failed to create instance:", error)
|
||||
} finally {
|
||||
setIsSelectingFolder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleLaunchErrorClose() {
|
||||
clearLaunchError()
|
||||
}
|
||||
|
||||
function handleLaunchErrorAdvanced() {
|
||||
clearLaunchError()
|
||||
setIsAdvancedSettingsOpen(true)
|
||||
}
|
||||
|
||||
function handleNewInstanceRequest() {
|
||||
if (hasInstances()) {
|
||||
setShowFolderSelection(true)
|
||||
@@ -802,43 +847,6 @@ const App: Component = () => {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "init",
|
||||
label: "Initialize AGENTS.md",
|
||||
description: "Create or update AGENTS.md file",
|
||||
category: "Agent & Model",
|
||||
keywords: ["/init", "agents", "initialize"],
|
||||
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 {
|
||||
// Generate ID similar to server format: timestamp in hex + random chars
|
||||
const timestamp = Date.now()
|
||||
const timePart = (timestamp * 0x1000).toString(16).padStart(12, "0")
|
||||
const randomPart = Math.random().toString(16).substring(2, 16)
|
||||
const messageID = `msg_${timePart}${randomPart}`
|
||||
|
||||
await instance.client.session.init({
|
||||
path: { id: sessionId },
|
||||
body: {
|
||||
messageID,
|
||||
providerID: session.model.providerId,
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
})
|
||||
console.log("Initializing AGENTS.md...")
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize AGENTS.md:", error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "clear-input",
|
||||
label: "Clear Input",
|
||||
@@ -893,8 +901,17 @@ const App: Component = () => {
|
||||
refreshCommandPalette()
|
||||
}
|
||||
|
||||
function handleExecuteCommand(commandId: string) {
|
||||
commandRegistry.execute(commandId)
|
||||
function handleExecuteCommand(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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -909,7 +926,12 @@ const App: Component = () => {
|
||||
handleCloseInstance,
|
||||
handleNewSession,
|
||||
handleCloseSession,
|
||||
showCommandPalette,
|
||||
() => {
|
||||
const instance = activeInstance()
|
||||
if (instance) {
|
||||
showCommandPalette(instance.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
registerNavigationShortcuts()
|
||||
@@ -996,7 +1018,7 @@ const App: Component = () => {
|
||||
const active = document.activeElement as HTMLElement
|
||||
active?.blur()
|
||||
},
|
||||
hideCommandPalette,
|
||||
() => hideCommandPalette(),
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -1055,6 +1077,41 @@ const App: Component = () => {
|
||||
reason={disconnectedInstance()?.reason}
|
||||
onClose={handleDisconnectedInstanceClose}
|
||||
/>
|
||||
|
||||
<Dialog open={Boolean(launchErrorBinary())} modal>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||
Advanced Settings.
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Binary path</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{launchErrorPath()}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={handleLaunchErrorAdvanced}
|
||||
>
|
||||
Open Advanced Settings
|
||||
</button>
|
||||
<button type="button" class="selector-button selector-button-primary" onClick={handleLaunchErrorClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
<div class="h-screen w-screen flex flex-col">
|
||||
<Show
|
||||
when={!hasInstances()}
|
||||
@@ -1069,123 +1126,145 @@ const App: Component = () => {
|
||||
/>
|
||||
|
||||
<Show when={activeInstance()} keyed>
|
||||
{(instance) => (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
{/* Session Sidebar */}
|
||||
<div
|
||||
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||
style={{ width: `${sessionSidebarWidth()}px` }}
|
||||
>
|
||||
<SessionList
|
||||
instanceId={instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={(id) => setActiveSession(instance.id, id)}
|
||||
onClose={(id) => handleCloseSession(instance.id, id)}
|
||||
onNew={() => handleNewSession(instance.id)}
|
||||
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">
|
||||
{(() => {
|
||||
const shortcuts = [
|
||||
keyboardRegistry.get("session-prev"),
|
||||
keyboardRegistry.get("session-next"),
|
||||
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
|
||||
return shortcuts.length ? (
|
||||
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{(instance) => {
|
||||
const customCommands = createMemo(() =>
|
||||
buildCustomCommandEntries(instance.id, getInstanceCommands(instance.id)),
|
||||
)
|
||||
const instancePaletteCommands = createMemo(() => [
|
||||
...paletteCommands(),
|
||||
...customCommands(),
|
||||
])
|
||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(instance.id))
|
||||
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={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={instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={handleSidebarAgentChange}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={handleSidebarModelChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
{/* Main Content Area */}
|
||||
<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={instance.id}
|
||||
instanceFolder={instance.folder}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={instance} />}>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
{/* Session Sidebar */}
|
||||
<div
|
||||
class="session-sidebar flex flex-col bg-surface-secondary"
|
||||
style={{ width: `${sessionSidebarWidth()}px` }}
|
||||
>
|
||||
<InfoView instanceId={instance.id} />
|
||||
</Show>
|
||||
<SessionList
|
||||
instanceId={instance.id}
|
||||
sessions={activeSessions()}
|
||||
activeSessionId={activeSessionIdForInstance()}
|
||||
onSelect={(id) => setActiveSession(instance.id, id)}
|
||||
onClose={(id) => handleCloseSession(instance.id, id)}
|
||||
onNew={() => handleNewSession(instance.id)}
|
||||
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">
|
||||
{(() => {
|
||||
const shortcuts = [
|
||||
keyboardRegistry.get("session-prev"),
|
||||
keyboardRegistry.get("session-next"),
|
||||
].filter((shortcut): shortcut is KeyboardShortcut => Boolean(shortcut))
|
||||
return shortcuts.length ? (
|
||||
<KeyboardHint shortcuts={shortcuts} separator=" " showDescription={false} />
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
onWidthChange={setSessionSidebarWidth}
|
||||
/>
|
||||
|
||||
<div class="session-sidebar-separator border-t border-base" />
|
||||
<Show when={activeSessionForInstance()}>
|
||||
{(activeSession) => (
|
||||
<>
|
||||
<ContextUsagePanel instanceId={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={instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentAgent={activeSession().agent}
|
||||
onAgentChange={handleSidebarAgentChange}
|
||||
/>
|
||||
|
||||
<ModelSelector
|
||||
instanceId={instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={handleSidebarModelChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
{/* Main Content Area */}
|
||||
<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={instance.id}
|
||||
instanceFolder={instance.folder}
|
||||
escapeInDebounce={escapeInDebounce()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<InfoView instanceId={instance.id} />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={paletteOpen()}
|
||||
onClose={() => hideCommandPalette(instance.id)}
|
||||
commands={instancePaletteCommands()}
|
||||
onExecute={handleExecuteCommand}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<CommandPalette
|
||||
open={isCommandPaletteOpen()}
|
||||
onClose={hideCommandPalette}
|
||||
commands={paletteCommands()}
|
||||
onExecute={handleExecuteCommand}
|
||||
/>
|
||||
|
||||
<Show when={showFolderSelection()}>
|
||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||
<div class="w-full h-full relative">
|
||||
<button
|
||||
onClick={() => setShowFolderSelection(false)}
|
||||
onClick={() => {
|
||||
setShowFolderSelection(false)
|
||||
setIsAdvancedSettingsOpen(false)
|
||||
clearLaunchError()
|
||||
}}
|
||||
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
@@ -1198,7 +1277,13 @@ const App: Component = () => {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<FolderSelectionView onSelectFolder={handleSelectFolder} isLoading={isSelectingFolder()} />
|
||||
<FolderSelectionView
|
||||
onSelectFolder={handleSelectFolder}
|
||||
isLoading={isSelectingFolder()}
|
||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -1216,4 +1301,52 @@ const App: Component = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function commandRequiresArguments(template?: string) {
|
||||
if (!template) return false
|
||||
return /\$(?:\d+|ARGUMENTS)/.test(template)
|
||||
}
|
||||
|
||||
function promptForCommandArguments(command: SDKCommand.Info) {
|
||||
if (!commandRequiresArguments(command.template)) {
|
||||
return ""
|
||||
}
|
||||
const input = window.prompt(`Arguments for /${command.name}`, "")
|
||||
if (input === null) {
|
||||
return null
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function formatCommandLabel(name: string) {
|
||||
if (!name) return ""
|
||||
return name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
function buildCustomCommandEntries(instanceId: string, commands: SDKCommand.Info[]): 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.")
|
||||
}
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -99,7 +99,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 +119,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()}>
|
||||
|
||||
@@ -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" }>
|
||||
@@ -14,6 +14,7 @@ interface MessagePartProps {
|
||||
}
|
||||
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())
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,7 +6,8 @@ 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 type { TextPart, SDKPart, ClientPart } from "../types/message"
|
||||
|
||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||
@@ -177,6 +178,7 @@ function extractDiffPayload(toolName: string, state: ToolState): DiffPayload | n
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
const { isDark } = useTheme()
|
||||
const toolCallId = () => props.toolCallId || props.toolCall?.id || ""
|
||||
const expanded = () => isToolCallExpanded(toolCallId())
|
||||
|
||||
@@ -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.Info[]>>(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.Info[] {
|
||||
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 }
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
removeSessionIndexes,
|
||||
clearInstanceDraftPrompts,
|
||||
} from "./sessions"
|
||||
import { fetchCommands, clearCommands } from "./commands"
|
||||
import { preferences, updateLastUsedBinary } from "./preferences"
|
||||
import { setHasInstances } from "./ui"
|
||||
|
||||
@@ -139,6 +140,7 @@ function removeInstance(id: string) {
|
||||
})
|
||||
|
||||
removeLogContainer(id)
|
||||
clearCommands(id)
|
||||
|
||||
if (activeInstanceId() === id) {
|
||||
setActiveInstanceId(nextActiveId)
|
||||
@@ -194,6 +196,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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -32,7 +33,7 @@ export interface RecentFolder {
|
||||
lastAccessed: number
|
||||
}
|
||||
|
||||
const MAX_RECENT_FOLDERS = 10
|
||||
const MAX_RECENT_FOLDERS = 20
|
||||
const MAX_RECENT_MODELS = 5
|
||||
|
||||
const defaultPreferences: Preferences = {
|
||||
@@ -45,11 +46,13 @@ const defaultPreferences: Preferences = {
|
||||
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 +63,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 +95,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)
|
||||
@@ -196,20 +219,85 @@ 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
|
||||
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,
|
||||
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,
|
||||
@@ -2029,6 +2029,48 @@ async function sendMessage(
|
||||
}
|
||||
}
|
||||
|
||||
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 abortSession(instanceId: string, sessionId: string): Promise<void> {
|
||||
const instance = instances().get(instanceId)
|
||||
if (!instance || !instance.client) {
|
||||
@@ -2266,4 +2308,5 @@ export {
|
||||
setSessionDraftPrompt,
|
||||
clearSessionDraftPrompt,
|
||||
clearInstanceDraftPrompts,
|
||||
executeCustomCommand,
|
||||
}
|
||||
|
||||
@@ -696,7 +696,7 @@ button.button-primary:disabled {
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--border-base);
|
||||
background-color: var(--surface-secondary);
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -846,7 +846,7 @@ button.button-primary:disabled {
|
||||
|
||||
/* Tool call component */
|
||||
.tool-call {
|
||||
@apply border rounded-md overflow-hidden;
|
||||
@apply border overflow-hidden;
|
||||
border-color: var(--border-base);
|
||||
color: inherit;
|
||||
--tool-call-line-unit: 1.4em;
|
||||
@@ -867,6 +867,7 @@ button.button-primary:disabled {
|
||||
@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 {
|
||||
@@ -1084,7 +1085,7 @@ button.button-primary:disabled {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background-color: var(--surface-base);
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
overflow-x: auto;
|
||||
max-height: var(--tool-call-max-height-compact, calc(25 * 1.4em));
|
||||
overflow-y: scroll;
|
||||
@@ -1104,12 +1105,12 @@ button.button-primary:disabled {
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-track {
|
||||
background: var(--surface-secondary);
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb {
|
||||
background: var(--border-base);
|
||||
border-radius: 4px;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.tool-call-section pre::-webkit-scrollbar-thumb:hover {
|
||||
@@ -1151,7 +1152,7 @@ button.button-primary:disabled {
|
||||
.tool-call-content {
|
||||
background-color: var(--surface-secondary);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 4px;
|
||||
border-radius: 0;
|
||||
padding: 8px 12px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user