Compare commits
29 Commits
v0.2.8-dev
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3706d2985 | ||
|
|
9769d7a46e | ||
|
|
783fb5c5b2 | ||
|
|
82ff1916b7 | ||
|
|
8204143810 | ||
|
|
e54f80f20e | ||
|
|
54a2917faa | ||
|
|
b72ead1bea | ||
|
|
7996228327 | ||
|
|
7aba3c1221 | ||
|
|
11dedd4446 | ||
|
|
8fcf757c5c | ||
|
|
5cf3c001b5 | ||
|
|
4ae54a1f7b | ||
|
|
81a9c28971 | ||
|
|
235b9338a7 | ||
|
|
642d5e22e6 | ||
|
|
67ff00d83e | ||
|
|
710938eef8 | ||
|
|
dc702b1fb2 | ||
|
|
92d16084db | ||
|
|
9b0e02f66f | ||
|
|
a2e5034c20 | ||
|
|
e3489b22e6 | ||
|
|
cd8948770d | ||
|
|
d4281f1d9c | ||
|
|
49214c60ca | ||
|
|
0a530a257f | ||
|
|
54f269e955 |
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
71
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or regression in CodeNomad
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
title: "[Bug]: "
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for filing a bug report! Please review open issues before submitting a new one and provide as much detail as possible so we can reproduce the problem.
|
||||||
|
- type: dropdown
|
||||||
|
id: variant
|
||||||
|
attributes:
|
||||||
|
label: App Variant
|
||||||
|
description: Which build are you running when this issue appears?
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- Electron
|
||||||
|
- Tauri
|
||||||
|
- Server CLI
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: os-version
|
||||||
|
attributes:
|
||||||
|
label: Operating System & Version
|
||||||
|
description: Include the OS family and version (e.g., macOS 15.0, Ubuntu 24.04, Windows 11 23H2).
|
||||||
|
placeholder: macOS 15.0
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: Issue Summary
|
||||||
|
description: Briefly describe what is happening.
|
||||||
|
placeholder: A quick one sentence problem statement
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: repro
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: List the steps needed to reproduce the problem.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
2. Click ...
|
||||||
|
3. Observe ...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: Describe what you expected to happen instead.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Logs & Screenshots
|
||||||
|
description: Attach relevant logs, stack traces, or screenshots if available.
|
||||||
|
placeholder: Paste logs here or drag-and-drop files onto the issue.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: extra
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context about the problem here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
57
.github/workflows/dev-release.yml
vendored
57
.github/workflows/dev-release.yml
vendored
@@ -7,61 +7,10 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: 20
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-dev:
|
dev-release:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
outputs:
|
|
||||||
version: ${{ steps.versions.outputs.version }}
|
|
||||||
tag: ${{ steps.versions.outputs.tag }}
|
|
||||||
release_name: ${{ steps.versions.outputs.release_name }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Compute dev versions
|
|
||||||
id: versions
|
|
||||||
run: |
|
|
||||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
|
||||||
DEV_VERSION="${BASE_VERSION}-dev"
|
|
||||||
TAG="v${DEV_VERSION}"
|
|
||||||
echo "version=$DEV_VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Create GitHub release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ steps.versions.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
||||||
echo "Release $TAG already exists"
|
|
||||||
else
|
|
||||||
gh release create "$TAG" --title "$TAG" --generate-notes
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-and-upload:
|
|
||||||
needs: prepare-dev
|
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prepare-dev.outputs.version }}
|
version_suffix: -dev
|
||||||
tag: ${{ needs.prepare-dev.outputs.tag }}
|
|
||||||
release_name: ${{ needs.prepare-dev.outputs.release_name }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
publish-server:
|
|
||||||
needs:
|
|
||||||
- prepare-dev
|
|
||||||
- build-and-upload
|
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
|
||||||
with:
|
|
||||||
version: ${{ needs.prepare-dev.outputs.version }}
|
|
||||||
dist_tag: dev
|
dist_tag: dev
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
@@ -9,77 +9,9 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
env:
|
|
||||||
NODE_VERSION: 20
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare-release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
uses: ./.github/workflows/reusable-release.yml
|
||||||
outputs:
|
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
|
||||||
tag: ${{ steps.ensure_tag.outputs.tag }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
|
||||||
|
|
||||||
- name: Read version
|
|
||||||
id: get_version
|
|
||||||
run: |
|
|
||||||
VERSION=$(node -p "require('./package.json').version")
|
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Ensure git tag
|
|
||||||
id: ensure_tag
|
|
||||||
env:
|
|
||||||
VERSION: ${{ steps.get_version.outputs.version }}
|
|
||||||
run: |
|
|
||||||
TAG="v${VERSION}"
|
|
||||||
git fetch --tags
|
|
||||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Tag $TAG already exists"
|
|
||||||
else
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git tag "$TAG"
|
|
||||||
git push origin "$TAG"
|
|
||||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Created tag $TAG"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Ensure GitHub release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TAG: ${{ steps.ensure_tag.outputs.tag }}
|
|
||||||
VERSION: ${{ steps.get_version.outputs.version }}
|
|
||||||
run: |
|
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
|
||||||
echo "Release $TAG already exists"
|
|
||||||
else
|
|
||||||
gh release create "$TAG" --title "CodeNomad v${VERSION}" --generate-notes
|
|
||||||
fi
|
|
||||||
|
|
||||||
build-and-upload:
|
|
||||||
needs: prepare-release
|
|
||||||
uses: ./.github/workflows/build-and-upload.yml
|
|
||||||
with:
|
with:
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
|
||||||
tag: ${{ needs.prepare-release.outputs.tag }}
|
|
||||||
release_name: CodeNomad v${{ needs.prepare-release.outputs.version }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
publish-server:
|
|
||||||
needs:
|
|
||||||
- prepare-release
|
|
||||||
- build-and-upload
|
|
||||||
if: ${{ needs.build-and-upload.result == 'success' }}
|
|
||||||
uses: ./.github/workflows/manual-npm-publish.yml
|
|
||||||
with:
|
|
||||||
version: ${{ needs.prepare-release.outputs.version }}
|
|
||||||
dist_tag: latest
|
dist_tag: latest
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|||||||
80
.github/workflows/reusable-release.yml
vendored
Normal file
80
.github/workflows/reusable-release.yml
vendored
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
name: Reusable Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
version_suffix:
|
||||||
|
description: "Suffix appended to package.json version"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
type: string
|
||||||
|
dist_tag:
|
||||||
|
description: "npm dist-tag to publish under"
|
||||||
|
required: false
|
||||||
|
default: dev
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: 20
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.versions.outputs.version }}
|
||||||
|
tag: ${{ steps.versions.outputs.tag }}
|
||||||
|
release_name: ${{ steps.versions.outputs.release_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Compute release versions
|
||||||
|
id: versions
|
||||||
|
env:
|
||||||
|
VERSION_SUFFIX: ${{ inputs.version_suffix }}
|
||||||
|
run: |
|
||||||
|
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
VERSION="${BASE_VERSION}${VERSION_SUFFIX}"
|
||||||
|
TAG="v${VERSION}"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_name=$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create GitHub release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ steps.versions.outputs.tag }}
|
||||||
|
run: |
|
||||||
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
|
echo "Release $TAG already exists"
|
||||||
|
else
|
||||||
|
gh release create "$TAG" --title "$TAG" --generate-notes
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-and-upload:
|
||||||
|
needs: prepare-release
|
||||||
|
uses: ./.github/workflows/build-and-upload.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
|
tag: ${{ needs.prepare-release.outputs.tag }}
|
||||||
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
publish-server:
|
||||||
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- build-and-upload
|
||||||
|
uses: ./.github/workflows/manual-npm-publish.yml
|
||||||
|
with:
|
||||||
|
version: ${{ needs.prepare-release.outputs.version }}
|
||||||
|
dist_tag: ${{ inputs.dist_tag }}
|
||||||
|
secrets: inherit
|
||||||
@@ -44,6 +44,12 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For dev version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx @neuralnomads/codenomad@dev --launch
|
||||||
|
```
|
||||||
|
|
||||||
This command starts the server and opens the web client in your default browser.
|
This command starts the server and opens the web client in your default browser.
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -8815,7 +8815,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -8843,7 +8843,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -8882,14 +8882,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"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.8",
|
"version": "0.3.0",
|
||||||
"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.8",
|
"version": "0.3.0",
|
||||||
"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.8",
|
"version": "0.3.0",
|
||||||
"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.8",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"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.8",
|
"version": "0.3.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const AgentModelSelectionsSchema = z.record(z.string(), AgentModelSelectionSchem
|
|||||||
const PreferencesSchema = z.object({
|
const PreferencesSchema = z.object({
|
||||||
showThinkingBlocks: z.boolean().default(false),
|
showThinkingBlocks: z.boolean().default(false),
|
||||||
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
thinkingBlocksExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
showTimelineTools: z.boolean().default(true),
|
||||||
lastUsedBinary: z.string().optional(),
|
lastUsedBinary: z.string().optional(),
|
||||||
environmentVariables: z.record(z.string()).default({}),
|
environmentVariables: z.record(z.string()).default({}),
|
||||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||||
|
|||||||
1
packages/tauri-app/Cargo.lock
generated
1
packages/tauri-app/Cargo.lock
generated
@@ -494,6 +494,7 @@ dependencies = [
|
|||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"url",
|
||||||
"which",
|
"which",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"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",
|
||||||
|
|||||||
@@ -20,3 +20,4 @@ libc = "0.2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
|
url = "2"
|
||||||
|
|||||||
@@ -3,15 +3,14 @@
|
|||||||
"identifier": "main-window-native-dialogs",
|
"identifier": "main-window-native-dialogs",
|
||||||
"description": "Grant the main window access to required core features and native dialog commands.",
|
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||||
"remote": {
|
"remote": {
|
||||||
"urls": [
|
"urls": ["http://127.0.0.1:*", "http://localhost:*"]
|
||||||
"http://127.0.0.1:*",
|
|
||||||
"http://localhost:*"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
|
"core:menu:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"opener:allow-default-urls"
|
"opener:allow-default-urls",
|
||||||
|
"core:webview:allow-set-webview-zoom"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","core:menu:default","dialog:allow-open","opener:allow-default-urls","core:webview:allow-set-webview-zoom"]}}
|
||||||
@@ -134,6 +134,174 @@
|
|||||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"allOf": [
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:default",
|
||||||
|
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-default-urls",
|
||||||
|
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-path",
|
||||||
|
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-url",
|
||||||
|
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-path",
|
||||||
|
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-url",
|
||||||
|
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"items": {
|
||||||
|
"title": "OpenerScopeEntry",
|
||||||
|
"description": "Opener scope entry.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this url with, for example: firefox.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this path with, for example: xdg-open.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"items": {
|
||||||
|
"title": "OpenerScopeEntry",
|
||||||
|
"description": "Opener scope entry.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this url with, for example: firefox.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this path with, for example: xdg-open.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "Identifier of the permission or permission set.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Identifier"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"properties": {
|
"properties": {
|
||||||
"identifier": {
|
"identifier": {
|
||||||
@@ -2209,6 +2377,54 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:default",
|
||||||
|
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-default-urls",
|
||||||
|
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-path",
|
||||||
|
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-url",
|
||||||
|
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-path",
|
||||||
|
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-url",
|
||||||
|
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -2305,6 +2521,23 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Application": {
|
||||||
|
"description": "Opener scope application.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Open in default application.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "If true, allow open with any application.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Allow specific application to open with.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +134,174 @@
|
|||||||
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
"description": "Reference a permission or permission set by identifier and extends its scope.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"allOf": [
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:default",
|
||||||
|
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-default-urls",
|
||||||
|
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-path",
|
||||||
|
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-url",
|
||||||
|
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-path",
|
||||||
|
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-url",
|
||||||
|
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"allow": {
|
||||||
|
"items": {
|
||||||
|
"title": "OpenerScopeEntry",
|
||||||
|
"description": "Opener scope entry.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this url with, for example: firefox.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this path with, for example: xdg-open.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deny": {
|
||||||
|
"items": {
|
||||||
|
"title": "OpenerScopeEntry",
|
||||||
|
"description": "Opener scope entry.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this url with, for example: firefox.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"app": {
|
||||||
|
"description": "An application to open this path with, for example: xdg-open.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Application"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"path": {
|
||||||
|
"description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"identifier": {
|
||||||
|
"description": "Identifier of the permission or permission set.",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/Identifier"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"properties": {
|
"properties": {
|
||||||
"identifier": {
|
"identifier": {
|
||||||
@@ -2209,6 +2377,54 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "dialog:deny-save",
|
"const": "dialog:deny-save",
|
||||||
"markdownDescription": "Denies the save command without any pre-configured scope."
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:default",
|
||||||
|
"markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-default-urls",
|
||||||
|
"markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-path",
|
||||||
|
"markdownDescription": "Enables the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-open-url",
|
||||||
|
"markdownDescription": "Enables the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:allow-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_path command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-path",
|
||||||
|
"markdownDescription": "Denies the open_path command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open_url command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-open-url",
|
||||||
|
"markdownDescription": "Denies the open_url command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the reveal_item_in_dir command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "opener:deny-reveal-item-in-dir",
|
||||||
|
"markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -2305,6 +2521,23 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"Application": {
|
||||||
|
"description": "Opener scope application.",
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"description": "Open in default application.",
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "If true, allow open with any application.",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Allow specific application to open with.",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,10 @@ mod cli_manager;
|
|||||||
|
|
||||||
use cli_manager::{CliProcessManager, CliStatus};
|
use cli_manager::{CliProcessManager, CliStatus};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tauri::menu::Menu;
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::Builder as PluginBuilder;
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -84,8 +84,81 @@ fn main() {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
.invoke_handler(tauri::generate_handler![cli_get_status, cli_restart])
|
||||||
.on_menu_event(|_app_handle, _event| {
|
.on_menu_event(|app_handle, event| {
|
||||||
// No menu items defined currently
|
match event.id().0.as_str() {
|
||||||
|
// File menu
|
||||||
|
"new_instance" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.emit("menu:newInstance", ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"close" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
app_handle.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
"reload" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload()");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"force_reload" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval("window.location.reload(true)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"toggle_devtools" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
window.open_devtools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
"toggle_fullscreen" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
"minimize" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.minimize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"zoom" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.maximize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// App menu (macOS)
|
||||||
|
"about" => {
|
||||||
|
// TODO: Implement about dialog
|
||||||
|
println!("About menu item clicked");
|
||||||
|
}
|
||||||
|
"hide" => {
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
let _ = window.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"hide_others" => {
|
||||||
|
// TODO: Hide other app windows
|
||||||
|
println!("Hide Others menu item clicked");
|
||||||
|
}
|
||||||
|
"show_all" => {
|
||||||
|
// TODO: Show all app windows
|
||||||
|
println!("Show All menu item clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => {
|
||||||
|
println!("Unhandled menu event: {}", event.id().0);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
@@ -118,8 +191,77 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
// Minimal empty menu for now (Tauri v2 menu API differs from v1 roles).
|
let is_mac = cfg!(target_os = "macos");
|
||||||
let menu = Menu::new(app)?;
|
|
||||||
|
// Create submenus
|
||||||
|
let mut submenus = Vec::new();
|
||||||
|
|
||||||
|
// App menu (macOS only)
|
||||||
|
if is_mac {
|
||||||
|
let app_menu = SubmenuBuilder::new(app, "CodeNomad")
|
||||||
|
.text("about", "About CodeNomad")
|
||||||
|
.separator()
|
||||||
|
.text("hide", "Hide CodeNomad")
|
||||||
|
.text("hide_others", "Hide Others")
|
||||||
|
.text("show_all", "Show All")
|
||||||
|
.separator()
|
||||||
|
.text("quit", "Quit CodeNomad")
|
||||||
|
.build()?;
|
||||||
|
submenus.push(app_menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File menu - create New Instance with accelerator
|
||||||
|
let new_instance_item = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
"new_instance",
|
||||||
|
"New Instance",
|
||||||
|
true,
|
||||||
|
Some("CmdOrCtrl+N")
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
|
.item(&new_instance_item)
|
||||||
|
.separator()
|
||||||
|
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||||
|
.build()?;
|
||||||
|
submenus.push(file_menu);
|
||||||
|
|
||||||
|
// Edit menu with predefined items for standard functionality
|
||||||
|
let edit_menu = SubmenuBuilder::new(app, "Edit")
|
||||||
|
.undo()
|
||||||
|
.redo()
|
||||||
|
.separator()
|
||||||
|
.cut()
|
||||||
|
.copy()
|
||||||
|
.paste()
|
||||||
|
.separator()
|
||||||
|
.select_all()
|
||||||
|
.build()?;
|
||||||
|
submenus.push(edit_menu);
|
||||||
|
|
||||||
|
// View menu
|
||||||
|
let view_menu = SubmenuBuilder::new(app, "View")
|
||||||
|
.text("reload", "Reload")
|
||||||
|
.text("force_reload", "Force Reload")
|
||||||
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
|
.separator()
|
||||||
|
|
||||||
|
.separator()
|
||||||
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
|
.build()?;
|
||||||
|
submenus.push(view_menu);
|
||||||
|
|
||||||
|
// Window menu
|
||||||
|
let window_menu = SubmenuBuilder::new(app, "Window")
|
||||||
|
.text("minimize", "Minimize")
|
||||||
|
.text("zoom", "Zoom")
|
||||||
|
.build()?;
|
||||||
|
submenus.push(window_menu);
|
||||||
|
|
||||||
|
// Build the main menu with all submenus
|
||||||
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||||
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,8 @@
|
|||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": true,
|
"decorations": true,
|
||||||
"theme": "Dark",
|
"theme": "Dark",
|
||||||
"backgroundColor": "#1a1a1a"
|
"backgroundColor": "#1a1a1a",
|
||||||
|
"zoomHotkeysEnabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.2.8",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Show, createMemo, createEffect, createSignal } from "solid-js"
|
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
|
||||||
import { Dialog } from "@kobalte/core/dialog"
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
import { Toaster } from "solid-toast"
|
import { Toaster } from "solid-toast"
|
||||||
import AlertDialog from "./components/alert-dialog"
|
import AlertDialog from "./components/alert-dialog"
|
||||||
@@ -14,6 +14,7 @@ import { useCommands } from "./lib/hooks/use-commands"
|
|||||||
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
|
||||||
import { getLogger } from "./lib/logger"
|
import { getLogger } from "./lib/logger"
|
||||||
import { initReleaseNotifications } from "./stores/releases"
|
import { initReleaseNotifications } from "./stores/releases"
|
||||||
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import {
|
import {
|
||||||
hasInstances,
|
hasInstances,
|
||||||
isSelectingFolder,
|
isSelectingFolder,
|
||||||
@@ -52,6 +53,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
@@ -222,6 +224,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
@@ -247,6 +250,28 @@ const App: Component = () => {
|
|||||||
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
getActiveSessionIdForInstance: activeSessionIdForInstance,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Listen for Tauri menu events
|
||||||
|
onMount(() => {
|
||||||
|
if (runtimeEnv.host === "tauri") {
|
||||||
|
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||||
|
if (tauriBridge?.event) {
|
||||||
|
let unlistenMenu: (() => void) | null = null
|
||||||
|
|
||||||
|
tauriBridge.event.listen("menu:newInstance", () => {
|
||||||
|
handleNewInstanceRequest()
|
||||||
|
}).then((unlisten) => {
|
||||||
|
unlistenMenu = unlisten
|
||||||
|
}).catch((error) => {
|
||||||
|
log.error("Failed to listen for menu:newInstance event", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
unlistenMenu?.()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InstanceDisconnectedModal
|
<InstanceDisconnectedModal
|
||||||
@@ -299,21 +324,27 @@ const App: Component = () => {
|
|||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<For each={Array.from(instances().values())}>
|
||||||
|
{(instance) => {
|
||||||
|
const isActiveInstance = () => activeInstanceId() === instance.id
|
||||||
|
return (
|
||||||
|
<div class="flex-1 min-h-0" style={{ display: isActiveInstance() ? "flex" : "none" }}>
|
||||||
|
<InstanceShell
|
||||||
|
instance={instance}
|
||||||
|
escapeInDebounce={escapeInDebounce()}
|
||||||
|
paletteCommands={paletteCommands}
|
||||||
|
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
||||||
|
onNewSession={() => handleNewSession(instance.id)}
|
||||||
|
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
||||||
|
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
||||||
|
onExecuteCommand={executeCommand}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
|
||||||
<Show when={activeInstance()} keyed>
|
|
||||||
{(instance) => (
|
|
||||||
<InstanceShell
|
|
||||||
instance={instance}
|
|
||||||
escapeInDebounce={escapeInDebounce()}
|
|
||||||
paletteCommands={paletteCommands}
|
|
||||||
onCloseSession={(sessionId) => handleCloseSession(instance.id, sessionId)}
|
|
||||||
onNewSession={() => handleNewSession(instance.id)}
|
|
||||||
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
|
|
||||||
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
|
|
||||||
onExecuteCommand={executeCommand}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Show, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component } from "solid-js"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { Instance } from "../../types/instance"
|
import type { Instance } from "../../types/instance"
|
||||||
import type { Command } from "../../lib/commands"
|
import type { Command } from "../../lib/commands"
|
||||||
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
import { activeParentSessionId, activeSessionId as activeSessionMap, getSessionFamily, setActiveSession } from "../../stores/sessions"
|
||||||
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
import { keyboardRegistry, type KeyboardShortcut } from "../../lib/keyboard-registry"
|
||||||
|
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||||
|
import { clearSessionRenderCache } from "../message-block"
|
||||||
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
import { buildCustomCommandEntries } from "../../lib/command-utils"
|
||||||
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
import { getCommands as getInstanceCommands } from "../../stores/commands"
|
||||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
import { isOpen as isCommandPaletteOpen, hideCommandPalette } from "../../stores/command-palette"
|
||||||
@@ -34,11 +36,14 @@ interface InstanceShellProps {
|
|||||||
|
|
||||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||||
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
const MOBILE_SIDEBAR_BREAKPOINT = 1024
|
||||||
|
const SESSION_CACHE_LIMIT = 2
|
||||||
|
|
||||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
const [isCompactLayout, setIsCompactLayout] = createSignal(false)
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
const [isSidebarOpen, setIsSidebarOpen] = createSignal(true)
|
||||||
|
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
|
||||||
|
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])
|
||||||
const sidebarId = `session-sidebar-${props.instance.id}`
|
const sidebarId = `session-sidebar-${props.instance.id}`
|
||||||
let previousIsCompact = false
|
let previousIsCompact = false
|
||||||
|
|
||||||
@@ -77,12 +82,17 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
return activeSessionMap().get(props.instance.id) || null
|
return activeSessionMap().get(props.instance.id) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parentSessionIdForInstance = createMemo(() => {
|
||||||
|
return activeParentSessionId().get(props.instance.id) || null
|
||||||
|
})
|
||||||
|
|
||||||
const activeSessionForInstance = createMemo(() => {
|
const activeSessionForInstance = createMemo(() => {
|
||||||
const sessionId = activeSessionIdForInstance()
|
const sessionId = activeSessionIdForInstance()
|
||||||
if (!sessionId || sessionId === "info") return null
|
if (!sessionId || sessionId === "info") return null
|
||||||
return activeSessions().get(sessionId) ?? null
|
return activeSessions().get(sessionId) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
const customCommands = createMemo(() => buildCustomCommandEntries(props.instance.id, getInstanceCommands(props.instance.id)))
|
||||||
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
const instancePaletteCommands = createMemo(() => [...props.paletteCommands(), ...customCommands()])
|
||||||
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
const paletteOpen = createMemo(() => isCommandPaletteOpen(props.instance.id))
|
||||||
@@ -97,6 +107,74 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
setActiveSession(props.instance.id, sessionId)
|
setActiveSession(props.instance.id, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evictSession = (sessionId: string) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
log.info("Evicting cached session", { instanceId: props.instance.id, sessionId })
|
||||||
|
const store = messageStoreBus.getInstance(props.instance.id)
|
||||||
|
store?.clearSession(sessionId)
|
||||||
|
clearSessionRenderCache(props.instance.id, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleEvictions = (ids: string[]) => {
|
||||||
|
if (!ids.length) return
|
||||||
|
setPendingEvictions((current) => {
|
||||||
|
const existing = new Set(current)
|
||||||
|
const next = [...current]
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (!existing.has(id)) {
|
||||||
|
next.push(id)
|
||||||
|
existing.add(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const pending = pendingEvictions()
|
||||||
|
if (!pending.length) return
|
||||||
|
const cached = new Set(cachedSessionIds())
|
||||||
|
const remaining: string[] = []
|
||||||
|
pending.forEach((id) => {
|
||||||
|
if (cached.has(id)) {
|
||||||
|
remaining.push(id)
|
||||||
|
} else {
|
||||||
|
evictSession(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (remaining.length !== pending.length) {
|
||||||
|
setPendingEvictions(remaining)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const sessionsMap = activeSessions()
|
||||||
|
const parentId = parentSessionIdForInstance()
|
||||||
|
const activeId = activeSessionIdForInstance()
|
||||||
|
setCachedSessionIds((current) => {
|
||||||
|
const next: string[] = []
|
||||||
|
const append = (id: string | null) => {
|
||||||
|
if (!id || id === "info") return
|
||||||
|
if (!sessionsMap.has(id)) return
|
||||||
|
if (next.includes(id)) return
|
||||||
|
next.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
append(parentId)
|
||||||
|
append(activeId)
|
||||||
|
current.forEach((id) => append(id))
|
||||||
|
|
||||||
|
const limit = parentId ? SESSION_CACHE_LIMIT + 1 : SESSION_CACHE_LIMIT
|
||||||
|
const trimmed = next.length > limit ? next.slice(0, limit) : next
|
||||||
|
const trimmedSet = new Set(trimmed)
|
||||||
|
const removed = current.filter((id) => !trimmedSet.has(id))
|
||||||
|
if (removed.length) {
|
||||||
|
scheduleEvictions(removed)
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
<Show when={activeSessions().size > 0} fallback={<InstanceWelcomeView instance={props.instance} />}>
|
||||||
@@ -212,8 +290,7 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
when={activeSessionIdForInstance() === "info"}
|
when={activeSessionIdForInstance() === "info"}
|
||||||
fallback={
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={activeSessionIdForInstance()}
|
when={cachedSessionIds().length > 0 && activeSessionIdForInstance()}
|
||||||
keyed
|
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex items-center justify-center h-full">
|
<div class="flex items-center justify-center h-full">
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||||
@@ -223,18 +300,31 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(sessionId) => (
|
<For each={cachedSessionIds()}>
|
||||||
<SessionView
|
{(sessionId) => {
|
||||||
sessionId={sessionId}
|
const isActive = () => activeSessionIdForInstance() === sessionId
|
||||||
activeSessions={activeSessions()}
|
return (
|
||||||
instanceId={props.instance.id}
|
<div
|
||||||
instanceFolder={props.instance.folder}
|
class="session-cache-pane flex flex-col flex-1 min-h-0"
|
||||||
escapeInDebounce={props.escapeInDebounce}
|
style={{ display: isActive() ? "flex" : "none" }}
|
||||||
showSidebarToggle={shouldShowSidebarToggle()}
|
data-session-id={sessionId}
|
||||||
onSidebarToggle={() => setIsSidebarOpen(true)}
|
aria-hidden={!isActive()}
|
||||||
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
>
|
||||||
/>
|
<SessionView
|
||||||
)}
|
sessionId={sessionId}
|
||||||
|
activeSessions={activeSessions()}
|
||||||
|
instanceId={props.instance.id}
|
||||||
|
instanceFolder={props.instance.folder}
|
||||||
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
|
showSidebarToggle={shouldShowSidebarToggle()}
|
||||||
|
onSidebarToggle={() => setIsSidebarOpen(true)}
|
||||||
|
forceCompactStatusLayout={shouldShowSidebarToggle()}
|
||||||
|
isActive={isActive()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import VirtualItem from "./virtual-item"
|
|||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
export function getMessageAnchorId(messageId: string) {
|
||||||
|
return `message-anchor-${messageId}`
|
||||||
|
}
|
||||||
|
|
||||||
const VIRTUAL_ITEM_MARGIN_PX = 800
|
const VIRTUAL_ITEM_MARGIN_PX = 800
|
||||||
const ESTIMATED_MESSAGE_HEIGHT = 320
|
|
||||||
const INITIAL_FORCE_MIN_ITEMS = 12
|
|
||||||
const INITIAL_FORCE_OVERSCAN = 6
|
|
||||||
|
|
||||||
interface MessageBlockListProps {
|
interface MessageBlockListProps {
|
||||||
|
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
store: () => InstanceMessageStore
|
store: () => InstanceMessageStore
|
||||||
@@ -24,62 +26,45 @@ interface MessageBlockListProps {
|
|||||||
onFork?: (messageId?: string) => void
|
onFork?: (messageId?: string) => void
|
||||||
onContentRendered?: () => void
|
onContentRendered?: () => void
|
||||||
setBottomSentinel: (element: HTMLDivElement | null) => void
|
setBottomSentinel: (element: HTMLDivElement | null) => void
|
||||||
|
suspendMeasurements?: () => boolean
|
||||||
|
onInitialRenderComplete?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageBlockList(props: MessageBlockListProps) {
|
export default function MessageBlockList(props: MessageBlockListProps) {
|
||||||
const [initialForceActive, setInitialForceActive] = createSignal(true)
|
const totalMessages = () => props.messageIds().length
|
||||||
const [initialForceInitialized, setInitialForceInitialized] = createSignal(false)
|
let renderedCount = 0
|
||||||
const [initialForceStartIndex, setInitialForceStartIndex] = createSignal(0)
|
let initialRenderReported = false
|
||||||
const [, setInitialForceRemaining] = createSignal(0)
|
const handleBlockRendered = () => {
|
||||||
|
if (initialRenderReported) return
|
||||||
|
renderedCount += 1
|
||||||
|
if (renderedCount >= totalMessages() && totalMessages() > 0) {
|
||||||
|
initialRenderReported = true
|
||||||
|
renderedCount = 0
|
||||||
|
props.onInitialRenderComplete?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
props.instanceId
|
if (props.loading) {
|
||||||
props.sessionId
|
renderedCount = 0
|
||||||
setInitialForceActive(true)
|
initialRenderReported = false
|
||||||
setInitialForceInitialized(false)
|
}
|
||||||
setInitialForceStartIndex(0)
|
|
||||||
setInitialForceRemaining(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!initialForceActive() || initialForceInitialized()) return
|
|
||||||
const ids = props.messageIds()
|
|
||||||
if (ids.length === 0) return
|
|
||||||
const viewportHeight = props.scrollContainer()?.clientHeight ?? (typeof window !== "undefined" ? window.innerHeight : 800)
|
|
||||||
const estimatedCount = Math.min(
|
|
||||||
ids.length,
|
|
||||||
Math.max(INITIAL_FORCE_MIN_ITEMS, Math.ceil(viewportHeight / ESTIMATED_MESSAGE_HEIGHT) + INITIAL_FORCE_OVERSCAN),
|
|
||||||
)
|
|
||||||
setInitialForceStartIndex(Math.max(0, ids.length - estimatedCount))
|
|
||||||
setInitialForceRemaining(estimatedCount)
|
|
||||||
setInitialForceInitialized(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Index each={props.messageIds()}>
|
<Index each={props.messageIds()}>
|
||||||
{(messageId) => {
|
{(messageId) => {
|
||||||
const messageIndex = () => props.messageIndexMap().get(messageId()) ?? 0
|
|
||||||
const forceVisible = () => initialForceActive() && messageIndex() >= initialForceStartIndex()
|
|
||||||
const handleMeasured = () => {
|
|
||||||
if (!forceVisible()) return
|
|
||||||
setInitialForceRemaining((value) => {
|
|
||||||
const next = value > 0 ? value - 1 : 0
|
|
||||||
if (next === 0) {
|
|
||||||
setInitialForceActive(false)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
|
id={getMessageAnchorId(messageId())}
|
||||||
cacheKey={messageId()}
|
cacheKey={messageId()}
|
||||||
scrollContainer={props.scrollContainer}
|
scrollContainer={props.scrollContainer}
|
||||||
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
threshold={VIRTUAL_ITEM_MARGIN_PX}
|
||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={() => !props.loading}
|
virtualizationEnabled={() => !props.loading}
|
||||||
forceVisible={forceVisible}
|
suspendMeasurements={props.suspendMeasurements}
|
||||||
onMeasured={handleMeasured}
|
onMeasured={handleBlockRendered}
|
||||||
>
|
>
|
||||||
<MessageBlock
|
<MessageBlock
|
||||||
messageId={messageId()}
|
messageId={messageId()}
|
||||||
|
|||||||
@@ -125,6 +125,10 @@ function makeSessionCacheKey(instanceId: string, sessionId: string) {
|
|||||||
return `${instanceId}:${sessionId}`
|
return `${instanceId}:${sessionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearSessionRenderCache(instanceId: string, sessionId: string) {
|
||||||
|
renderCaches.delete(makeSessionCacheKey(instanceId, sessionId))
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
|
function getSessionRenderCache(instanceId: string, sessionId: string): SessionRenderCache {
|
||||||
const key = makeSessionCacheKey(instanceId, sessionId)
|
const key = makeSessionCacheKey(instanceId, sessionId)
|
||||||
let cache = renderCaches.get(key)
|
let cache = renderCaches.get(key)
|
||||||
|
|||||||
39
packages/ui/src/components/message-preview.tsx
Normal file
39
packages/ui/src/components/message-preview.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createMemo, type Component } from "solid-js"
|
||||||
|
import MessageBlock from "./message-block"
|
||||||
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
|
|
||||||
|
interface MessagePreviewProps {
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
messageId: string
|
||||||
|
store: () => InstanceMessageStore
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessagePreview: Component<MessagePreviewProps> = (props) => {
|
||||||
|
const indexMap = createMemo(() => new Map([[props.messageId, 0]]))
|
||||||
|
const lastAssistantIndex = createMemo(() => {
|
||||||
|
const record = props.store().getMessage(props.messageId)
|
||||||
|
if (record?.role === "assistant") {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-preview message-stream">
|
||||||
|
<MessageBlock
|
||||||
|
messageId={props.messageId}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={props.store}
|
||||||
|
messageIndexMap={indexMap}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => false}
|
||||||
|
thinkingDefaultExpanded={() => false}
|
||||||
|
showUsageMetrics={() => false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessagePreview
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlockList from "./message-block-list"
|
import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
|
||||||
import MessageListHeader from "./message-list-header"
|
import MessageListHeader from "./message-list-header"
|
||||||
|
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { getSessionInfo } from "../stores/sessions"
|
import { getSessionInfo } from "../stores/sessions"
|
||||||
import { showCommandPalette } from "../stores/command-palette"
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
@@ -15,6 +16,7 @@ const SCROLL_SCOPE = "session"
|
|||||||
const SCROLL_SENTINEL_MARGIN_PX = 48
|
const SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
function formatTokens(tokens: number): string {
|
function formatTokens(tokens: number): string {
|
||||||
@@ -31,11 +33,14 @@ export interface MessageSectionProps {
|
|||||||
showSidebarToggle?: boolean
|
showSidebarToggle?: boolean
|
||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
|
onQuoteSelection?: (text: string, mode: "quote" | "code") => void
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
const store = createMemo<InstanceMessageStore>(() => messageStoreBus.getOrCreate(props.instanceId))
|
||||||
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
const messageIds = createMemo(() => store().getSessionMessageIds(props.sessionId))
|
||||||
|
|
||||||
@@ -76,14 +81,21 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const handleCommandPaletteClick = () => {
|
const handleCommandPaletteClick = () => {
|
||||||
showCommandPalette(props.instanceId)
|
showCommandPalette(props.instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTimelineSegmentClick = (segment: TimelineSegment) => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const anchor = document.getElementById(getMessageAnchorId(segment.messageId))
|
||||||
|
anchor?.scrollIntoView({ block: "start", behavior: "smooth" })
|
||||||
|
}
|
||||||
|
|
||||||
const messageIndexMap = createMemo(() => {
|
const messageIndexMap = createMemo(() => {
|
||||||
|
|
||||||
const map = new Map<string, number>()
|
const map = new Map<string, number>()
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
ids.forEach((id, index) => map.set(id, index))
|
ids.forEach((id, index) => map.set(id, index))
|
||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
const lastAssistantIndex = createMemo(() => {
|
const lastAssistantIndex = createMemo(() => {
|
||||||
const ids = messageIds()
|
const ids = messageIds()
|
||||||
const resolvedStore = store()
|
const resolvedStore = store()
|
||||||
@@ -95,9 +107,26 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const timelineSegments = createMemo<TimelineSegment[]>(() => {
|
||||||
|
const ids = messageIds()
|
||||||
|
const resolvedStore = store()
|
||||||
|
const segments: TimelineSegment[] = []
|
||||||
|
ids.forEach((messageId) => {
|
||||||
|
const record = resolvedStore.getMessage(messageId)
|
||||||
|
if (!record) return
|
||||||
|
const built = buildTimelineSegments(props.instanceId, record)
|
||||||
|
segments.push(...built)
|
||||||
|
})
|
||||||
|
return segments
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasTimelineSegments = () => timelineSegments().length > 0
|
||||||
|
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
|
||||||
|
|
||||||
const changeToken = createMemo(() => String(sessionRevision()))
|
const changeToken = createMemo(() => String(sessionRevision()))
|
||||||
|
|
||||||
|
|
||||||
const scrollCache = useScrollCache({
|
const scrollCache = useScrollCache({
|
||||||
instanceId: () => props.instanceId,
|
instanceId: () => props.instanceId,
|
||||||
sessionId: () => props.sessionId,
|
sessionId: () => props.sessionId,
|
||||||
@@ -106,21 +135,36 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
const [topSentinel, setTopSentinel] = createSignal<HTMLDivElement | null>(null)
|
||||||
const [bottomSentinel, setBottomSentinel] = createSignal<HTMLDivElement | null>(null)
|
const [bottomSentinelSignal, setBottomSentinelSignal] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
const bottomSentinel = () => bottomSentinelSignal()
|
||||||
|
const setBottomSentinel = (element: HTMLDivElement | null) => {
|
||||||
|
setBottomSentinelSignal(element)
|
||||||
|
resolvePendingActiveScroll()
|
||||||
|
}
|
||||||
const [autoScroll, setAutoScroll] = createSignal(true)
|
const [autoScroll, setAutoScroll] = createSignal(true)
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
|
||||||
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
|
||||||
|
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||||
|
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
let shellRef: HTMLDivElement | undefined
|
||||||
let pendingScrollFrame: number | null = null
|
let pendingScrollFrame: number | null = null
|
||||||
|
|
||||||
let pendingAnchorScroll: number | null = null
|
let pendingAnchorScroll: number | null = null
|
||||||
|
|
||||||
let pendingScrollPersist: number | null = null
|
let pendingScrollPersist: number | null = null
|
||||||
let userScrollIntentUntil = 0
|
let userScrollIntentUntil = 0
|
||||||
let detachScrollIntentListeners: (() => void) | undefined
|
let detachScrollIntentListeners: (() => void) | undefined
|
||||||
let hasRestoredScroll = false
|
let hasRestoredScroll = false
|
||||||
let suppressAutoScrollOnce = false
|
let suppressAutoScrollOnce = false
|
||||||
|
let pendingActiveScroll = false
|
||||||
|
let scrollToBottomFrame: number | null = null
|
||||||
|
let scrollToBottomDelayedFrame: number | null = null
|
||||||
|
let pendingInitialScroll = true
|
||||||
|
|
||||||
|
const [initialRenderComplete, setInitialRenderComplete] = createSignal(false)
|
||||||
|
|
||||||
function markUserScrollIntent() {
|
function markUserScrollIntent() {
|
||||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
const now = typeof performance !== "undefined" ? performance.now() : Date.now()
|
||||||
@@ -160,12 +204,27 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
containerRef = element || undefined
|
containerRef = element || undefined
|
||||||
setScrollElement(containerRef)
|
setScrollElement(containerRef)
|
||||||
attachScrollIntentListeners(containerRef)
|
attachScrollIntentListeners(containerRef)
|
||||||
|
if (!containerRef) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolvePendingActiveScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setShellElement(element: HTMLDivElement | null) {
|
||||||
|
shellRef = element || undefined
|
||||||
|
if (!shellRef) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateScrollIndicatorsFromVisibility() {
|
function updateScrollIndicatorsFromVisibility() {
|
||||||
|
|
||||||
const hasItems = messageIds().length > 0
|
const hasItems = messageIds().length > 0
|
||||||
setShowScrollBottomButton(hasItems && !bottomSentinelVisible())
|
const bottomVisible = bottomSentinelVisible()
|
||||||
setShowScrollTopButton(hasItems && !topSentinelVisible())
|
const topVisible = topSentinelVisible()
|
||||||
|
setShowScrollBottomButton(hasItems && !bottomVisible)
|
||||||
|
setShowScrollTopButton(hasItems && !topVisible)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleScrollPersist() {
|
function scheduleScrollPersist() {
|
||||||
@@ -173,7 +232,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
pendingScrollPersist = requestAnimationFrame(() => {
|
pendingScrollPersist = requestAnimationFrame(() => {
|
||||||
pendingScrollPersist = null
|
pendingScrollPersist = null
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +247,39 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
scheduleScrollPersist()
|
scheduleScrollPersist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearScrollToBottomFrames() {
|
||||||
|
if (scrollToBottomFrame !== null) {
|
||||||
|
cancelAnimationFrame(scrollToBottomFrame)
|
||||||
|
scrollToBottomFrame = null
|
||||||
|
}
|
||||||
|
if (scrollToBottomDelayedFrame !== null) {
|
||||||
|
cancelAnimationFrame(scrollToBottomDelayedFrame)
|
||||||
|
scrollToBottomDelayedFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestScrollToBottom(immediate = true) {
|
||||||
|
if (!containerRef || !bottomSentinel()) {
|
||||||
|
pendingActiveScroll = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingActiveScroll = false
|
||||||
|
clearScrollToBottomFrames()
|
||||||
|
scrollToBottomFrame = requestAnimationFrame(() => {
|
||||||
|
scrollToBottomFrame = null
|
||||||
|
scrollToBottomDelayedFrame = requestAnimationFrame(() => {
|
||||||
|
scrollToBottomDelayedFrame = null
|
||||||
|
scrollToBottom(immediate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePendingActiveScroll() {
|
||||||
|
if (!pendingActiveScroll) return
|
||||||
|
if (!props.isActive) return
|
||||||
|
requestScrollToBottom(true)
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToTop(immediate = false) {
|
function scrollToTop(immediate = false) {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
@@ -212,11 +304,85 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearQuoteSelection() {
|
||||||
|
setQuoteSelection(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectionWithinStream(range: Range | null) {
|
||||||
|
if (!range || !containerRef) return false
|
||||||
|
const node = range.commonAncestorContainer
|
||||||
|
if (!node) return false
|
||||||
|
return containerRef.contains(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuoteSelectionFromSelection() {
|
||||||
|
if (!props.onQuoteSelection || typeof window === "undefined") {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
if (!isSelectionWithinStream(range)) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const shell = shellRef
|
||||||
|
if (!shell) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rawText = selection.toString().trim()
|
||||||
|
if (!rawText) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const limited =
|
||||||
|
rawText.length > QUOTE_SELECTION_MAX_LENGTH ? rawText.slice(0, QUOTE_SELECTION_MAX_LENGTH).trimEnd() : rawText
|
||||||
|
if (!limited) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rects = range.getClientRects()
|
||||||
|
const anchorRect = rects.length > 0 ? rects[0] : range.getBoundingClientRect()
|
||||||
|
const shellRect = shell.getBoundingClientRect()
|
||||||
|
const relativeTop = Math.max(anchorRect.top - shellRect.top - 40, 8)
|
||||||
|
const maxLeft = Math.max(shell.clientWidth - 180, 8)
|
||||||
|
const relativeLeft = Math.min(Math.max(anchorRect.left - shellRect.left, 8), maxLeft)
|
||||||
|
setQuoteSelection({ text: limited, top: relativeTop, left: relativeLeft })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStreamMouseUp() {
|
||||||
|
updateQuoteSelectionFromSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuoteSelectionRequest(mode: "quote" | "code") {
|
||||||
|
const info = quoteSelection()
|
||||||
|
if (!info || !props.onQuoteSelection) return
|
||||||
|
props.onQuoteSelection(info.text, mode)
|
||||||
|
clearQuoteSelection()
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
selection?.removeAllRanges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleContentRendered() {
|
function handleContentRendered() {
|
||||||
|
if (props.loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
scheduleAnchorScroll()
|
scheduleAnchorScroll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleInitialRenderComplete() {
|
||||||
|
setInitialRenderComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
|
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
if (pendingScrollFrame !== null) {
|
if (pendingScrollFrame !== null) {
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
@@ -235,13 +401,67 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearQuoteSelection()
|
||||||
scheduleScrollPersist()
|
scheduleScrollPersist()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (props.registerScrollToBottom) {
|
if (props.registerScrollToBottom) {
|
||||||
props.registerScrollToBottom(() => scrollToBottom(true))
|
props.registerScrollToBottom(() => requestScrollToBottom(true))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastActiveState = false
|
||||||
|
createEffect(() => {
|
||||||
|
const active = Boolean(props.isActive)
|
||||||
|
if (active && !lastActiveState) {
|
||||||
|
requestScrollToBottom(true)
|
||||||
|
}
|
||||||
|
lastActiveState = active
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const loading = Boolean(props.loading)
|
||||||
|
if (loading) {
|
||||||
|
pendingInitialScroll = true
|
||||||
|
setInitialRenderComplete(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (pendingInitialScroll && initialRenderComplete()) {
|
||||||
|
pendingInitialScroll = false
|
||||||
|
requestScrollToBottom(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.onQuoteSelection) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
const handleSelectionChange = () => updateQuoteSelectionFromSelection()
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
if (!shellRef) return
|
||||||
|
if (!shellRef.contains(event.target as Node)) {
|
||||||
|
clearQuoteSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("selectionchange", handleSelectionChange)
|
||||||
|
document.addEventListener("pointerdown", handlePointerDown)
|
||||||
|
onCleanup(() => {
|
||||||
|
document.removeEventListener("selectionchange", handleSelectionChange)
|
||||||
|
document.removeEventListener("pointerdown", handlePointerDown)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (props.loading) {
|
||||||
|
clearQuoteSelection()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,16 +470,17 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const loading = props.loading
|
const loading = props.loading
|
||||||
if (!target || loading || hasRestoredScroll) return
|
if (!target || loading || hasRestoredScroll) return
|
||||||
|
|
||||||
scrollCache.restore(target, {
|
|
||||||
onApplied: (snapshot) => {
|
// scrollCache.restore(target, {
|
||||||
if (snapshot) {
|
// onApplied: (snapshot) => {
|
||||||
setAutoScroll(snapshot.atBottom)
|
// if (snapshot) {
|
||||||
} else {
|
// setAutoScroll(snapshot.atBottom)
|
||||||
setAutoScroll(bottomSentinelVisible())
|
// } else {
|
||||||
}
|
// setAutoScroll(bottomSentinelVisible())
|
||||||
updateScrollIndicatorsFromVisibility()
|
// }
|
||||||
},
|
// updateScrollIndicatorsFromVisibility()
|
||||||
})
|
// },
|
||||||
|
// })
|
||||||
|
|
||||||
hasRestoredScroll = true
|
hasRestoredScroll = true
|
||||||
})
|
})
|
||||||
@@ -329,9 +550,44 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
observer.observe(bottomTarget)
|
observer.observe(bottomTarget)
|
||||||
onCleanup(() => observer.disconnect())
|
onCleanup(() => observer.disconnect())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const container = scrollElement()
|
||||||
|
const ids = messageIds()
|
||||||
|
if (!container || ids.length === 0) return
|
||||||
|
if (typeof document === "undefined") return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
let best: IntersectionObserverEntry | null = null
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isIntersecting) continue
|
||||||
|
if (!best || entry.boundingClientRect.top < best.boundingClientRect.top) {
|
||||||
|
best = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best) {
|
||||||
|
const anchorId = (best.target as HTMLElement).id
|
||||||
|
const messageId = anchorId.startsWith("message-anchor-") ? anchorId.slice("message-anchor-".length) : anchorId
|
||||||
|
setActiveMessageId((current) => (current === messageId ? current : messageId))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: container, rootMargin: "-10% 0px -80% 0px", threshold: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
ids.forEach((messageId) => {
|
||||||
|
const anchor = document.getElementById(getMessageAnchorId(messageId))
|
||||||
|
if (anchor) {
|
||||||
|
observer.observe(anchor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
||||||
|
|
||||||
if (pendingScrollFrame !== null) {
|
if (pendingScrollFrame !== null) {
|
||||||
cancelAnimationFrame(pendingScrollFrame)
|
cancelAnimationFrame(pendingScrollFrame)
|
||||||
}
|
}
|
||||||
@@ -341,12 +597,14 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
if (pendingAnchorScroll !== null) {
|
if (pendingAnchorScroll !== null) {
|
||||||
cancelAnimationFrame(pendingAnchorScroll)
|
cancelAnimationFrame(pendingAnchorScroll)
|
||||||
}
|
}
|
||||||
|
clearScrollToBottomFrames()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
}
|
}
|
||||||
if (containerRef) {
|
if (containerRef) {
|
||||||
scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
// scrollCache.persist(containerRef, { atBottomOffset: SCROLL_SENTINEL_MARGIN_PX })
|
||||||
}
|
}
|
||||||
|
clearQuoteSelection()
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -362,76 +620,116 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll}>
|
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}>
|
||||||
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
<div class="message-stream-shell" ref={setShellElement}>
|
||||||
<Show when={!props.loading && messageIds().length === 0}>
|
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
|
||||||
<div class="empty-state">
|
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
|
||||||
<div class="empty-state-content">
|
<Show when={!props.loading && messageIds().length === 0}>
|
||||||
<div class="flex flex-col items-center gap-3 mb-6">
|
<div class="empty-state">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
<div class="empty-state-content">
|
||||||
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
<div class="flex flex-col items-center gap-3 mb-6">
|
||||||
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
||||||
|
<h1 class="text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
|
</div>
|
||||||
|
<h3>Start a conversation</h3>
|
||||||
|
<p>Type a message below or open the Command Palette:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<span>Command Palette</span>
|
||||||
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
|
</li>
|
||||||
|
<li>Ask about your codebase</li>
|
||||||
|
<li>
|
||||||
|
Attach files with <code>@</code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3>Start a conversation</h3>
|
</Show>
|
||||||
<p>Type a message below or open the Command Palette:</p>
|
|
||||||
<ul>
|
<Show when={props.loading}>
|
||||||
<li>
|
<div class="loading-state">
|
||||||
<span>Command Palette</span>
|
<div class="spinner" />
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
<p>Loading messages...</p>
|
||||||
</li>
|
</div>
|
||||||
<li>Ask about your codebase</li>
|
</Show>
|
||||||
<li>
|
|
||||||
Attach files with <code>@</code>
|
<MessageBlockList
|
||||||
</li>
|
instanceId={props.instanceId}
|
||||||
</ul>
|
sessionId={props.sessionId}
|
||||||
|
store={store}
|
||||||
|
messageIds={messageIds}
|
||||||
|
messageIndexMap={messageIndexMap}
|
||||||
|
lastAssistantIndex={lastAssistantIndex}
|
||||||
|
showThinking={() => preferences().showThinkingBlocks}
|
||||||
|
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
||||||
|
showUsageMetrics={showUsagePreference}
|
||||||
|
scrollContainer={scrollElement}
|
||||||
|
loading={props.loading}
|
||||||
|
onRevert={props.onRevert}
|
||||||
|
onFork={props.onFork}
|
||||||
|
onContentRendered={handleContentRendered}
|
||||||
|
setBottomSentinel={setBottomSentinel}
|
||||||
|
suspendMeasurements={() => props.isActive === false}
|
||||||
|
onInitialRenderComplete={handleInitialRenderComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<Show when={showScrollTopButton()}>
|
||||||
|
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={showScrollBottomButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => scrollToBottom()}
|
||||||
|
aria-label="Scroll to latest message"
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={props.loading}>
|
|
||||||
<div class="loading-state">
|
|
||||||
<div class="spinner" />
|
|
||||||
<p>Loading messages...</p>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<MessageBlockList
|
|
||||||
instanceId={props.instanceId}
|
|
||||||
sessionId={props.sessionId}
|
|
||||||
store={store}
|
|
||||||
messageIds={messageIds}
|
|
||||||
messageIndexMap={messageIndexMap}
|
|
||||||
lastAssistantIndex={lastAssistantIndex}
|
|
||||||
showThinking={() => preferences().showThinkingBlocks}
|
|
||||||
thinkingDefaultExpanded={() => (preferences().thinkingBlocksExpansion ?? "expanded") === "expanded"}
|
|
||||||
showUsageMetrics={showUsagePreference}
|
|
||||||
scrollContainer={scrollElement}
|
|
||||||
loading={props.loading}
|
|
||||||
onRevert={props.onRevert}
|
|
||||||
onFork={props.onFork}
|
|
||||||
onContentRendered={handleContentRendered}
|
|
||||||
setBottomSentinel={setBottomSentinel}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Show when={showScrollTopButton() || showScrollBottomButton()}>
|
|
||||||
<div class="message-scroll-button-wrapper">
|
|
||||||
<Show when={showScrollTopButton()}>
|
|
||||||
<button type="button" class="message-scroll-button" onClick={() => scrollToTop()} aria-label="Scroll to first message">
|
|
||||||
<span class="message-scroll-icon" aria-hidden="true">↑</span>
|
|
||||||
</button>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showScrollBottomButton()}>
|
|
||||||
<button
|
<Show when={quoteSelection()}>
|
||||||
type="button"
|
{(selection) => (
|
||||||
class="message-scroll-button"
|
<div
|
||||||
onClick={() => scrollToBottom()}
|
class="message-quote-popover"
|
||||||
aria-label="Scroll to latest message"
|
style={{ top: `${selection().top}px`, left: `${selection().left}px` }}
|
||||||
>
|
>
|
||||||
<span class="message-scroll-icon" aria-hidden="true">↓</span>
|
<div class="message-quote-button-group">
|
||||||
</button>
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("quote")}>
|
||||||
|
Add as quote
|
||||||
|
</button>
|
||||||
|
<button type="button" class="message-quote-button" onClick={() => handleQuoteSelectionRequest("code")}>
|
||||||
|
Add as code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
|
<Show when={hasTimelineSegments()}>
|
||||||
|
<div class="message-timeline-sidebar">
|
||||||
|
<MessageTimeline
|
||||||
|
segments={timelineSegments()}
|
||||||
|
onSegmentClick={handleTimelineSegmentClick}
|
||||||
|
activeMessageId={activeMessageId()}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
showToolSegments={showTimelineToolsPreference()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
396
packages/ui/src/components/message-timeline.tsx
Normal file
396
packages/ui/src/components/message-timeline.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js"
|
||||||
|
import MessagePreview from "./message-preview"
|
||||||
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
|
import type { ClientPart } from "../types/message"
|
||||||
|
import type { MessageRecord } from "../stores/message-v2/types"
|
||||||
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
|
import { getToolIcon } from "./tool-call/utils"
|
||||||
|
import { User as UserIcon, Bot as BotIcon } from "lucide-solid"
|
||||||
|
|
||||||
|
export type TimelineSegmentType = "user" | "assistant" | "tool"
|
||||||
|
|
||||||
|
export interface TimelineSegment {
|
||||||
|
id: string
|
||||||
|
messageId: string
|
||||||
|
type: TimelineSegmentType
|
||||||
|
label: string
|
||||||
|
tooltip: string
|
||||||
|
shortLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageTimelineProps {
|
||||||
|
segments: TimelineSegment[]
|
||||||
|
onSegmentClick?: (segment: TimelineSegment) => void
|
||||||
|
activeMessageId?: string | null
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
|
showToolSegments?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENT_LABELS: Record<TimelineSegmentType, string> = {
|
||||||
|
user: "You",
|
||||||
|
assistant: "Asst",
|
||||||
|
tool: "Tool",
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_FALLBACK_LABEL = "Tool Call"
|
||||||
|
const MAX_TOOLTIP_LENGTH = 220
|
||||||
|
|
||||||
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
|
interface PendingSegment {
|
||||||
|
type: TimelineSegmentType
|
||||||
|
texts: string[]
|
||||||
|
reasoningTexts: string[]
|
||||||
|
toolTitles: string[]
|
||||||
|
toolTypeLabels: string[]
|
||||||
|
toolIcons: string[]
|
||||||
|
hasPrimaryText: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateText(value: string): string {
|
||||||
|
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return `${value.slice(0, MAX_TOOLTIP_LENGTH - 1).trimEnd()}…`
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReasoningText(part: ClientPart): string {
|
||||||
|
const stringifySegment = (segment: unknown): string => {
|
||||||
|
if (typeof segment === "string") {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
if (segment && typeof segment === "object") {
|
||||||
|
const obj = segment as { text?: unknown; value?: unknown; content?: unknown[] }
|
||||||
|
const parts: string[] = []
|
||||||
|
if (typeof obj.text === "string") {
|
||||||
|
parts.push(obj.text)
|
||||||
|
}
|
||||||
|
if (typeof obj.value === "string") {
|
||||||
|
parts.push(obj.value)
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj.content)) {
|
||||||
|
parts.push(obj.content.map((entry) => stringifySegment(entry)).join("\n"))
|
||||||
|
}
|
||||||
|
return parts.filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof (part as any)?.text === "string") {
|
||||||
|
return (part as any).text
|
||||||
|
}
|
||||||
|
if (Array.isArray((part as any)?.content)) {
|
||||||
|
return (part as any).content.map((entry: unknown) => stringifySegment(entry)).join("\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTextFromPart(part: ClientPart): string {
|
||||||
|
if (!part) return ""
|
||||||
|
if (typeof (part as any).text === "string") {
|
||||||
|
return (part as any).text as string
|
||||||
|
}
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
return collectReasoningText(part)
|
||||||
|
}
|
||||||
|
if (Array.isArray((part as any)?.content)) {
|
||||||
|
return ((part as any).content as unknown[])
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry : ""))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
if (part.type === "file") {
|
||||||
|
const filename = (part as any)?.filename
|
||||||
|
return typeof filename === "string" && filename.length > 0 ? `[File] ${filename}` : "Attachment"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolTitle(part: ToolCallPart): string {
|
||||||
|
const metadata = (((part as unknown as { state?: { metadata?: unknown } })?.state?.metadata) || {}) as { title?: unknown }
|
||||||
|
const title = typeof metadata.title === "string" && metadata.title.length > 0 ? metadata.title : undefined
|
||||||
|
if (title) return title
|
||||||
|
if (typeof part.tool === "string" && part.tool.length > 0) {
|
||||||
|
return part.tool
|
||||||
|
}
|
||||||
|
return TOOL_FALLBACK_LABEL
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolTypeLabel(part: ToolCallPart): string {
|
||||||
|
if (typeof part.tool === "string" && part.tool.trim().length > 0) {
|
||||||
|
return part.tool.trim().slice(0, 4)
|
||||||
|
}
|
||||||
|
return TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTextsTooltip(texts: string[], fallback: string): string {
|
||||||
|
const combined = texts
|
||||||
|
.map((text) => text.trim())
|
||||||
|
.filter((text) => text.length > 0)
|
||||||
|
.join("\n\n")
|
||||||
|
if (combined.length > 0) {
|
||||||
|
return truncateText(combined)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolTooltip(titles: string[]): string {
|
||||||
|
if (titles.length === 0) {
|
||||||
|
return TOOL_FALLBACK_LABEL
|
||||||
|
}
|
||||||
|
return truncateText(`${TOOL_FALLBACK_LABEL}: ${titles.join(", ")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineSegments(instanceId: string, record: MessageRecord): TimelineSegment[] {
|
||||||
|
if (!record) return []
|
||||||
|
const { orderedParts } = buildRecordDisplayData(instanceId, record)
|
||||||
|
if (!orderedParts || orderedParts.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: TimelineSegment[] = []
|
||||||
|
let segmentIndex = 0
|
||||||
|
let pending: PendingSegment | null = null
|
||||||
|
const flushPending = () => {
|
||||||
|
if (!pending) return
|
||||||
|
if (pending.type === "assistant" && !pending.hasPrimaryText) {
|
||||||
|
pending = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const isToolSegment = pending.type === "tool"
|
||||||
|
const label = isToolSegment
|
||||||
|
? pending.toolTypeLabels[0] || TOOL_FALLBACK_LABEL.slice(0, 4)
|
||||||
|
: SEGMENT_LABELS[pending.type]
|
||||||
|
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined
|
||||||
|
const tooltip = isToolSegment
|
||||||
|
? formatToolTooltip(pending.toolTitles)
|
||||||
|
: formatTextsTooltip(
|
||||||
|
[...pending.texts, ...pending.reasoningTexts],
|
||||||
|
pending.type === "user" ? "User message" : "Assistant response",
|
||||||
|
)
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: `${record.id}:${segmentIndex}`,
|
||||||
|
messageId: record.id,
|
||||||
|
type: pending.type,
|
||||||
|
label,
|
||||||
|
tooltip,
|
||||||
|
shortLabel,
|
||||||
|
})
|
||||||
|
segmentIndex += 1
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
|
||||||
|
if (!pending || pending.type !== type) {
|
||||||
|
flushPending()
|
||||||
|
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], hasPrimaryText: type !== "assistant" }
|
||||||
|
}
|
||||||
|
return pending!
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const defaultContentType: TimelineSegmentType = record.role === "user" ? "user" : "assistant"
|
||||||
|
|
||||||
|
for (const part of orderedParts) {
|
||||||
|
if (!part || typeof part !== "object") continue
|
||||||
|
|
||||||
|
if (part.type === "tool") {
|
||||||
|
const target = ensureSegment("tool")
|
||||||
|
const toolPart = part as ToolCallPart
|
||||||
|
target.toolTitles.push(getToolTitle(toolPart))
|
||||||
|
target.toolTypeLabels.push(getToolTypeLabel(toolPart))
|
||||||
|
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
const text = collectReasoningText(part)
|
||||||
|
if (text.trim().length === 0) continue
|
||||||
|
const target = ensureSegment(defaultContentType)
|
||||||
|
if (target) {
|
||||||
|
target.reasoningTexts.push(text)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === "step-start" || part.type === "step-finish") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = collectTextFromPart(part)
|
||||||
|
if (text.trim().length === 0) continue
|
||||||
|
const target = ensureSegment(defaultContentType)
|
||||||
|
if (target) {
|
||||||
|
target.texts.push(text)
|
||||||
|
target.hasPrimaryText = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
flushPending()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
||||||
|
const buttonRefs = new Map<string, HTMLButtonElement>()
|
||||||
|
const store = () => messageStoreBus.getOrCreate(props.instanceId)
|
||||||
|
const [hoveredSegment, setHoveredSegment] = createSignal<TimelineSegment | null>(null)
|
||||||
|
const [tooltipCoords, setTooltipCoords] = createSignal<{ top: number; left: number }>({ top: 0, left: 0 })
|
||||||
|
const [hoverAnchorRect, setHoverAnchorRect] = createSignal<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||||
|
const [tooltipSize, setTooltipSize] = createSignal<{ width: number; height: number }>({ width: 360, height: 420 })
|
||||||
|
const [tooltipElement, setTooltipElement] = createSignal<HTMLDivElement | null>(null)
|
||||||
|
let hoverTimer: number | null = null
|
||||||
|
const showTools = () => props.showToolSegments ?? true
|
||||||
|
|
||||||
|
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
|
||||||
|
if (element) {
|
||||||
|
buttonRefs.set(segmentId, element)
|
||||||
|
} else {
|
||||||
|
buttonRefs.delete(segmentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearHoverTimer = () => {
|
||||||
|
if (hoverTimer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(hoverTimer)
|
||||||
|
hoverTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseEnter = (segment: TimelineSegment, event: MouseEvent) => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
clearHoverTimer()
|
||||||
|
const target = event.currentTarget as HTMLButtonElement
|
||||||
|
hoverTimer = window.setTimeout(() => {
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
setHoverAnchorRect({ top: rect.top, left: rect.left, width: rect.width, height: rect.height })
|
||||||
|
setHoveredSegment(segment)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
clearHoverTimer()
|
||||||
|
setHoveredSegment(null)
|
||||||
|
setHoverAnchorRect(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const anchor = hoverAnchorRect()
|
||||||
|
const segment = hoveredSegment()
|
||||||
|
if (!anchor || !segment) return
|
||||||
|
const { width, height } = tooltipSize()
|
||||||
|
const verticalGap = 16
|
||||||
|
const horizontalGap = 16
|
||||||
|
const preferredTop = anchor.top + anchor.height / 2 - height / 2
|
||||||
|
const maxTop = window.innerHeight - height - verticalGap
|
||||||
|
const clampedTop = Math.min(maxTop, Math.max(verticalGap, preferredTop))
|
||||||
|
const preferredLeft = anchor.left - width - horizontalGap
|
||||||
|
const clampedLeft = Math.max(horizontalGap, preferredLeft)
|
||||||
|
setTooltipCoords({ top: clampedTop, left: clampedLeft })
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => clearHoverTimer())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const activeId = props.activeMessageId
|
||||||
|
|
||||||
|
if (!activeId) return
|
||||||
|
const targetSegment = props.segments.find((segment) => segment.messageId === activeId)
|
||||||
|
if (!targetSegment) return
|
||||||
|
const element = buttonRefs.get(targetSegment.id)
|
||||||
|
if (!element) return
|
||||||
|
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||||
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
|
}, 120) : null
|
||||||
|
onCleanup(() => {
|
||||||
|
if (timer !== null && typeof window !== "undefined") {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const element = tooltipElement()
|
||||||
|
if (!element || typeof window === "undefined") return
|
||||||
|
const updateSize = () => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
setTooltipSize({ width: rect.width, height: rect.height })
|
||||||
|
}
|
||||||
|
updateSize()
|
||||||
|
if (typeof ResizeObserver === "undefined") return
|
||||||
|
const observer = new ResizeObserver(() => updateSize())
|
||||||
|
observer.observe(element)
|
||||||
|
onCleanup(() => observer.disconnect())
|
||||||
|
})
|
||||||
|
|
||||||
|
const previewData = createMemo(() => {
|
||||||
|
|
||||||
|
const segment = hoveredSegment()
|
||||||
|
if (!segment) return null
|
||||||
|
const record = store().getMessage(segment.messageId)
|
||||||
|
if (!record) return null
|
||||||
|
return { messageId: segment.messageId }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-timeline" role="navigation" aria-label="Message timeline">
|
||||||
|
<For each={props.segments}>
|
||||||
|
{(segment) => {
|
||||||
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
|
const isActive = () => props.activeMessageId === segment.messageId
|
||||||
|
const isHidden = () => segment.type === "tool" && !(showTools() || isActive())
|
||||||
|
const shortLabelContent = () => {
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
|
}
|
||||||
|
if (segment.type === "user") {
|
||||||
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
|
type="button"
|
||||||
|
class={`message-timeline-segment message-timeline-${segment.type} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
|
onClick={() => props.onSegmentClick?.(segment)}
|
||||||
|
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
||||||
|
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
<Show when={previewData()}>
|
||||||
|
{(data) => {
|
||||||
|
onCleanup(() => setTooltipElement(null))
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={(element) => setTooltipElement(element)}
|
||||||
|
class="message-timeline-tooltip"
|
||||||
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
|
>
|
||||||
|
<MessagePreview
|
||||||
|
messageId={data().messageId}
|
||||||
|
instanceId={props.instanceId}
|
||||||
|
sessionId={props.sessionId}
|
||||||
|
store={store}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageTimeline
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ interface PromptInputProps {
|
|||||||
escapeInDebounce?: boolean
|
escapeInDebounce?: boolean
|
||||||
isSessionBusy?: boolean
|
isSessionBusy?: boolean
|
||||||
onAbortSession?: () => Promise<void>
|
onAbortSession?: () => Promise<void>
|
||||||
|
registerQuoteHandler?: (handler: (text: string, mode: "quote" | "code") => void) => void | (() => void)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
@@ -42,6 +43,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const [pasteCount, setPasteCount] = createSignal(0)
|
const [pasteCount, setPasteCount] = createSignal(0)
|
||||||
const [imageCount, setImageCount] = createSignal(0)
|
const [imageCount, setImageCount] = createSignal(0)
|
||||||
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
const [mode, setMode] = createSignal<"normal" | "shell">("normal")
|
||||||
|
const SELECTION_INSERT_MAX_LENGTH = 2000
|
||||||
let textareaRef: HTMLTextAreaElement | undefined
|
let textareaRef: HTMLTextAreaElement | undefined
|
||||||
let containerRef: HTMLDivElement | undefined
|
let containerRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -51,6 +53,22 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
const attachments = () => getAttachments(props.instanceId, props.sessionId)
|
const attachments = () => getAttachments(props.instanceId, props.sessionId)
|
||||||
const instanceAgents = () => agents().get(props.instanceId) || []
|
const instanceAgents = () => agents().get(props.instanceId) || []
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.registerQuoteHandler) return
|
||||||
|
const cleanup = props.registerQuoteHandler((text, mode) => {
|
||||||
|
if (mode === "code") {
|
||||||
|
insertCodeSelection(text)
|
||||||
|
} else {
|
||||||
|
insertQuotedSelection(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
if (typeof cleanup === "function") {
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const setPrompt = (value: string) => {
|
const setPrompt = (value: string) => {
|
||||||
setPromptInternal(value)
|
setPromptInternal(value)
|
||||||
setSessionDraftPrompt(props.instanceId, props.sessionId, value)
|
setSessionDraftPrompt(props.instanceId, props.sessionId, value)
|
||||||
@@ -869,6 +887,64 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
textareaRef?.focus()
|
textareaRef?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertBlockContent(block: string) {
|
||||||
|
const textarea = textareaRef
|
||||||
|
const current = prompt()
|
||||||
|
const start = textarea ? textarea.selectionStart : current.length
|
||||||
|
const end = textarea ? textarea.selectionEnd : current.length
|
||||||
|
const before = current.substring(0, start)
|
||||||
|
const after = current.substring(end)
|
||||||
|
const needsLeading = before.length > 0 && !before.endsWith("\n") ? "\n" : ""
|
||||||
|
const insertion = `${needsLeading}${block}`
|
||||||
|
const nextValue = before + insertion + after
|
||||||
|
|
||||||
|
setPrompt(nextValue)
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
setHistoryDraft(null)
|
||||||
|
setShowPicker(false)
|
||||||
|
setAtPosition(null)
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const cursor = before.length + insertion.length
|
||||||
|
textarea.focus()
|
||||||
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertQuotedSelection(rawText: string) {
|
||||||
|
const normalized = (rawText ?? "").replace(/\r/g, "").trim()
|
||||||
|
if (!normalized) return
|
||||||
|
const limited =
|
||||||
|
normalized.length > SELECTION_INSERT_MAX_LENGTH
|
||||||
|
? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH).trimEnd()
|
||||||
|
: normalized
|
||||||
|
const lines = limited
|
||||||
|
.split(/\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
if (lines.length === 0) return
|
||||||
|
|
||||||
|
const blockquote = lines.map((line) => `> ${line}`).join("\n")
|
||||||
|
if (!blockquote) return
|
||||||
|
|
||||||
|
insertBlockContent(`${blockquote}\n\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCodeSelection(rawText: string) {
|
||||||
|
const normalized = (rawText ?? "").replace(/\r/g, "")
|
||||||
|
const limited =
|
||||||
|
normalized.length > SELECTION_INSERT_MAX_LENGTH
|
||||||
|
? normalized.slice(0, SELECTION_INSERT_MAX_LENGTH)
|
||||||
|
: normalized
|
||||||
|
const trimmed = limited.replace(/^\n+/, "").replace(/\n+$/, "")
|
||||||
|
if (!trimmed) return
|
||||||
|
|
||||||
|
const block = "```\n" + trimmed + "\n```\n\n"
|
||||||
|
insertBlockContent(block)
|
||||||
|
}
|
||||||
|
|
||||||
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
const canStop = () => Boolean(props.isSessionBusy && props.onAbortSession)
|
||||||
|
|
||||||
const hasHistory = () => history().length > 0
|
const hasHistory = () => history().length > 0
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface SessionViewProps {
|
|||||||
showSidebarToggle?: boolean
|
showSidebarToggle?: boolean
|
||||||
onSidebarToggle?: () => void
|
onSidebarToggle?: () => void
|
||||||
forceCompactStatusLayout?: boolean
|
forceCompactStatusLayout?: boolean
|
||||||
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionView: Component<SessionViewProps> = (props) => {
|
export const SessionView: Component<SessionViewProps> = (props) => {
|
||||||
@@ -38,6 +39,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
return getSessionBusyStatus(props.instanceId, currentSession.id)
|
||||||
})
|
})
|
||||||
let scrollToBottomHandle: (() => void) | undefined
|
let scrollToBottomHandle: (() => void) | undefined
|
||||||
|
function scheduleScrollToBottom() {
|
||||||
|
if (!scrollToBottomHandle) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
createEffect(() => {
|
||||||
|
if (!props.isActive) return
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
})
|
||||||
|
let quoteHandler: ((text: string, mode: "quote" | "code") => void) | null = null
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const currentSession = session()
|
const currentSession = session()
|
||||||
@@ -45,12 +57,27 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
|
loadMessages(props.instanceId, currentSession.id).catch((error) => log.error("Failed to load messages", error))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function registerQuoteHandler(handler: (text: string, mode: "quote" | "code") => void) {
|
||||||
|
quoteHandler = handler
|
||||||
|
return () => {
|
||||||
|
if (quoteHandler === handler) {
|
||||||
|
quoteHandler = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuoteSelection(text: string, mode: "quote" | "code") {
|
||||||
|
if (quoteHandler) {
|
||||||
|
quoteHandler(text, mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
|
||||||
|
if (scrollToBottomHandle && import.meta.env?.DEV) {
|
||||||
if (scrollToBottomHandle) {
|
console.debug("[SessionView] handleSendMessage scroll", props.sessionId)
|
||||||
scrollToBottomHandle()
|
|
||||||
}
|
}
|
||||||
|
scheduleScrollToBottom()
|
||||||
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,12 +204,21 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
registerScrollToBottom={(fn) => {
|
isActive={props.isActive}
|
||||||
scrollToBottomHandle = fn
|
registerScrollToBottom={(fn) => {
|
||||||
}}
|
scrollToBottomHandle = fn
|
||||||
|
if (props.isActive) {
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
showSidebarToggle={props.showSidebarToggle}
|
showSidebarToggle={props.showSidebarToggle}
|
||||||
onSidebarToggle={props.onSidebarToggle}
|
onSidebarToggle={props.onSidebarToggle}
|
||||||
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
forceCompactStatusLayout={props.forceCompactStatusLayout}
|
||||||
|
onQuoteSelection={handleQuoteSelection}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
@@ -195,6 +231,7 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
escapeInDebounce={props.escapeInDebounce}
|
escapeInDebounce={props.escapeInDebounce}
|
||||||
isSessionBusy={sessionBusy()}
|
isSessionBusy={sessionBusy()}
|
||||||
onAbortSession={handleAbortSession}
|
onAbortSession={handleAbortSession}
|
||||||
|
registerQuoteHandler={registerQuoteHandler}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -702,6 +702,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
if (!state) return getRendererAction()
|
if (!state) return getRendererAction()
|
||||||
if (state.status === "pending") return getRendererAction()
|
if (state.status === "pending") return getRendererAction()
|
||||||
|
|
||||||
|
const customTitle = renderer().getTitle?.(rendererContext)
|
||||||
|
if (customTitle) return customTitle
|
||||||
|
|
||||||
if (isToolStateRunning(state) && state.title) {
|
if (isToolStateRunning(state) && state.title) {
|
||||||
return state.title
|
return state.title
|
||||||
}
|
}
|
||||||
@@ -710,9 +713,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return state.title
|
return state.title
|
||||||
}
|
}
|
||||||
|
|
||||||
const customTitle = renderer().getTitle?.(rendererContext)
|
|
||||||
if (customTitle) return customTitle
|
|
||||||
|
|
||||||
return getToolName(toolName())
|
return getToolName(toolName())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,7 +886,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
<Show when={status() === "pending" && !pendingPermission()}>
|
<Show when={status() === "pending" && !pendingPermission()}>
|
||||||
<div class="tool-call-pending-message">
|
<div class="tool-call-pending-message">
|
||||||
<span class="spinner-small"></span>
|
<span class="spinner-small"></span>
|
||||||
<span>Waiting for permission...</span>
|
<span>Waiting to run...</span>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ export const bashRenderer: ToolRenderer = {
|
|||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
const name = getToolName("bash")
|
const name = getToolName("bash")
|
||||||
if (typeof input.description === "string" && input.description.length > 0) {
|
const description = typeof input.description === "string" && input.description.length > 0 ? input.description : ""
|
||||||
return `${name} ${input.description}`
|
const timeout = typeof input.timeout === "number" && input.timeout > 0 ? input.timeout : undefined
|
||||||
|
|
||||||
|
const baseTitle = description ? `${name} ${description}` : name
|
||||||
|
if (!timeout) {
|
||||||
|
return baseTitle
|
||||||
}
|
}
|
||||||
return name
|
|
||||||
|
const timeoutLabel = `${timeout}ms`
|
||||||
|
return `${baseTitle} · Timeout: ${timeoutLabel}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown }) {
|
renderBody({ toolState, renderMarkdown }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
|
|||||||
@@ -9,8 +9,25 @@ export const readRenderer: ToolRenderer = {
|
|||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
const filePath = typeof input.filePath === "string" ? input.filePath : ""
|
||||||
if (!filePath) return getToolName("read")
|
const offset = typeof input.offset === "number" ? input.offset : undefined
|
||||||
return `${getToolName("read")} ${getRelativePath(filePath)}`
|
const limit = typeof input.limit === "number" ? input.limit : undefined
|
||||||
|
const relativePath = filePath ? getRelativePath(filePath) : ""
|
||||||
|
const detailParts: string[] = []
|
||||||
|
|
||||||
|
if (typeof offset === "number") {
|
||||||
|
detailParts.push(`Offset: ${offset}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limit === "number") {
|
||||||
|
detailParts.push(`Limit: ${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseTitle = relativePath ? `${getToolName("read")} ${relativePath}` : getToolName("read")
|
||||||
|
if (!detailParts.length) {
|
||||||
|
return baseTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseTitle} · ${detailParts.join(" · ")}`
|
||||||
},
|
},
|
||||||
renderBody({ toolState, renderMarkdown }) {
|
renderBody({ toolState, renderMarkdown }) {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { JSX, Show, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
import { JSX, Accessor, children as resolveChildren, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
const sizeCache = new Map<string, number>()
|
const sizeCache = new Map<string, number>()
|
||||||
const DEFAULT_MARGIN_PX = 600
|
const DEFAULT_MARGIN_PX = 600
|
||||||
@@ -98,7 +98,9 @@ interface VirtualItemProps {
|
|||||||
placeholderClass?: string
|
placeholderClass?: string
|
||||||
virtualizationEnabled?: Accessor<boolean>
|
virtualizationEnabled?: Accessor<boolean>
|
||||||
forceVisible?: Accessor<boolean>
|
forceVisible?: Accessor<boolean>
|
||||||
|
suspendMeasurements?: Accessor<boolean>
|
||||||
onMeasured?: () => void
|
onMeasured?: () => void
|
||||||
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
@@ -132,9 +134,17 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
|
const shouldHideContent = createMemo(() => {
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
if (props.forceVisible?.()) return false
|
||||||
|
if (!virtualizationEnabled()) return false
|
||||||
|
return !isIntersecting()
|
||||||
|
})
|
||||||
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
|
||||||
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
let resizeObserver: ResizeObserver | undefined
|
||||||
let intersectionCleanup: (() => void) | undefined
|
let intersectionCleanup: (() => void) | undefined
|
||||||
|
|
||||||
@@ -169,23 +179,27 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMeasuredHeight() {
|
function updateMeasuredHeight() {
|
||||||
if (!contentRef) return
|
if (!contentRef || measurementsSuspended()) return
|
||||||
const next = contentRef.offsetHeight
|
const next = contentRef.offsetHeight
|
||||||
if (next === measuredHeight()) return
|
if (next === measuredHeight()) return
|
||||||
persistMeasurement(next)
|
persistMeasurement(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupResizeObserver() {
|
function setupResizeObserver() {
|
||||||
if (!contentRef) return
|
if (!contentRef || measurementsSuspended()) return
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
if (typeof ResizeObserver === "undefined") {
|
if (typeof ResizeObserver === "undefined") {
|
||||||
updateMeasuredHeight()
|
updateMeasuredHeight()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resizeObserver = new ResizeObserver(() => updateMeasuredHeight())
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (measurementsSuspended()) return
|
||||||
|
updateMeasuredHeight()
|
||||||
|
})
|
||||||
resizeObserver.observe(contentRef)
|
resizeObserver.observe(contentRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
function refreshIntersectionObserver(targetRoot: Element | Document | null) {
|
||||||
cleanupIntersectionObserver()
|
cleanupIntersectionObserver()
|
||||||
if (!wrapperRef) {
|
if (!wrapperRef) {
|
||||||
@@ -212,6 +226,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
contentRef = element ?? undefined
|
contentRef = element ?? undefined
|
||||||
if (contentRef) {
|
if (contentRef) {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
updateMeasuredHeight()
|
updateMeasuredHeight()
|
||||||
setupResizeObserver()
|
setupResizeObserver()
|
||||||
})
|
})
|
||||||
@@ -219,9 +234,23 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) {
|
||||||
|
cleanupResizeObserver()
|
||||||
|
} else if (contentRef) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
updateMeasuredHeight()
|
||||||
|
setupResizeObserver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const key = props.cacheKey
|
const key = props.cacheKey
|
||||||
|
|
||||||
const cached = sizeCache.get(key)
|
const cached = sizeCache.get(key)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setMeasuredHeight(cached)
|
setMeasuredHeight(cached)
|
||||||
@@ -237,13 +266,8 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
refreshIntersectionObserver(root ?? null)
|
refreshIntersectionObserver(root ?? null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const shouldHideContent = createMemo(() => {
|
|
||||||
if (props.forceVisible?.()) return false
|
|
||||||
if (!virtualizationEnabled()) return false
|
|
||||||
return !isIntersecting()
|
|
||||||
})
|
|
||||||
|
|
||||||
const placeholderHeight = createMemo(() => {
|
const placeholderHeight = createMemo(() => {
|
||||||
|
|
||||||
const seenHeight = measuredHeight()
|
const seenHeight = measuredHeight()
|
||||||
if (seenHeight > 0) {
|
if (seenHeight > 0) {
|
||||||
return seenHeight
|
return seenHeight
|
||||||
@@ -266,9 +290,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
return classes.filter(Boolean).join(" ")
|
return classes.filter(Boolean).join(" ")
|
||||||
}
|
}
|
||||||
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
const placeholderClass = () => ["virtual-item-placeholder", props.placeholderClass].filter(Boolean).join(" ")
|
||||||
|
const lazyContent = createMemo<JSX.Element | null>(() => {
|
||||||
|
if (shouldHideContent()) return null
|
||||||
|
return resolved()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setWrapperRef} class={wrapperClass()} style={{ width: "100%" }}>
|
<div ref={setWrapperRef} id={props.id} class={wrapperClass()} style={{ width: "100%" }}>
|
||||||
<div
|
<div
|
||||||
class={placeholderClass()}
|
class={placeholderClass()}
|
||||||
style={{
|
style={{
|
||||||
@@ -277,7 +306,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={setContentRef} class={contentClass()}>
|
<div ref={setContentRef} class={contentClass()}>
|
||||||
{resolved()}
|
{lazyContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const log = getLogger("actions")
|
|||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
|
toggleShowTimelineTools: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
@@ -410,6 +411,15 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
action: options.toggleShowThinkingBlocks,
|
action: options.toggleShowThinkingBlocks,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "timeline-tools",
|
||||||
|
label: () => `${options.preferences().showTimelineTools ? "Hide" : "Show"} Timeline Tool Calls`,
|
||||||
|
description: "Toggle tool call entries in the message timeline",
|
||||||
|
category: "System",
|
||||||
|
keywords: ["timeline", "tool", "toggle"],
|
||||||
|
action: options.toggleShowTimelineTools,
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "thinking-default-visibility",
|
id: "thinking-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
|
|||||||
@@ -8,17 +8,39 @@ const log = getLogger("session")
|
|||||||
class MessageStoreBus {
|
class MessageStoreBus {
|
||||||
private stores = new Map<string, InstanceMessageStore>()
|
private stores = new Map<string, InstanceMessageStore>()
|
||||||
private teardownHandlers = new Set<(instanceId: string) => void>()
|
private teardownHandlers = new Set<(instanceId: string) => void>()
|
||||||
|
private sessionClearHandlers = new Set<(instanceId: string, sessionId: string) => void>()
|
||||||
|
|
||||||
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
|
registerInstance(instanceId: string, store?: InstanceMessageStore): InstanceMessageStore {
|
||||||
if (this.stores.has(instanceId)) {
|
if (this.stores.has(instanceId)) {
|
||||||
return this.stores.get(instanceId) as InstanceMessageStore
|
return this.stores.get(instanceId) as InstanceMessageStore
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = store ?? createInstanceMessageStore(instanceId)
|
const resolved =
|
||||||
|
store ??
|
||||||
|
createInstanceMessageStore(instanceId, {
|
||||||
|
onSessionCleared: (id, sessionId) => this.notifySessionCleared(id, sessionId),
|
||||||
|
})
|
||||||
this.stores.set(instanceId, resolved)
|
this.stores.set(instanceId, resolved)
|
||||||
return resolved
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSessionCleared(handler: (instanceId: string, sessionId: string) => void): () => void {
|
||||||
|
this.sessionClearHandlers.add(handler)
|
||||||
|
return () => {
|
||||||
|
this.sessionClearHandlers.delete(handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifySessionCleared(instanceId: string, sessionId: string) {
|
||||||
|
for (const handler of this.sessionClearHandlers) {
|
||||||
|
try {
|
||||||
|
handler(instanceId, sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to run session clear handler", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getInstance(instanceId: string): InstanceMessageStore | undefined {
|
getInstance(instanceId: string): InstanceMessageStore | undefined {
|
||||||
return this.stores.get(instanceId)
|
return this.stores.get(instanceId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import type { SetStoreFunction } from "solid-js/store"
|
import type { SetStoreFunction } from "solid-js/store"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
import type { ClientPart, MessageInfo } from "../../types/message"
|
import type { ClientPart, MessageInfo } from "../../types/message"
|
||||||
|
import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
|
||||||
import type {
|
import type {
|
||||||
InstanceMessageState,
|
InstanceMessageState,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
@@ -17,6 +19,12 @@ import type {
|
|||||||
UsageEntry,
|
UsageEntry,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
|
const storeLog = getLogger("session")
|
||||||
|
|
||||||
|
interface MessageStoreHooks {
|
||||||
|
onSessionCleared?: (instanceId: string, sessionId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
function createInitialState(instanceId: string): InstanceMessageState {
|
function createInitialState(instanceId: string): InstanceMessageState {
|
||||||
return {
|
return {
|
||||||
instanceId,
|
instanceId,
|
||||||
@@ -202,7 +210,7 @@ export interface InstanceMessageStore {
|
|||||||
clearInstance: () => void
|
clearInstance: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInstanceMessageStore(instanceId: string): InstanceMessageStore {
|
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
|
||||||
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
|
||||||
|
|
||||||
|
|
||||||
@@ -696,80 +704,92 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
|
|||||||
function clearSession(sessionId: string) {
|
function clearSession(sessionId: string) {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
|
|
||||||
const messageIds = Object.values(state.messages)
|
const messageIds = Object.values(state.messages)
|
||||||
.filter((record) => record.sessionId === sessionId)
|
.filter((record) => record.sessionId === sessionId)
|
||||||
.map((record) => record.id)
|
.map((record) => record.id)
|
||||||
|
|
||||||
|
storeLog.info("Clearing session data", { instanceId, sessionId, messageCount: messageIds.length })
|
||||||
|
clearRecordDisplayCacheForMessages(instanceId, messageIds)
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
setState("messages", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
messageIds.forEach((id) => delete next[id])
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
// Remove message-level data
|
setState("messageInfoVersion", (prev) => {
|
||||||
setState("messages", (prev) => {
|
const next = { ...prev }
|
||||||
const next = { ...prev }
|
messageIds.forEach((id) => delete next[id])
|
||||||
messageIds.forEach((id) => delete next[id])
|
return next
|
||||||
return next
|
})
|
||||||
})
|
|
||||||
|
|
||||||
setState("messageInfoVersion", (prev) => {
|
messageIds.forEach((id) => messageInfoCache.delete(id))
|
||||||
const next = { ...prev }
|
|
||||||
messageIds.forEach((id) => delete next[id])
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
messageIds.forEach((id) => messageInfoCache.delete(id))
|
setState("pendingParts", (prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
messageIds.forEach((id) => {
|
||||||
|
if (next[id]) delete next[id]
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("pendingParts", (prev) => {
|
setState("permissions", "byMessage", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
messageIds.forEach((id) => {
|
messageIds.forEach((id) => {
|
||||||
if (next[id]) delete next[id]
|
if (next[id]) delete next[id]
|
||||||
})
|
})
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setState("permissions", "byMessage", (prev) => {
|
setState("usage", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
messageIds.forEach((id) => {
|
delete next[sessionId]
|
||||||
if (next[id]) delete next[id]
|
return next
|
||||||
})
|
})
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remove session-level data
|
setState("sessionRevisions", (prev) => {
|
||||||
setState("usage", (prev) => {
|
const next = { ...prev }
|
||||||
const next = { ...prev }
|
delete next[sessionId]
|
||||||
delete next[sessionId]
|
return next
|
||||||
return next
|
})
|
||||||
})
|
|
||||||
|
|
||||||
setState("sessionRevisions", (prev) => {
|
setState("scrollState", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[sessionId]
|
const prefix = `${sessionId}:`
|
||||||
return next
|
Object.keys(next).forEach((key) => {
|
||||||
})
|
if (key.startsWith(prefix)) {
|
||||||
|
delete next[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
setState("scrollState", (prev) => {
|
setState("sessions", sessionId, (current) => {
|
||||||
const next = { ...prev }
|
if (!current) return current
|
||||||
const prefix = `${sessionId}:`
|
return { ...current, messageIds: [] }
|
||||||
Object.keys(next).forEach((key) => {
|
})
|
||||||
if (key.startsWith(prefix)) {
|
|
||||||
delete next[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
setState("sessions", (prev) => {
|
setState("sessions", (prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[sessionId]
|
delete next[sessionId]
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
|
||||||
}
|
})
|
||||||
|
|
||||||
|
hooks?.onSessionCleared?.(instanceId, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function clearInstance() {
|
function clearInstance() {
|
||||||
messageInfoCache.clear()
|
messageInfoCache.clear()
|
||||||
setState(reconcile(createInitialState(instanceId)))
|
setState(reconcile(createInitialState(instanceId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
instanceId,
|
instanceId,
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
|
|||||||
@@ -44,3 +44,10 @@ export function clearRecordDisplayCacheForInstance(instanceId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearRecordDisplayCacheForMessages(instanceId: string, messageIds: Iterable<string>) {
|
||||||
|
for (const messageId of messageIds) {
|
||||||
|
if (typeof messageId !== "string" || messageId.length === 0) continue
|
||||||
|
recordDisplayCache.delete(makeCacheKey(instanceId, messageId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type ListeningMode = "local" | "all"
|
|||||||
export interface Preferences {
|
export interface Preferences {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
|
showTimelineTools: boolean
|
||||||
lastUsedBinary?: string
|
lastUsedBinary?: string
|
||||||
environmentVariables: Record<string, string>
|
environmentVariables: Record<string, string>
|
||||||
modelRecents: ModelPreference[]
|
modelRecents: ModelPreference[]
|
||||||
@@ -67,6 +68,7 @@ const MAX_RECENT_MODELS = 5
|
|||||||
const defaultPreferences: Preferences = {
|
const defaultPreferences: Preferences = {
|
||||||
showThinkingBlocks: false,
|
showThinkingBlocks: false,
|
||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
|
showTimelineTools: true,
|
||||||
environmentVariables: {},
|
environmentVariables: {},
|
||||||
modelRecents: [],
|
modelRecents: [],
|
||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
@@ -103,6 +105,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
return {
|
return {
|
||||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||||
|
showTimelineTools: sanitized.showTimelineTools ?? defaultPreferences.showTimelineTools,
|
||||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||||
environmentVariables,
|
environmentVariables,
|
||||||
modelRecents,
|
modelRecents,
|
||||||
@@ -301,6 +304,10 @@ function toggleShowThinkingBlocks(): void {
|
|||||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleShowTimelineTools(): void {
|
||||||
|
updatePreferences({ showTimelineTools: !preferences().showTimelineTools })
|
||||||
|
}
|
||||||
|
|
||||||
function toggleUsageMetrics(): void {
|
function toggleUsageMetrics(): void {
|
||||||
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||||
}
|
}
|
||||||
@@ -411,8 +418,10 @@ interface ConfigContextValue {
|
|||||||
setThemePreference: typeof setThemePreference
|
setThemePreference: typeof setThemePreference
|
||||||
updateConfig: typeof updateConfig
|
updateConfig: typeof updateConfig
|
||||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||||
|
toggleShowTimelineTools: typeof toggleShowTimelineTools
|
||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
|
|
||||||
setDiffViewMode: typeof setDiffViewMode
|
setDiffViewMode: typeof setDiffViewMode
|
||||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||||
@@ -445,6 +454,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
setThemePreference,
|
setThemePreference,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
@@ -503,6 +513,7 @@ export {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
|
|||||||
@@ -39,7 +39,31 @@ const [loading, setLoading] = createSignal({
|
|||||||
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
const [messagesLoaded, setMessagesLoaded] = createSignal<Map<string, Set<string>>>(new Map())
|
||||||
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
const [sessionInfoByInstance, setSessionInfoByInstance] = createSignal<Map<string, Map<string, SessionInfo>>>(new Map())
|
||||||
|
|
||||||
|
function clearLoadedFlag(instanceId: string, sessionId: string) {
|
||||||
|
if (!instanceId || !sessionId) return
|
||||||
|
setMessagesLoaded((prev) => {
|
||||||
|
const existing = prev.get(instanceId)
|
||||||
|
if (!existing || !existing.has(sessionId)) {
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
const next = new Map(prev)
|
||||||
|
const updated = new Set(existing)
|
||||||
|
updated.delete(sessionId)
|
||||||
|
if (updated.size === 0) {
|
||||||
|
next.delete(instanceId)
|
||||||
|
} else {
|
||||||
|
next.set(instanceId, updated)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
messageStoreBus.onSessionCleared((instanceId, sessionId) => {
|
||||||
|
clearLoadedFlag(instanceId, sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
function getDraftKey(instanceId: string, sessionId: string): string {
|
function getDraftKey(instanceId: string, sessionId: string): string {
|
||||||
|
|
||||||
return `${instanceId}:${sessionId}`
|
return `${instanceId}:${sessionId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +381,9 @@ export {
|
|||||||
setSessionCompactionState,
|
setSessionCompactionState,
|
||||||
setSessionPendingPermission,
|
setSessionPendingPermission,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
|
|
||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
|
|
||||||
clearActiveParentSession,
|
clearActiveParentSession,
|
||||||
getActiveSession,
|
getActiveSession,
|
||||||
getActiveParentSession,
|
getActiveParentSession,
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
setActiveParentSession,
|
setActiveParentSession,
|
||||||
setActiveSession,
|
setActiveSession,
|
||||||
setSessionDraftPrompt,
|
setSessionDraftPrompt,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
|
|
||||||
import { getDefaultModel } from "./session-models"
|
import { getDefaultModel } from "./session-models"
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@import "./messaging/prompt-input.css";
|
@import "./messaging/prompt-input.css";
|
||||||
@import "./messaging/message-section.css";
|
@import "./messaging/message-section.css";
|
||||||
@import "./messaging/message-block-list.css";
|
@import "./messaging/message-block-list.css";
|
||||||
|
@import "./messaging/message-timeline.css";
|
||||||
@import "./messaging/tool-call.css";
|
@import "./messaging/tool-call.css";
|
||||||
@import "./messaging/log-view.css";
|
@import "./messaging/log-view.css";
|
||||||
|
|
||||||
|
|||||||
@@ -228,3 +228,47 @@
|
|||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-quote-popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quote-button-group {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--list-item-highlight-border);
|
||||||
|
background-color: var(--list-item-highlight-bg-solid);
|
||||||
|
box-shadow: var(--panel-shadow, 0 4px 16px rgba(0, 0, 0, 0.2));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quote-button {
|
||||||
|
pointer-events: auto;
|
||||||
|
@apply inline-flex items-center justify-center;
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quote-button + .message-quote-button {
|
||||||
|
border-left: 1px solid var(--list-item-highlight-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quote-button:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-quote-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent-primary);
|
||||||
|
}
|
||||||
|
|||||||
183
packages/ui/src/styles/messaging/message-timeline.css
Normal file
183
packages/ui/src/styles/messaging/message-timeline.css
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
.message-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 0rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-layout--with-timeline {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.message-layout--with-timeline {
|
||||||
|
grid-template-columns: minmax(0, 1fr) 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-shell {
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-stream-shell .message-stream {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-sidebar {
|
||||||
|
width: 64px;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.message-timeline-sidebar {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
box-shadow: var(--panel-shadow, 0 6px 24px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--border-muted);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: transform 0.15s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment.message-timeline-segment-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment-active {
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: #0f5b44;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment:hover,
|
||||||
|
.message-timeline-segment:focus-visible {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
color: var(--accent-primary);
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment-active,
|
||||||
|
.message-timeline-segment-active:hover,
|
||||||
|
.message-timeline-segment-active:focus-visible {
|
||||||
|
background-color: #0f5b44;
|
||||||
|
color: #fff;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--surface-base), 0 0 0 4px var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-user {
|
||||||
|
border-color: var(--message-user-border);
|
||||||
|
background-color: var(--message-user-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-assistant {
|
||||||
|
border-color: var(--message-assistant-border);
|
||||||
|
background-color: var(--message-assistant-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-tool {
|
||||||
|
border-color: var(--message-tool-border);
|
||||||
|
background-color: var(--surface-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-segment-active {
|
||||||
|
background-color: #0f5b44 !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-label {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-label-short {
|
||||||
|
display: none;
|
||||||
|
line-height: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.message-timeline-label-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.message-timeline-label-short {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview {
|
||||||
|
width: 360px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
box-shadow: var(--panel-shadow, 0 12px 32px rgba(0, 0, 0, 0.25));
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-preview .message-item-base {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
--session-status-permission-fg: #c2410c;
|
--session-status-permission-fg: #c2410c;
|
||||||
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
--session-status-permission-bg: rgba(251, 191, 36, 0.25);
|
||||||
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
--list-item-highlight-bg: rgba(0, 102, 255, 0.1);
|
||||||
|
--list-item-highlight-bg-solid: #e5f0ff;
|
||||||
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
--list-item-highlight-border: rgba(0, 102, 255, 0.25);
|
||||||
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 102, 255, 0.1);
|
||||||
--attachment-chip-text: #0066ff;
|
--attachment-chip-text: #0066ff;
|
||||||
@@ -192,8 +193,10 @@
|
|||||||
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
--session-status-idle-bg: rgba(74, 222, 128, 0.22);
|
||||||
--session-status-permission-fg: #fbbf24;
|
--session-status-permission-fg: #fbbf24;
|
||||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
--list-item-highlight-bg-solid: #15324e;
|
||||||
|
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||||
|
|
||||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||||
--attachment-chip-text: #0080ff;
|
--attachment-chip-text: #0080ff;
|
||||||
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
--attachment-chip-ring: rgba(0, 128, 255, 0.2);
|
||||||
@@ -345,6 +348,7 @@
|
|||||||
--session-status-permission-fg: #fbbf24;
|
--session-status-permission-fg: #fbbf24;
|
||||||
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
--session-status-permission-bg: rgba(251, 191, 36, 0.35);
|
||||||
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
--list-item-highlight-bg: rgba(0, 128, 255, 0.2);
|
||||||
|
--list-item-highlight-bg-solid: #15324e;
|
||||||
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
--list-item-highlight-border: rgba(0, 128, 255, 0.4);
|
||||||
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
--attachment-chip-bg: rgba(0, 128, 255, 0.1);
|
||||||
--attachment-chip-text: #0080ff;
|
--attachment-chip-text: #0080ff;
|
||||||
|
|||||||
Reference in New Issue
Block a user