Compare commits
37 Commits
v0.2.6-dev
...
v0.2.7-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1aee8f91 | ||
|
|
5384ff8e80 | ||
|
|
6d5836ce1f | ||
|
|
d3dc170e02 | ||
|
|
983c8cc4a3 | ||
|
|
757c587b17 | ||
|
|
5f9cf397b9 | ||
|
|
78ab17d148 | ||
|
|
e91923ad99 | ||
|
|
fd23ea54b6 | ||
|
|
1e7969eaba | ||
|
|
77bfe41a8e | ||
|
|
6d134e4dec | ||
|
|
9423326193 | ||
|
|
c5011e4ece | ||
|
|
66c270151a | ||
|
|
5ce41217e9 | ||
|
|
1e4d949d35 | ||
|
|
6bb9e8e414 | ||
|
|
1efc49b67b | ||
|
|
f0ed98222a | ||
|
|
ddd8ce341a | ||
|
|
b7721ba3e7 | ||
|
|
0554018980 | ||
|
|
ca18942bfd | ||
|
|
c9c1f69b82 | ||
|
|
aa0c31fa1e | ||
|
|
96b88dbcdc | ||
|
|
50676416ed | ||
|
|
f633d75005 | ||
|
|
4085f6d6b9 | ||
|
|
ae288833e1 | ||
|
|
f16e244265 | ||
|
|
b6e43c899b | ||
|
|
9fa436b0b8 | ||
|
|
ccd65fbc74 | ||
|
|
daa7e3a6d1 |
4
.github/workflows/dev-release.yml
vendored
4
.github/workflows/dev-release.yml
vendored
@@ -57,7 +57,9 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
needs: prepare-dev
|
needs:
|
||||||
|
- prepare-dev
|
||||||
|
- build-and-upload
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prepare-dev.outputs.version }}
|
version: ${{ needs.prepare-dev.outputs.version }}
|
||||||
|
|||||||
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
@@ -77,6 +77,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
- build-and-upload
|
- build-and-upload
|
||||||
|
if: ${{ needs.build-and-upload.result == 'success' }}
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ release/
|
|||||||
.vite/
|
.vite/
|
||||||
.electron-vite/
|
.electron-vite/
|
||||||
out/
|
out/
|
||||||
|
.dir-locals.el
|
||||||
82
dev-docs/solidjs-llms.txt
Normal file
82
dev-docs/solidjs-llms.txt
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# SolidJS Documentation
|
||||||
|
|
||||||
|
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
|
||||||
|
|
||||||
|
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
|
||||||
|
|
||||||
|
Key principles:
|
||||||
|
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
|
||||||
|
- Compile-time optimization: JSX transforms into efficient DOM operations
|
||||||
|
- Unidirectional data flow: Props are read-only, promoting predictable state management
|
||||||
|
- Component lifecycle: Components run once, with reactive primitives handling updates
|
||||||
|
|
||||||
|
**Use your web fetch tool on any of the following links to understand the relevant concept**.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
|
||||||
|
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
|
||||||
|
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
|
||||||
|
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
|
||||||
|
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
|
||||||
|
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
|
||||||
|
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
|
||||||
|
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
|
||||||
|
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
|
||||||
|
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
|
||||||
|
|
||||||
|
## Component APIs
|
||||||
|
|
||||||
|
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
|
||||||
|
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
|
||||||
|
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
|
||||||
|
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
|
||||||
|
|
||||||
|
## Control Flow
|
||||||
|
|
||||||
|
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
|
||||||
|
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
|
||||||
|
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
|
||||||
|
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
|
||||||
|
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
|
||||||
|
|
||||||
|
## Derived Values
|
||||||
|
|
||||||
|
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
|
||||||
|
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
|
||||||
|
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
|
||||||
|
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
|
||||||
|
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
|
||||||
|
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
|
||||||
|
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
|
||||||
|
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
|
||||||
|
|
||||||
|
## Ecosystem
|
||||||
|
|
||||||
|
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
|
||||||
|
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
|
||||||
|
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
|
||||||
|
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
|
||||||
|
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
|
||||||
|
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
|
||||||
|
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options
|
||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -8613,7 +8613,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -8641,7 +8641,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -8680,14 +8680,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const PreferencesSchema = z.object({
|
|||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
showUsageMetrics: z.boolean().default(true),
|
showUsageMetrics: z.boolean().default(true),
|
||||||
|
autoCleanupBlankSessions: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const sources = ["dist", "public", "node_modules", "package.json"]
|
|||||||
const serverInstallCommand =
|
const serverInstallCommand =
|
||||||
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||||
const serverDevInstallCommand =
|
const serverDevInstallCommand =
|
||||||
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
const uiDevInstallCommand =
|
const uiDevInstallCommand =
|
||||||
"npm ci --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
|
||||||
const envWithRootBin = {
|
const envWithRootBin = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -114,6 +114,42 @@ function ensureUiDevDependencies() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureRollupPlatformBinary() {
|
||||||
|
const platformKey = `${process.platform}-${process.arch}`
|
||||||
|
const platformPackages = {
|
||||||
|
"linux-x64": "@rollup/rollup-linux-x64-gnu",
|
||||||
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||||
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||||
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||||
|
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkgName = platformPackages[platformKey]
|
||||||
|
if (!pkgName) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
|
||||||
|
if (fs.existsSync(platformPackagePath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let rollupVersion = ""
|
||||||
|
try {
|
||||||
|
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
|
||||||
|
} catch (error) {
|
||||||
|
// leave version empty; fallback install will use latest compatible
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
|
||||||
|
|
||||||
|
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
|
||||||
|
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function copyServerArtifacts() {
|
function copyServerArtifacts() {
|
||||||
fs.rmSync(serverDest, { recursive: true, force: true })
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
fs.mkdirSync(serverDest, { recursive: true })
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
@@ -151,8 +187,9 @@ function copyUiLoadingAssets() {
|
|||||||
|
|
||||||
ensureServerDevDependencies()
|
ensureServerDevDependencies()
|
||||||
ensureUiDevDependencies()
|
ensureUiDevDependencies()
|
||||||
|
ensureRollupPlatformBinary()
|
||||||
|
ensureServerDependencies()
|
||||||
ensureServerBuild()
|
ensureServerBuild()
|
||||||
ensureUiBuild()
|
ensureUiBuild()
|
||||||
ensureServerDependencies()
|
|
||||||
copyServerArtifacts()
|
copyServerArtifacts()
|
||||||
copyUiLoadingAssets()
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.2.6",
|
"version": "0.2.7",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
@@ -206,6 +207,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||||
preferences,
|
preferences,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
@@ -248,7 +250,7 @@ const App: Component = () => {
|
|||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
|
||||||
Advanced Settings.
|
Advanced Settings.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|||||||
@@ -89,9 +89,9 @@ const AlertDialog: Component = () => {
|
|||||||
>
|
>
|
||||||
{accent.symbol}
|
{accent.symbol}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1 min-w-0">
|
||||||
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line">
|
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
|
||||||
{payload.message}
|
{payload.message}
|
||||||
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
|
||||||
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
|
||||||
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
import type { DiffHighlighterLang } from "@git-diff-view/core"
|
||||||
|
import { ErrorBoundary } from "solid-js"
|
||||||
import { getLanguageFromPath } from "../lib/markdown"
|
import { getLanguageFromPath } from "../lib/markdown"
|
||||||
import { normalizeDiffText } from "../lib/diff-utils"
|
import { normalizeDiffText } from "../lib/diff-utils"
|
||||||
import { setCacheEntry } from "../lib/global-cache"
|
import { setCacheEntry } from "../lib/global-cache"
|
||||||
@@ -54,94 +55,45 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let diffContainerRef: HTMLDivElement | undefined
|
let diffContainerRef: HTMLDivElement | undefined
|
||||||
let pendingCapture: number | undefined
|
let lastCapturedKey: string | undefined
|
||||||
let pendingContext: CaptureContext | undefined
|
|
||||||
let lastRenderedMarkup: string | undefined
|
|
||||||
let lastCachedHtml: string | undefined
|
|
||||||
|
|
||||||
const clearPendingCapture = () => {
|
const contextKey = createMemo(() => {
|
||||||
if (pendingCapture !== undefined) {
|
const data = diffData()
|
||||||
cancelAnimationFrame(pendingCapture)
|
if (!data) return ""
|
||||||
pendingCapture = undefined
|
return `${props.theme}|${props.mode}|${props.diffText}`
|
||||||
}
|
|
||||||
pendingContext = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const runCapture = (context: CaptureContext) => {
|
|
||||||
if (!diffContainerRef) {
|
|
||||||
props.onRendered?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const markup = diffContainerRef.innerHTML
|
|
||||||
if (!markup) {
|
|
||||||
props.onRendered?.()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChanged = markup !== lastRenderedMarkup
|
|
||||||
if (hasChanged) {
|
|
||||||
lastRenderedMarkup = markup
|
|
||||||
if (context.cacheEntryParams) {
|
|
||||||
setCacheEntry(context.cacheEntryParams, {
|
|
||||||
text: context.diffText,
|
|
||||||
html: markup,
|
|
||||||
theme: context.theme,
|
|
||||||
mode: context.mode,
|
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
props.onRendered?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleCapture = (context: CaptureContext) => {
|
|
||||||
clearPendingCapture()
|
|
||||||
pendingContext = context
|
|
||||||
pendingCapture = requestAnimationFrame(() => {
|
|
||||||
const activeContext = pendingContext
|
|
||||||
pendingContext = undefined
|
|
||||||
pendingCapture = undefined
|
|
||||||
if (activeContext) {
|
|
||||||
runCapture(activeContext)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const cachedHtml = props.cachedHtml
|
const cachedHtml = props.cachedHtml
|
||||||
if (cachedHtml) {
|
if (cachedHtml) {
|
||||||
clearPendingCapture()
|
// When we are given cached HTML, we rely on the caller's cache
|
||||||
if (cachedHtml !== lastCachedHtml) {
|
// and simply notify once rendered.
|
||||||
lastCachedHtml = cachedHtml
|
|
||||||
lastRenderedMarkup = cachedHtml
|
|
||||||
props.onRendered?.()
|
props.onRendered?.()
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastCachedHtml = undefined
|
const key = contextKey()
|
||||||
|
if (!key) return
|
||||||
|
if (!diffContainerRef) return
|
||||||
|
if (lastCapturedKey === key) return
|
||||||
|
|
||||||
const data = diffData()
|
requestAnimationFrame(() => {
|
||||||
const theme = props.theme
|
if (!diffContainerRef) return
|
||||||
const mode = props.mode
|
const markup = diffContainerRef.innerHTML
|
||||||
|
if (!markup) return
|
||||||
if (!data) {
|
lastCapturedKey = key
|
||||||
clearPendingCapture()
|
if (props.cacheEntryParams) {
|
||||||
return
|
setCacheEntry(props.cacheEntryParams, {
|
||||||
|
text: props.diffText,
|
||||||
|
html: markup,
|
||||||
|
theme: props.theme,
|
||||||
|
mode: props.mode,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
props.onRendered?.()
|
||||||
scheduleCapture({
|
|
||||||
theme,
|
|
||||||
mode,
|
|
||||||
diffText: props.diffText,
|
|
||||||
cacheEntryParams: props.cacheEntryParams,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
clearPendingCapture()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="tool-call-diff-viewer">
|
<div class="tool-call-diff-viewer">
|
||||||
@@ -154,6 +106,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
|
||||||
>
|
>
|
||||||
{(data) => (
|
{(data) => (
|
||||||
|
<ErrorBoundary fallback={(error) => {
|
||||||
|
console.warn("Failed to render diff view", error)
|
||||||
|
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
|
||||||
|
}}>
|
||||||
<DiffView
|
<DiffView
|
||||||
data={data()}
|
data={data()}
|
||||||
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
|
||||||
@@ -162,6 +118,7 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
|
|||||||
diffViewWrap={false}
|
diffViewWrap={false}
|
||||||
diffViewFontSize={13}
|
diffViewFontSize={13}
|
||||||
/>
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
|
|||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
|
||||||
<Dialog.Description class="text-sm text-secondary mt-2">
|
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
|
||||||
{folderLabel} can no longer be reached. Close the tab to continue working.
|
{folderLabel} can no longer be reached. Close the tab to continue working.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export function Markdown(props: MarkdownProps) {
|
|||||||
|
|
||||||
latestRequestedText = text
|
latestRequestedText = text
|
||||||
|
|
||||||
await initMarkdown(dark)
|
// Markdown initialization is now handled globally in App.
|
||||||
|
// initMarkdown is idempotent but we avoid per-part calls here.
|
||||||
|
|
||||||
if (!highlightEnabled) {
|
if (!highlightEnabled) {
|
||||||
part.renderCache = undefined
|
part.renderCache = undefined
|
||||||
|
|||||||
@@ -10,31 +10,36 @@ interface MessageItemProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
isQueued?: boolean
|
isQueued?: boolean
|
||||||
combinedParts: ClientPart[]
|
parts: ClientPart[]
|
||||||
orderedParts: ClientPart[]
|
|
||||||
onRevert?: (messageId: string) => void
|
onRevert?: (messageId: string) => void
|
||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
showAgentMeta?: boolean
|
showAgentMeta?: boolean
|
||||||
}
|
onContentRendered?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
|
||||||
const isUser = () => props.record.role === "user"
|
const isUser = () => props.record.role === "user"
|
||||||
|
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
|
||||||
|
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
|
const date = new Date(createdTimestamp())
|
||||||
const date = new Date(createdTime)
|
|
||||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timestampIso = () => new Date(createdTimestamp()).toISOString()
|
||||||
|
|
||||||
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
type FilePart = Extract<ClientPart, { type: "file" }> & {
|
||||||
url?: string
|
url?: string
|
||||||
mime?: string
|
mime?: string
|
||||||
filename?: string
|
filename?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedParts = () => props.combinedParts
|
const messageParts = () => props.parts
|
||||||
|
|
||||||
const fileAttachments = () =>
|
const fileAttachments = () =>
|
||||||
props.orderedParts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
|
||||||
|
|
||||||
|
|
||||||
const getAttachmentName = (part: FilePart) => {
|
const getAttachmentName = (part: FilePart) => {
|
||||||
if (part.filename && part.filename.trim().length > 0) {
|
if (part.filename && part.filename.trim().length > 0) {
|
||||||
@@ -124,7 +129,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return combinedParts().some((part) => partHasRenderableText(part))
|
return messageParts().some((part) => partHasRenderableText(part))
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGenerating = () => {
|
const isGenerating = () => {
|
||||||
@@ -147,6 +152,8 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||||
|
|
||||||
|
const speakerLabel = () => (isUser() ? "You" : "Assistant")
|
||||||
|
|
||||||
const agentIdentifier = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
@@ -164,42 +171,46 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
return modelID
|
return modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentMeta = () => {
|
||||||
|
if (isUser() || !props.showAgentMeta) return ""
|
||||||
|
const segments: string[] = []
|
||||||
|
const agent = agentIdentifier()
|
||||||
|
const model = modelIdentifier()
|
||||||
|
if (agent) {
|
||||||
|
segments.push(`Agent: ${agent}`)
|
||||||
|
}
|
||||||
|
if (model) {
|
||||||
|
segments.push(`Model: ${model}`)
|
||||||
|
}
|
||||||
|
return segments.join(" • ")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div class={containerClass()}>
|
<div class={containerClass()}>
|
||||||
<div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
|
||||||
<div class="flex flex-col">
|
<div class="message-speaker">
|
||||||
<Show when={isUser()}>
|
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
|
||||||
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
|
{speakerLabel()}
|
||||||
</Show>
|
|
||||||
<Show when={!isUser()}>
|
|
||||||
<div class="flex flex-wrap items-center gap-2 text-xs text-[var(--message-assistant-border)]">
|
|
||||||
<span class="font-semibold">Assistant</span>
|
|
||||||
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
|
|
||||||
<span class="message-step-meta-inline">
|
|
||||||
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
|
|
||||||
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<div class="message-item-actions">
|
||||||
</div>
|
<Show when={isUser()}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="message-action-group">
|
||||||
|
<Show when={props.onRevert}>
|
||||||
<Show when={isUser() && props.onRevert}>
|
|
||||||
<button
|
<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"
|
class="message-action-button"
|
||||||
onClick={handleRevert}
|
onClick={handleRevert}
|
||||||
title="Revert to this message"
|
title="Revert to this message"
|
||||||
aria-label="Revert to this message"
|
aria-label="Revert to this message"
|
||||||
>
|
>
|
||||||
Revert to
|
Revert
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={isUser() && props.onFork}>
|
<Show when={props.onFork}>
|
||||||
<button
|
<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"
|
class="message-action-button"
|
||||||
onClick={() => props.onFork?.(props.record.id)}
|
onClick={() => props.onFork?.(props.record.id)}
|
||||||
title="Fork from this message"
|
title="Fork from this message"
|
||||||
aria-label="Fork from this message"
|
aria-label="Fork from this message"
|
||||||
@@ -207,12 +218,16 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
Fork
|
Fork
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
|
||||||
|
|
||||||
|
|
||||||
<Show when={props.isQueued && isUser()}>
|
<Show when={props.isQueued && isUser()}>
|
||||||
<div class="message-queued-badge">QUEUED</div>
|
<div class="message-queued-badge">QUEUED</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -227,13 +242,14 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<For each={combinedParts()}>
|
<For each={messageParts()}>
|
||||||
{(part) => (
|
{(part) => (
|
||||||
<MessagePart
|
<MessagePart
|
||||||
part={part}
|
part={part}
|
||||||
messageType={props.record.role}
|
messageType={props.record.role}
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
sessionId={props.sessionId}
|
sessionId={props.sessionId}
|
||||||
|
onRendered={props.onContentRendered}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ interface MessagePartProps {
|
|||||||
messageType?: "user" | "assistant"
|
messageType?: "user" | "assistant"
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
onRendered?: () => void
|
||||||
export default function MessagePart(props: MessagePartProps) {
|
}
|
||||||
|
export default function MessagePart(props: MessagePartProps) {
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const partType = () => props.part?.type || ""
|
const partType = () => props.part?.type || ""
|
||||||
@@ -98,8 +100,14 @@ export default function MessagePart(props: MessagePartProps) {
|
|||||||
when={isAssistantMessage()}
|
when={isAssistantMessage()}
|
||||||
fallback={<span>{plainTextContent()}</span>}
|
fallback={<span>{plainTextContent()}</span>}
|
||||||
>
|
>
|
||||||
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
|
<Markdown
|
||||||
|
part={createTextPartForMarkdown()}
|
||||||
|
isDark={isDark()}
|
||||||
|
size={isAssistantMessage() ? "tight" : "base"}
|
||||||
|
onRendered={props.onRendered}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,8 +25,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
const session = () => props.activeSessions.get(props.sessionId)
|
const session = () => props.activeSessions.get(props.sessionId)
|
||||||
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
|
||||||
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
||||||
const currentSession = session()
|
const currentSession = session()
|
||||||
if (currentSession) {
|
if (currentSession) {
|
||||||
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
loadMessages(props.instanceId, currentSession.id).catch(console.error)
|
||||||
@@ -34,6 +36,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||||
|
if (scrollToBottomHandle) {
|
||||||
|
scrollToBottomHandle()
|
||||||
|
}
|
||||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +147,12 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
|
registerScrollToBottom={(fn) => {
|
||||||
|
scrollToBottomHandle = fn
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
<PromptInput
|
<PromptInput
|
||||||
instanceId={props.instanceId}
|
instanceId={props.instanceId}
|
||||||
instanceFolder={props.instanceFolder}
|
instanceFolder={props.instanceFolder}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
|
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { Markdown } from "./markdown"
|
import { Markdown } from "./markdown"
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||||
@@ -57,7 +56,9 @@ interface ToolCallProps {
|
|||||||
partVersion?: number
|
partVersion?: number
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
onContentRendered?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function getToolIcon(tool: string): string {
|
function getToolIcon(tool: string): string {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
@@ -349,10 +350,28 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
return props.toolCall.pendingPermission
|
return props.toolCall.pendingPermission
|
||||||
})
|
})
|
||||||
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
|
|
||||||
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
|
||||||
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
|
|
||||||
|
const defaultExpandedForTool = createMemo(() => {
|
||||||
|
const prefExpanded = toolOutputDefaultExpanded()
|
||||||
|
const toolName = props.toolCall?.tool || ""
|
||||||
|
if (toolName === "read") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return prefExpanded
|
||||||
|
})
|
||||||
|
|
||||||
|
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
|
||||||
|
|
||||||
|
const expanded = () => {
|
||||||
|
const permission = pendingPermission()
|
||||||
|
if (permission?.active) return true
|
||||||
|
const override = userExpanded()
|
||||||
|
if (override !== null) return override
|
||||||
|
return defaultExpandedForTool()
|
||||||
|
}
|
||||||
|
|
||||||
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
const permissionDetails = createMemo(() => pendingPermission()?.permission)
|
||||||
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
|
||||||
const activePermissionKey = createMemo(() => {
|
const activePermissionKey = createMemo(() => {
|
||||||
@@ -361,7 +380,16 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
})
|
})
|
||||||
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
|
||||||
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
const [permissionError, setPermissionError] = createSignal<string | null>(null)
|
||||||
const [diagnosticsExpanded, setDiagnosticsExpanded] = createSignal(diagnosticsDefaultExpanded())
|
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
const diagnosticsExpanded = () => {
|
||||||
|
const permission = pendingPermission()
|
||||||
|
if (permission?.active) return true
|
||||||
|
const override = diagnosticsOverride()
|
||||||
|
if (override !== undefined) return override
|
||||||
|
return diagnosticsDefaultExpanded()
|
||||||
|
}
|
||||||
|
|
||||||
const diagnosticsEntries = createMemo(() => {
|
const diagnosticsEntries = createMemo(() => {
|
||||||
const tool = props.toolCall?.tool || ""
|
const tool = props.toolCall?.tool || ""
|
||||||
const state = props.toolCall?.state
|
const state = props.toolCall?.state
|
||||||
@@ -369,10 +397,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return extractDiagnostics(tool, state)
|
return extractDiagnostics(tool, state)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const preferred = diagnosticsDefaultExpanded()
|
|
||||||
setDiagnosticsExpanded((prev) => (prev === preferred ? prev : preferred))
|
|
||||||
})
|
|
||||||
|
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
let toolCallRootRef: HTMLDivElement | undefined
|
let toolCallRootRef: HTMLDivElement | undefined
|
||||||
@@ -421,21 +445,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
restoreScrollSnapshot(resolvedElement)
|
restoreScrollSnapshot(resolvedElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const id = toolCallId()
|
|
||||||
if (!id) return
|
|
||||||
const toolName = props.toolCall?.tool || ""
|
|
||||||
const desiredExpansion = toolName === "read" ? false : toolOutputDefaultExpanded()
|
|
||||||
if (appliedPreference() === desiredExpansion) return
|
|
||||||
setToolCallExpanded(id, desiredExpansion)
|
|
||||||
setAppliedPreference(desiredExpansion)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const id = toolCallId()
|
|
||||||
if (!id) return
|
|
||||||
setAppliedPreference((prev) => (prev === null ? prev : null))
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const permission = permissionDetails()
|
const permission = permissionDetails()
|
||||||
@@ -447,19 +456,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (props.toolCall?.tool !== "task") return
|
|
||||||
const state = props.toolCall?.state
|
|
||||||
const summarySignature = JSON.stringify(
|
|
||||||
state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
|
|
||||||
? state.metadata?.summary ?? []
|
|
||||||
: []
|
|
||||||
)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
void summarySignature
|
|
||||||
handleScrollRendered()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeKey = activePermissionKey()
|
const activeKey = activePermissionKey()
|
||||||
@@ -488,6 +484,11 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
onCleanup(() => document.removeEventListener("keydown", handler))
|
onCleanup(() => document.removeEventListener("keydown", handler))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!expanded()) {
|
||||||
|
scrollContainerRef = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const statusIcon = () => {
|
const statusIcon = () => {
|
||||||
const status = props.toolCall?.state?.status || ""
|
const status = props.toolCall?.state?.status || ""
|
||||||
@@ -516,7 +517,14 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
toggleToolCallExpanded(toolCallId())
|
const permission = pendingPermission()
|
||||||
|
if (permission?.active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUserExpanded((prev) => {
|
||||||
|
const current = prev === null ? defaultExpandedForTool() : prev
|
||||||
|
return !current
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderToolAction = () => {
|
const renderToolAction = () => {
|
||||||
@@ -760,8 +768,10 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (!options?.disableScrollTracking) {
|
if (!options?.disableScrollTracking) {
|
||||||
handleScrollRendered()
|
handleScrollRendered()
|
||||||
}
|
}
|
||||||
|
props.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||||
@@ -825,8 +835,10 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const handleMarkdownRendered = () => {
|
const handleMarkdownRendered = () => {
|
||||||
markdownCache.set(markdownPart.renderCache)
|
markdownCache.set(markdownPart.renderCache)
|
||||||
handleScrollRendered()
|
handleScrollRendered()
|
||||||
|
props.onContentRendered?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={messageClass}
|
class={messageClass}
|
||||||
@@ -1185,14 +1197,18 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}}
|
}}
|
||||||
class={`tool-call ${combinedStatusClass()}`}
|
class={`tool-call ${combinedStatusClass()}`}
|
||||||
>
|
>
|
||||||
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
|
<button
|
||||||
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
|
class="tool-call-header"
|
||||||
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
|
onClick={toggle}
|
||||||
<span class="tool-call-summary">{renderToolTitle()}</span>
|
aria-expanded={expanded()}
|
||||||
<span class="tool-call-status">{statusIcon()}</span>
|
data-status-icon={statusIcon()}
|
||||||
|
>
|
||||||
|
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
|
||||||
|
{renderToolTitle()}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={expanded()}>
|
{expanded() && (
|
||||||
<div class="tool-call-details">
|
<div class="tool-call-details">
|
||||||
{renderToolBody()}
|
{renderToolBody()}
|
||||||
|
|
||||||
@@ -1207,13 +1223,17 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
)}
|
||||||
|
|
||||||
<Show when={diagnosticsEntries().length}>
|
<Show when={diagnosticsEntries().length}>
|
||||||
|
|
||||||
{renderDiagnosticsSection(
|
{renderDiagnosticsSection(
|
||||||
diagnosticsEntries(),
|
diagnosticsEntries(),
|
||||||
diagnosticsExpanded(),
|
diagnosticsExpanded(),
|
||||||
() => setDiagnosticsExpanded((prev) => !prev),
|
() => setDiagnosticsOverride((prev) => {
|
||||||
|
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
|
||||||
|
return !current
|
||||||
|
}),
|
||||||
getToolIcon(toolName()),
|
getToolIcon(toolName()),
|
||||||
diagnosticFileName(diagnosticsEntries()),
|
diagnosticFileName(diagnosticsEntries()),
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ import { showAlertDialog } from "../../stores/alerts"
|
|||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
import type { MessageRecord } from "../../stores/message-v2/types"
|
import type { MessageRecord } from "../../stores/message-v2/types"
|
||||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
|
import { cleanupBlankSessions } from "../../stores/session-state"
|
||||||
|
|
||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -142,6 +144,19 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "cleanup-blank-sessions",
|
||||||
|
label: "Scrub Sessions",
|
||||||
|
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
|
||||||
|
category: "Session",
|
||||||
|
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
|
||||||
|
action: async () => {
|
||||||
|
const instance = activeInstance()
|
||||||
|
if (!instance) return
|
||||||
|
cleanupBlankSessions(instance.id, undefined, true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "switch-to-info",
|
id: "switch-to-info",
|
||||||
label: "Instance Info",
|
label: "Instance Info",
|
||||||
@@ -468,6 +483,18 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
action: options.toggleUsageMetrics,
|
action: options.toggleUsageMetrics,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "auto-cleanup-blank-sessions",
|
||||||
|
label: () => {
|
||||||
|
const enabled = options.preferences().autoCleanupBlankSessions
|
||||||
|
return `Auto-Cleanup Blank Sessions · ${enabled ? "Enabled" : "Disabled"}`
|
||||||
|
},
|
||||||
|
description: "Automatically clean up blank sessions when creating new ones",
|
||||||
|
category: "System",
|
||||||
|
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
|
||||||
|
action: options.toggleAutoCleanupBlankSessions,
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "help",
|
id: "help",
|
||||||
label: "Show Help",
|
label: "Show Help",
|
||||||
|
|||||||
@@ -618,6 +618,13 @@ function mutateToolPartPermission(
|
|||||||
draft.updatedAt = Date.now()
|
draft.updatedAt = Date.now()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Permission attachment/removal can change the rendered height of the
|
||||||
|
// message list (e.g., permission blocks or diffs), so bump the
|
||||||
|
// session revision to ensure auto-scroll reacts.
|
||||||
|
if (messageRecord.sessionId) {
|
||||||
|
store.setState("sessionRevisions", messageRecord.sessionId, (value: number = 0) => value + 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {
|
||||||
|
|||||||
@@ -46,36 +46,13 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
|
|||||||
const PENDING_PART_MAX_AGE_MS = 30_000
|
const PENDING_PART_MAX_AGE_MS = 30_000
|
||||||
|
|
||||||
function clonePart(part: ClientPart): ClientPart {
|
function clonePart(part: ClientPart): ClientPart {
|
||||||
if (!part || typeof part !== "object") {
|
// Cloning is intentionally disabled; message parts
|
||||||
|
// are stored as received from the backend.
|
||||||
return part
|
return part
|
||||||
}
|
|
||||||
const cloned: Record<string, any> = { ...part }
|
|
||||||
if ("renderCache" in cloned) {
|
|
||||||
cloned.renderCache = undefined
|
|
||||||
}
|
|
||||||
if ("text" in cloned) {
|
|
||||||
cloned.text = cloneStructuredValue(cloned.text)
|
|
||||||
}
|
|
||||||
if ("thinking" in cloned && typeof cloned.thinking === "object") {
|
|
||||||
cloned.thinking = cloneStructuredValue(cloned.thinking)
|
|
||||||
}
|
|
||||||
if ("content" in cloned && Array.isArray(cloned.content)) {
|
|
||||||
cloned.content = cloneStructuredValue(cloned.content)
|
|
||||||
}
|
|
||||||
return cloned as ClientPart
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cloneStructuredValue<T>(value: T): T {
|
function cloneStructuredValue<T>(value: T): T {
|
||||||
if (Array.isArray(value)) {
|
// Legacy helper kept as a no-op to avoid deep copies.
|
||||||
return value.map((item) => cloneStructuredValue(item)) as T
|
|
||||||
}
|
|
||||||
if (value && typeof value === "object") {
|
|
||||||
const next: Record<string, any> = {}
|
|
||||||
Object.entries(value as Record<string, any>).forEach(([key, nested]) => {
|
|
||||||
next[key] = cloneStructuredValue(nested)
|
|
||||||
})
|
|
||||||
return next as T
|
|
||||||
}
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,8 +463,13 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Any part update can change the rendered height of the message
|
||||||
|
// list, so we treat it as a session revision for scroll purposes.
|
||||||
|
bumpSessionRevision(message.sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function flushPendingParts(messageId: string) {
|
function flushPendingParts(messageId: string) {
|
||||||
const pending = state.pendingParts[messageId]
|
const pending = state.pendingParts[messageId]
|
||||||
if (!pending || pending.length === 0) {
|
if (!pending || pending.length === 0) {
|
||||||
@@ -636,9 +618,60 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
|||||||
return { entry, active }
|
return { entry, active }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
|
||||||
|
const session = state.sessions[sessionId]
|
||||||
|
if (!session) return
|
||||||
|
const stopIndex = session.messageIds.indexOf(revertMessageId)
|
||||||
|
if (stopIndex === -1) return
|
||||||
|
const removedIds = session.messageIds.slice(stopIndex)
|
||||||
|
const keptIds = session.messageIds.slice(0, stopIndex)
|
||||||
|
if (removedIds.length === 0) return
|
||||||
|
|
||||||
|
setState("sessions", sessionId, "messageIds", keptIds)
|
||||||
|
|
||||||
|
setState("messages", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => delete next[id])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setState("messageInfoVersion", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => delete next[id])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
removedIds.forEach((id) => messageInfoCache.delete(id))
|
||||||
|
|
||||||
|
setState("pendingParts", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
setState("permissions", "byMessage", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
removedIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
withUsageState(sessionId, (draft) => {
|
||||||
|
removedIds.forEach((id) => removeUsageEntry(draft, id))
|
||||||
|
})
|
||||||
|
|
||||||
|
bumpSessionRevision(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
|
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
ensureSessionEntry(sessionId)
|
ensureSessionEntry(sessionId)
|
||||||
|
if (revert?.messageID) {
|
||||||
|
pruneMessagesAfterRevert(sessionId, revert.messageID)
|
||||||
|
}
|
||||||
setState("sessions", sessionId, "revert", revert ?? null)
|
setState("sessions", sessionId, "revert", revert ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export interface Preferences {
|
|||||||
toolOutputExpansion: ExpansionPreference
|
toolOutputExpansion: ExpansionPreference
|
||||||
diagnosticsExpansion: ExpansionPreference
|
diagnosticsExpansion: ExpansionPreference
|
||||||
showUsageMetrics: boolean
|
showUsageMetrics: boolean
|
||||||
|
autoCleanupBlankSessions?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeBinary {
|
export interface OpenCodeBinary {
|
||||||
@@ -64,6 +65,7 @@ const defaultPreferences: Preferences = {
|
|||||||
toolOutputExpansion: "expanded",
|
toolOutputExpansion: "expanded",
|
||||||
diagnosticsExpansion: "expanded",
|
diagnosticsExpansion: "expanded",
|
||||||
showUsageMetrics: true,
|
showUsageMetrics: true,
|
||||||
|
autoCleanupBlankSessions: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepEqual(a: unknown, b: unknown): boolean {
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
@@ -98,6 +100,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||||
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||||
|
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +288,11 @@ function toggleUsageMetrics(): void {
|
|||||||
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAutoCleanupBlankSessions(): void {
|
||||||
|
console.log("toggle auto cleanup")
|
||||||
|
updatePreferences({ autoCleanupBlankSessions: !preferences().autoCleanupBlankSessions })
|
||||||
|
}
|
||||||
|
|
||||||
function addRecentFolder(path: string): void {
|
function addRecentFolder(path: string): void {
|
||||||
updateConfig((draft) => {
|
updateConfig((draft) => {
|
||||||
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
||||||
@@ -386,6 +394,7 @@ interface ConfigContextValue {
|
|||||||
updateConfig: typeof updateConfig
|
updateConfig: typeof updateConfig
|
||||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
setDiffViewMode: typeof setDiffViewMode
|
setDiffViewMode: typeof setDiffViewMode
|
||||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||||
@@ -418,6 +427,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -473,6 +483,7 @@ export {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import type { Session } from "../types/session"
|
|||||||
import type { Message } from "../types/message"
|
import type { Message } from "../types/message"
|
||||||
|
|
||||||
import { instances, refreshPermissionsForSession } from "./instances"
|
import { instances, refreshPermissionsForSession } from "./instances"
|
||||||
import { setAgentModelPreference } from "./preferences"
|
import { preferences, setAgentModelPreference } from "./preferences"
|
||||||
import { setSessionCompactionState } from "./session-compaction"
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
import {
|
import {
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
agents,
|
agents,
|
||||||
clearSessionDraftPrompt,
|
clearSessionDraftPrompt,
|
||||||
|
getChildSessions,
|
||||||
|
isBlankSession,
|
||||||
messagesLoaded,
|
messagesLoaded,
|
||||||
providers,
|
|
||||||
pruneDraftPrompts,
|
pruneDraftPrompts,
|
||||||
|
providers,
|
||||||
setActiveSessionId,
|
setActiveSessionId,
|
||||||
setAgents,
|
setAgents,
|
||||||
setMessagesLoaded,
|
setMessagesLoaded,
|
||||||
@@ -20,6 +22,7 @@ import {
|
|||||||
sessions,
|
sessions,
|
||||||
loading,
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
|
cleanupBlankSessions,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import { normalizeMessagePart } from "./message-v2/normalizers"
|
import { normalizeMessagePart } from "./message-v2/normalizers"
|
||||||
@@ -228,6 +231,10 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (preferences().autoCleanupBlankSessions) {
|
||||||
|
await cleanupBlankSessions(instanceId, session.id)
|
||||||
|
}
|
||||||
|
|
||||||
return session
|
return session
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create session:", error)
|
console.error("Failed to create session:", error)
|
||||||
|
|||||||
@@ -83,18 +83,23 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
if (!rawPart) return
|
if (!rawPart) return
|
||||||
|
|
||||||
const part = normalizeMessagePart(rawPart)
|
const part = normalizeMessagePart(rawPart)
|
||||||
const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined
|
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
|
||||||
const messageId = typeof part.messageID === "string" ? part.messageID : undefined
|
|
||||||
|
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
|
||||||
|
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
|
||||||
|
|
||||||
|
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
|
||||||
|
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
|
||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
const session = instanceSessions.get(sessionId)
|
const session = instanceSessions.get(sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
|
|
||||||
const role: MessageRole = resolveMessageRole(messageInfo)
|
const role: MessageRole = resolveMessageRole(messageInfo)
|
||||||
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
|
||||||
|
|
||||||
|
|
||||||
let record = store.getMessage(messageId)
|
let record = store.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const pendingId = findPendingMessageId(store, sessionId, role)
|
const pendingId = findPendingMessageId(store, sessionId, role)
|
||||||
@@ -120,7 +125,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
|
||||||
}
|
}
|
||||||
|
|
||||||
applyPartUpdateV2(instanceId, part)
|
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
|
||||||
|
|
||||||
|
|
||||||
updateSessionInfo(instanceId, sessionId)
|
updateSessionInfo(instanceId, sessionId)
|
||||||
refreshPermissionsForSession(instanceId, sessionId)
|
refreshPermissionsForSession(instanceId, sessionId)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { createSignal } from "solid-js"
|
import { createSignal } from "solid-js"
|
||||||
|
|
||||||
import type { Session, Agent, Provider } from "../types/session"
|
import type { Session, Agent, Provider } from "../types/session"
|
||||||
|
import { deleteSession, loadMessages } from "./session-api"
|
||||||
|
import { showToastNotification } from "../lib/notifications"
|
||||||
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
|
import { instances } from "./instances"
|
||||||
|
import { showConfirmDialog } from "./alerts"
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
cost: number
|
cost: number
|
||||||
@@ -221,6 +226,108 @@ function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | un
|
|||||||
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise<boolean> {
|
||||||
|
const created = session.time?.created || 0
|
||||||
|
const updated = session.time?.updated || 0
|
||||||
|
const hasChildren = getChildSessions(instanceId, session.id).length > 0
|
||||||
|
const isFreshSession = created === updated && !hasChildren
|
||||||
|
|
||||||
|
// Common short-circuit: fresh sessions without children
|
||||||
|
if (!fetchIfNeeded) {
|
||||||
|
return isFreshSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a more thorough deep clean, we need to look at actual messages
|
||||||
|
|
||||||
|
const instance = instances().get(instanceId)
|
||||||
|
if (!instance?.client) {
|
||||||
|
return isFreshSession
|
||||||
|
}
|
||||||
|
let messages: any[] = []
|
||||||
|
try {
|
||||||
|
const response = await instance.client.session.messages({ path: { id: session.id } })
|
||||||
|
messages = response.data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch messages for session ${session.id}:`, error)
|
||||||
|
return isFreshSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specific logic by session type
|
||||||
|
if (session.parentId === null) {
|
||||||
|
// Parent: blank if no messages and no children (fresh !== blank sometimes!)
|
||||||
|
const hasChildren = getChildSessions(instanceId, session.id).length > 0
|
||||||
|
return messages.length === 0 && !hasChildren
|
||||||
|
} else if (session.title?.includes("subagent)")) {
|
||||||
|
// Subagent: "blank" (really: finished doing its job) if actually blank...
|
||||||
|
// ... OR no streaming, no pending perms, no tool parts
|
||||||
|
if (messages.length === 0) return true
|
||||||
|
|
||||||
|
const hasStreaming = messages.some((msg) => {
|
||||||
|
const info = msg.info.status || msg.status
|
||||||
|
return info === "streaming" || info === "sending"
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const lastParts = lastMessage?.parts || []
|
||||||
|
const hasToolPart = lastParts.some((part: any) =>
|
||||||
|
part.type === "tool" || part.data?.type === "tool"
|
||||||
|
)
|
||||||
|
|
||||||
|
return !hasStreaming && !session.pendingPermission && !hasToolPart
|
||||||
|
} else {
|
||||||
|
// Fork: blank if somehow has no messages or at revert point
|
||||||
|
if (messages.length === 0) return true
|
||||||
|
|
||||||
|
const lastMessage = messages[messages.length - 1]
|
||||||
|
const lastInfo = lastMessage?.info || lastMessage
|
||||||
|
return lastInfo?.id === session.revert?.messageID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function cleanupBlankSessions(instanceId: string, excludeSessionId?: string, fetchIfNeeded = false): Promise<void> {
|
||||||
|
const instanceSessions = sessions().get(instanceId)
|
||||||
|
if (!instanceSessions) return
|
||||||
|
|
||||||
|
if (fetchIfNeeded) {
|
||||||
|
const confirmed = await showConfirmDialog(
|
||||||
|
"This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?",
|
||||||
|
{
|
||||||
|
title: "Deep Clean Sessions",
|
||||||
|
detail: "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.",
|
||||||
|
confirmLabel: "Continue",
|
||||||
|
cancelLabel: "Cancel"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupPromises = Array.from(instanceSessions)
|
||||||
|
.filter(([sessionId]) => sessionId !== excludeSessionId)
|
||||||
|
.map(async ([sessionId, session]) => {
|
||||||
|
const isBlank = await isBlankSession(session, instanceId, fetchIfNeeded)
|
||||||
|
if (!isBlank) return false
|
||||||
|
|
||||||
|
await deleteSession(instanceId, sessionId).catch((error: Error) => {
|
||||||
|
console.error(`Failed to delete blank session ${sessionId}:`, error)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (cleanupPromises.length > 0) {
|
||||||
|
console.log(`Cleaning up ${cleanupPromises.length} blank sessions`)
|
||||||
|
const deletionResults = await Promise.all(cleanupPromises)
|
||||||
|
const deletedCount = deletionResults.filter(Boolean).length
|
||||||
|
|
||||||
|
if (deletedCount > 0) {
|
||||||
|
showToastNotification({
|
||||||
|
message: `Cleaned up ${deletedCount} blank session${deletedCount === 1 ? "" : "s"}`,
|
||||||
|
variant: "info"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
sessions,
|
sessions,
|
||||||
setSessions,
|
setSessions,
|
||||||
@@ -259,4 +366,6 @@ export {
|
|||||||
isSessionBusy,
|
isSessionBusy,
|
||||||
isSessionMessagesLoading,
|
isSessionMessagesLoading,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
|
isBlankSession,
|
||||||
|
cleanupBlankSessions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,56 @@
|
|||||||
@apply flex flex-col gap-2 p-3 w-full;
|
@apply flex flex-col gap-2 p-3 w-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-item-header {
|
||||||
|
@apply flex justify-between items-start gap-2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-speaker {
|
||||||
|
@apply flex flex-col gap-0.5 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-speaker-label {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-speaker-label[data-role="user"] {
|
||||||
|
color: var(--message-user-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-speaker-label[data-role="assistant"] {
|
||||||
|
color: var(--message-assistant-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-agent-meta {
|
||||||
|
@apply text-xs text-[var(--message-assistant-border)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item-actions {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-group {
|
||||||
|
@apply flex items-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-button {
|
||||||
|
@apply 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-button:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-action-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timestamp {
|
||||||
|
@apply text-[11px] text-[var(--text-muted)];
|
||||||
|
}
|
||||||
|
|
||||||
.assistant-message {
|
.assistant-message {
|
||||||
/* gap: 0.25rem; */
|
/* gap: 0.25rem; */
|
||||||
padding: 0.6rem 0.65rem;
|
padding: 0.6rem 0.65rem;
|
||||||
@@ -35,6 +85,22 @@
|
|||||||
@apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)];
|
@apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-step-usage-chip {
|
||||||
|
@apply inline-flex items-center rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px];
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-step-usage-chip::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
.message-step-heading {
|
.message-step-heading {
|
||||||
@apply flex flex-wrap items-center gap-2 text-xs;
|
@apply flex flex-wrap items-center gap-2 text-xs;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -85,20 +85,46 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-header::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: 11px;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header[aria-expanded="true"]::before {
|
||||||
|
content: "▼";
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header::after {
|
||||||
|
content: attr(data-status-icon);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-header[data-status-icon=""]::after {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-call-header:hover {
|
.tool-call-header:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-icon {
|
|
||||||
@apply text-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-call-summary {
|
.tool-call-summary {
|
||||||
@apply flex-1 text-left;
|
@apply flex-1 text-left inline-flex items-center gap-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-status {
|
.tool-call-summary::before {
|
||||||
@apply text-sm;
|
content: attr(data-tool-icon);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-summary[data-tool-icon=""]::before {
|
||||||
|
margin-right: 0;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-summary[data-tool-icon]:not([data-tool-icon=""])::before {
|
||||||
|
margin-right: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-status-success {
|
.tool-call-status-success {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
@apply rounded-lg shadow-2xl flex flex-col;
|
@apply rounded-lg shadow-2xl flex flex-col;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
max-width: min(100%, calc(100vw - 32px));
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-search-container {
|
.modal-search-container {
|
||||||
|
|||||||
Reference in New Issue
Block a user