Compare commits
18 Commits
v0.13.3-de
...
v0.14.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ed3d3b2c | ||
|
|
a111de1af8 | ||
|
|
8a3b162be9 | ||
|
|
c62cb3ce4a | ||
|
|
d9811e735d | ||
|
|
1ce58b9dd9 | ||
|
|
1907a4da03 | ||
|
|
abf4c67fcc | ||
|
|
bc130ceb5b | ||
|
|
8505a43b16 | ||
|
|
2a3329b5ed | ||
|
|
c9c1cf21f0 | ||
|
|
c7d4f99e48 | ||
|
|
d50c00afb4 | ||
|
|
0ef57df3bc | ||
|
|
0739ec857c | ||
|
|
b060ab45ff | ||
|
|
af6429162f |
31
.github/workflows/build-and-upload.yml
vendored
31
.github/workflows/build-and-upload.yml
vendored
@@ -378,7 +378,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build macOS bundle (Tauri)
|
- name: Build macOS bundle (Tauri)
|
||||||
working-directory: packages/tauri-app
|
working-directory: packages/tauri-app
|
||||||
run: npm exec -- tauri build --bundles app,zip
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS)
|
- name: Package Tauri artifacts (macOS)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
@@ -388,9 +388,7 @@ jobs:
|
|||||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
rm -rf "$ARTIFACT_DIR"
|
rm -rf "$ARTIFACT_DIR"
|
||||||
mkdir -p "$ARTIFACT_DIR"
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
if [ -f "$BUNDLE_ROOT/macos/CodeNomad.app.zip" ]; then
|
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||||
mv "$BUNDLE_ROOT/macos/CodeNomad.app.zip" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
|
||||||
elif [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
|
||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-x64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -464,7 +462,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build macOS bundle (Tauri, arm64)
|
- name: Build macOS bundle (Tauri, arm64)
|
||||||
working-directory: packages/tauri-app
|
working-directory: packages/tauri-app
|
||||||
run: npm exec -- tauri build --bundles app,zip
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (macOS arm64)
|
- name: Package Tauri artifacts (macOS arm64)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
@@ -474,9 +472,7 @@ jobs:
|
|||||||
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
ARTIFACT_DIR="packages/tauri-app/release-tauri"
|
||||||
rm -rf "$ARTIFACT_DIR"
|
rm -rf "$ARTIFACT_DIR"
|
||||||
mkdir -p "$ARTIFACT_DIR"
|
mkdir -p "$ARTIFACT_DIR"
|
||||||
if [ -f "$BUNDLE_ROOT/macos/CodeNomad.app.zip" ]; then
|
if [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
||||||
mv "$BUNDLE_ROOT/macos/CodeNomad.app.zip" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
|
||||||
elif [ -d "$BUNDLE_ROOT/macos/CodeNomad.app" ]; then
|
|
||||||
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
ditto -ck --sequesterRsrc --keepParent "$BUNDLE_ROOT/macos/CodeNomad.app" "$ARTIFACT_DIR/CodeNomad-Tauri-${VERSION}-macos-arm64.zip"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -553,7 +549,7 @@ jobs:
|
|||||||
- name: Build Windows bundle (Tauri)
|
- name: Build Windows bundle (Tauri)
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: packages/tauri-app
|
working-directory: packages/tauri-app
|
||||||
run: npm exec -- tauri build --bundles nsis,zip
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Windows)
|
- name: Package Tauri artifacts (Windows)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
@@ -563,19 +559,10 @@ jobs:
|
|||||||
$artifactDir = "packages/tauri-app/release-tauri"
|
$artifactDir = "packages/tauri-app/release-tauri"
|
||||||
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
if (Test-Path $artifactDir) { Remove-Item $artifactDir -Recurse -Force }
|
||||||
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
New-Item -ItemType Directory -Path $artifactDir | Out-Null
|
||||||
|
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
||||||
# Use Tauri-generated zip if available
|
if ($null -ne $exe) {
|
||||||
$tauriZip = Get-ChildItem -Path "$bundleRoot/nsis" -Filter "*.zip" -File | Select-Object -First 1
|
|
||||||
if ($null -ne $tauriZip) {
|
|
||||||
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
||||||
Move-Item $tauriZip.FullName $dest -Force
|
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
||||||
} else {
|
|
||||||
# Fallback: manually zip the exe
|
|
||||||
$exe = Get-ChildItem -Path $bundleRoot -Recurse -File -Filter *.exe | Select-Object -First 1
|
|
||||||
if ($null -ne $exe) {
|
|
||||||
$dest = Join-Path $artifactDir ("CodeNomad-Tauri-$env:VERSION-windows-x64.zip")
|
|
||||||
Compress-Archive -Path $exe.Directory.FullName -DestinationPath $dest -Force
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: Upload Actions artifacts (Tauri Windows)
|
- name: Upload Actions artifacts (Tauri Windows)
|
||||||
@@ -661,7 +648,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Linux bundle (Tauri)
|
- name: Build Linux bundle (Tauri)
|
||||||
working-directory: packages/tauri-app
|
working-directory: packages/tauri-app
|
||||||
run: npm exec -- tauri build --bundles appimage,deb,rpm
|
run: npm exec -- tauri build
|
||||||
|
|
||||||
- name: Package Tauri artifacts (Linux)
|
- name: Package Tauri artifacts (Linux)
|
||||||
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
if: ${{ inputs.upload || inputs.upload_actions_artifacts }}
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -12068,7 +12068,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12105,7 +12105,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12147,7 +12147,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12155,7 +12155,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.13.3",
|
"minServerVersion": "0.14.0",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
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.13.3",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/networ
|
|||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
import { SideCarManager } from "./sidecars/manager"
|
import { SideCarManager } from "./sidecars/manager"
|
||||||
|
import { ClientConnectionManager } from "./clients/connection-manager"
|
||||||
|
import { PluginChannelManager } from "./plugins/channel"
|
||||||
|
import { VoiceModeManager } from "./plugins/voice-mode"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -378,6 +381,14 @@ async function main() {
|
|||||||
|
|
||||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|
||||||
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
|
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||||
|
const voiceModeManager = new VoiceModeManager({
|
||||||
|
connections: clientConnectionManager,
|
||||||
|
channel: pluginChannel,
|
||||||
|
logger: logger.child({ component: "voice-mode" }),
|
||||||
|
})
|
||||||
|
|
||||||
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
const httpsPortExplicit = programHasArg(process.argv.slice(2), "--https-port") || Boolean(process.env.CLI_HTTPS_PORT)
|
||||||
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
const httpPortExplicit = programHasArg(process.argv.slice(2), "--http-port") || Boolean(process.env.CLI_HTTP_PORT)
|
||||||
|
|
||||||
@@ -408,6 +419,9 @@ async function main() {
|
|||||||
speechService,
|
speechService,
|
||||||
sidecarManager,
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
logger,
|
logger,
|
||||||
@@ -430,6 +444,9 @@ async function main() {
|
|||||||
speechService,
|
speechService,
|
||||||
sidecarManager,
|
sidecarManager,
|
||||||
authManager,
|
authManager,
|
||||||
|
clientConnectionManager,
|
||||||
|
pluginChannel,
|
||||||
|
voiceModeManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
logger,
|
logger,
|
||||||
@@ -534,6 +551,12 @@ async function main() {
|
|||||||
logger.error({ err: error }, "SideCar manager shutdown failed")
|
logger.error({ err: error }, "SideCar manager shutdown failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
clientConnectionManager.shutdown()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ err: error }, "Client connection manager shutdown failed")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ export class VoiceModeManager {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): void {
|
setEnabled(instanceId: string, connection: ClientConnectionRef, enabled: boolean): boolean {
|
||||||
if (enabled && !this.options.connections.isConnected(connection)) {
|
if (enabled && !this.options.connections.isConnected(connection)) {
|
||||||
this.options.logger.debug(
|
this.options.logger.debug(
|
||||||
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
{ instanceId, clientId: connection.clientId, connectionId: connection.connectionId },
|
||||||
"Ignoring voice mode enable for disconnected client connection",
|
"Ignoring voice mode enable for disconnected client connection",
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = getConnectionKey(connection)
|
const key = getConnectionKey(connection)
|
||||||
@@ -44,6 +44,7 @@ export class VoiceModeManager {
|
|||||||
|
|
||||||
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
this.options.logger.debug({ instanceId, clientId: connection.clientId, connectionId: connection.connectionId, enabled }, "Voice mode updated for client connection")
|
||||||
this.publishIfChanged(instanceId)
|
this.publishIfChanged(instanceId)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInstance(instanceId: string): void {
|
syncInstance(instanceId: string): void {
|
||||||
@@ -76,7 +77,10 @@ export class VoiceModeManager {
|
|||||||
this.aggregateByInstance.delete(instanceId)
|
this.aggregateByInstance.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.options.logger.debug({ instanceId, enabled }, "Broadcasting aggregate voice mode")
|
this.options.logger.debug(
|
||||||
|
{ instanceId, enabled },
|
||||||
|
"Broadcasting aggregate voice mode",
|
||||||
|
)
|
||||||
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
this.options.channel.send(instanceId, buildVoiceModeEvent(enabled))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ interface HttpServerDeps {
|
|||||||
speechService: SpeechService
|
speechService: SpeechService
|
||||||
sidecarManager: SideCarManager
|
sidecarManager: SideCarManager
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
|
clientConnectionManager: ClientConnectionManager
|
||||||
|
pluginChannel: PluginChannelManager
|
||||||
|
voiceModeManager: VoiceModeManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
logger: Logger
|
logger: Logger
|
||||||
@@ -182,13 +185,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: deps.logger.child({ component: "background-processes" }),
|
logger: deps.logger.child({ component: "background-processes" }),
|
||||||
})
|
})
|
||||||
const clientConnectionManager = new ClientConnectionManager(deps.logger.child({ component: "client-connections" }))
|
|
||||||
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
|
|
||||||
const voiceModeManager = new VoiceModeManager({
|
|
||||||
connections: clientConnectionManager,
|
|
||||||
channel: pluginChannel,
|
|
||||||
logger: deps.logger.child({ component: "voice-mode" }),
|
|
||||||
})
|
|
||||||
|
|
||||||
registerAuthRoutes(app, { authManager: deps.authManager })
|
registerAuthRoutes(app, { authManager: deps.authManager })
|
||||||
|
|
||||||
@@ -268,7 +264,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
registerClient: registerSseClient,
|
registerClient: registerSseClient,
|
||||||
logger: sseLogger,
|
logger: sseLogger,
|
||||||
connectionManager: clientConnectionManager,
|
connectionManager: deps.clientConnectionManager,
|
||||||
})
|
})
|
||||||
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorktreeRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerStorageRoutes(app, {
|
registerStorageRoutes(app, {
|
||||||
@@ -289,8 +285,8 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
logger: proxyLogger,
|
logger: proxyLogger,
|
||||||
channel: pluginChannel,
|
channel: deps.pluginChannel,
|
||||||
voiceModeManager,
|
voiceModeManager: deps.voiceModeManager,
|
||||||
})
|
})
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
@@ -356,7 +352,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
},
|
},
|
||||||
stop: () => {
|
stop: () => {
|
||||||
closeSseClients()
|
closeSseClients()
|
||||||
clientConnectionManager.shutdown()
|
|
||||||
return app.close()
|
return app.close()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,11 +66,17 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
const payload = VoiceModeStateSchema.parse(request.body ?? {})
|
||||||
deps.voiceModeManager.setEnabled(
|
const applied = deps.voiceModeManager.setEnabled(
|
||||||
request.params.id,
|
request.params.id,
|
||||||
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
{ clientId: payload.clientId, connectionId: payload.connectionId },
|
||||||
payload.enabled,
|
payload.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (payload.enabled && !applied) {
|
||||||
|
reply.code(409).send({ error: "Client connection not active for voice mode enable" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
return { enabled: payload.enabled }
|
return { enabled: payload.enabled }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
2
packages/tauri-app/Cargo.lock
generated
2
packages/tauri-app/Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,14 +11,7 @@
|
|||||||
"sync:version": "node ./scripts/sync-tauri-version.js",
|
"sync:version": "node ./scripts/sync-tauri-version.js",
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "tauri build",
|
"build": "tauri build"
|
||||||
"build:mac": "tauri build --target universal-apple-darwin --bundles app,zip",
|
|
||||||
"build:mac-arm": "tauri build --target aarch64-apple-darwin --bundles app,zip",
|
|
||||||
"build:mac-intel": "tauri build --target x86_64-apple-darwin --bundles app,zip",
|
|
||||||
"build:mac-zip": "tauri build --target universal-apple-darwin --bundles zip",
|
|
||||||
"build:win": "tauri build --bundles nsis,zip",
|
|
||||||
"build:win-zip": "tauri build --bundles zip",
|
|
||||||
"build:linux": "tauri build --bundles appimage,deb,rpm"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "codenomad-tauri"
|
name = "codenomad-tauri"
|
||||||
version = "0.13.3"
|
version = "0.14.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
||||||
@@ -28,4 +28,4 @@ url = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.59", features = ["Win32_UI_Shell"] }
|
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::ffi::c_void;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::{BufRead, BufReader, Read, Write};
|
use std::io::{BufRead, BufReader, Read, Write};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::mem::{size_of, zeroed};
|
||||||
use std::net::TcpStream;
|
use std::net::TcpStream;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::process::CommandExt;
|
use std::os::unix::process::CommandExt;
|
||||||
@@ -19,12 +23,95 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::io::AsRawHandle;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_sys::Win32::System::JobObjects::{
|
||||||
|
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||||
|
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||||
|
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct WindowsJobObject {
|
||||||
|
// The desktop wrapper may observe only a short-lived Node wrapper PID while the real
|
||||||
|
// server and workspace descendants continue running below it. KILL_ON_JOB_CLOSE gives
|
||||||
|
// Tauri an OS-owned handle for the whole subtree instead of relying on a single PID.
|
||||||
|
handle: HANDLE,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl WindowsJobObject {
|
||||||
|
fn create() -> anyhow::Result<Self> {
|
||||||
|
let handle = unsafe { CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()) };
|
||||||
|
if handle.is_null() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"CreateJobObjectW failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||||
|
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||||
|
|
||||||
|
let ok = unsafe {
|
||||||
|
SetInformationJobObject(
|
||||||
|
handle,
|
||||||
|
JobObjectExtendedLimitInformation,
|
||||||
|
&mut info as *mut _ as *mut c_void,
|
||||||
|
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
let err = std::io::Error::last_os_error();
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(handle);
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!("SetInformationJobObject failed: {}", err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assign_child(&self, child: &Child) -> anyhow::Result<()> {
|
||||||
|
let process_handle = child.as_raw_handle() as HANDLE;
|
||||||
|
let ok = unsafe { AssignProcessToJobObject(self.handle, process_handle) };
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"AssignProcessToJobObject failed: {}",
|
||||||
|
std::io::Error::last_os_error()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
impl Drop for WindowsJobObject {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.handle.is_null() {
|
||||||
|
unsafe {
|
||||||
|
CloseHandle(self.handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Send for WindowsJobObject {}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
unsafe impl Sync for WindowsJobObject {}
|
||||||
|
|
||||||
fn log_line(message: &str) {
|
fn log_line(message: &str) {
|
||||||
println!("[tauri-cli] {message}");
|
println!("[tauri-cli] {message}");
|
||||||
}
|
}
|
||||||
@@ -363,6 +450,8 @@ impl Default for CliStatus {
|
|||||||
pub struct CliProcessManager {
|
pub struct CliProcessManager {
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child: Arc<Mutex<Option<Child>>>,
|
child: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
@@ -372,6 +461,8 @@ impl CliProcessManager {
|
|||||||
Self {
|
Self {
|
||||||
status: Arc::new(Mutex::new(CliStatus::default())),
|
status: Arc::new(Mutex::new(CliStatus::default())),
|
||||||
child: Arc::new(Mutex::new(None)),
|
child: Arc::new(Mutex::new(None)),
|
||||||
|
#[cfg(windows)]
|
||||||
|
job: Arc::new(Mutex::new(None)),
|
||||||
ready: Arc::new(AtomicBool::new(false)),
|
ready: Arc::new(AtomicBool::new(false)),
|
||||||
bootstrap_token: Arc::new(Mutex::new(None)),
|
bootstrap_token: Arc::new(Mutex::new(None)),
|
||||||
}
|
}
|
||||||
@@ -394,6 +485,8 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let status_arc = self.status.clone();
|
let status_arc = self.status.clone();
|
||||||
let child_arc = self.child.clone();
|
let child_arc = self.child.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_arc = self.job.clone();
|
||||||
let ready_flag = self.ready.clone();
|
let ready_flag = self.ready.clone();
|
||||||
let token_arc = self.bootstrap_token.clone();
|
let token_arc = self.bootstrap_token.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
@@ -401,6 +494,8 @@ impl CliProcessManager {
|
|||||||
app.clone(),
|
app.clone(),
|
||||||
status_arc.clone(),
|
status_arc.clone(),
|
||||||
child_arc,
|
child_arc,
|
||||||
|
#[cfg(windows)]
|
||||||
|
job_arc,
|
||||||
ready_flag,
|
ready_flag,
|
||||||
token_arc,
|
token_arc,
|
||||||
dev,
|
dev,
|
||||||
@@ -420,11 +515,12 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop(&self) -> anyhow::Result<()> {
|
pub fn stop(&self) -> anyhow::Result<()> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _job = self.job.lock().take();
|
||||||
|
|
||||||
let mut child_opt = self.child.lock();
|
let mut child_opt = self.child.lock();
|
||||||
if let Some(mut child) = child_opt.take() {
|
if let Some(mut child) = child_opt.take() {
|
||||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||||
#[cfg(windows)]
|
|
||||||
let mut forced_tree_shutdown = false;
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
unsafe {
|
unsafe {
|
||||||
let pid = child.id() as i32;
|
let pid = child.id() as i32;
|
||||||
@@ -446,18 +542,16 @@ impl CliProcessManager {
|
|||||||
Ok(Some(_)) => break,
|
Ok(Some(_)) => break,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if !forced_tree_shutdown
|
if start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS) {
|
||||||
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
|
|
||||||
{
|
|
||||||
log_line(&format!(
|
log_line(&format!(
|
||||||
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
"regular Windows shutdown still running after {}ms; escalating pid={}",
|
||||||
CLI_WINDOWS_FORCE_GRACE_MS,
|
CLI_WINDOWS_FORCE_GRACE_MS,
|
||||||
child.id()
|
child.id()
|
||||||
));
|
));
|
||||||
forced_tree_shutdown = true;
|
|
||||||
if !kill_process_tree_windows(child.id(), true) {
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||||
@@ -476,11 +570,7 @@ impl CliProcessManager {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
if !forced_tree_shutdown
|
if !kill_process_tree_windows(child.id(), true) {
|
||||||
&& !kill_process_tree_windows(child.id(), true)
|
|
||||||
{
|
|
||||||
let _ = child.kill();
|
|
||||||
} else if forced_tree_shutdown {
|
|
||||||
let _ = child.kill();
|
let _ = child.kill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,6 +581,9 @@ impl CliProcessManager {
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
#[cfg(windows)]
|
||||||
|
log_line("tracked CLI process already exited; dropping Windows job object to reap descendants");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut status = self.status.lock();
|
let mut status = self.status.lock();
|
||||||
@@ -511,6 +604,7 @@ impl CliProcessManager {
|
|||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
status: Arc<Mutex<CliStatus>>,
|
status: Arc<Mutex<CliStatus>>,
|
||||||
child_holder: Arc<Mutex<Option<Child>>>,
|
child_holder: Arc<Mutex<Option<Child>>>,
|
||||||
|
#[cfg(windows)] job_holder: Arc<Mutex<Option<WindowsJobObject>>>,
|
||||||
ready: Arc<AtomicBool>,
|
ready: Arc<AtomicBool>,
|
||||||
bootstrap_token: Arc<Mutex<Option<String>>>,
|
bootstrap_token: Arc<Mutex<Option<String>>>,
|
||||||
dev: bool,
|
dev: bool,
|
||||||
@@ -592,6 +686,22 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let pid = child.id();
|
let pid = child.id();
|
||||||
log_line(&format!("spawned pid={pid}"));
|
log_line(&format!("spawned pid={pid}"));
|
||||||
|
#[cfg(windows)]
|
||||||
|
match WindowsJobObject::create().and_then(|job| {
|
||||||
|
job.assign_child(&child)?;
|
||||||
|
Ok(job)
|
||||||
|
}) {
|
||||||
|
Ok(job) => {
|
||||||
|
log_line(&format!("attached pid={pid} to Windows job object"));
|
||||||
|
*job_holder.lock() = Some(job);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log_line(&format!(
|
||||||
|
"failed to attach pid={pid} to Windows job object; falling back to taskkill-only cleanup: {err}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut locked = status.lock();
|
let mut locked = status.lock();
|
||||||
locked.pid = Some(pid);
|
locked.pid = Some(pid);
|
||||||
@@ -665,6 +775,8 @@ impl CliProcessManager {
|
|||||||
let status_clone = status.clone();
|
let status_clone = status.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let child_holder_clone = child_holder.clone();
|
let child_holder_clone = child_holder.clone();
|
||||||
|
#[cfg(windows)]
|
||||||
|
let job_holder_clone = job_holder.clone();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let timeout = Duration::from_secs(60);
|
let timeout = Duration::from_secs(60);
|
||||||
thread::sleep(timeout);
|
thread::sleep(timeout);
|
||||||
@@ -719,6 +831,10 @@ impl CliProcessManager {
|
|||||||
// Drop the handle after the process exits so other callers
|
// Drop the handle after the process exits so other callers
|
||||||
// don't attempt to stop/kill a finished process.
|
// don't attempt to stop/kill a finished process.
|
||||||
*guard = None;
|
*guard = None;
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
let _ = job_holder_clone.lock().take();
|
||||||
|
}
|
||||||
Some(status)
|
Some(status)
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -776,7 +892,8 @@ impl CliProcessManager {
|
|||||||
auth_cookie_name: &str,
|
auth_cookie_name: &str,
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
let local_url_regex =
|
||||||
|
Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)\s*$").ok();
|
||||||
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
let token_prefix = "CODENOMAD_BOOTSTRAP_TOKEN:";
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -818,7 +935,6 @@ impl CliProcessManager {
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
@@ -1022,15 +1138,23 @@ fn resolve_tsx(_app: &AppHandle) -> Option<String> {
|
|||||||
let cwd = std::env::current_dir().ok();
|
let cwd = std::env::current_dir().ok();
|
||||||
let workspace = workspace_root();
|
let workspace = workspace_root();
|
||||||
let mut candidates = vec![
|
let mut candidates = vec![
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("node_modules/tsx/dist/cli.cjs")),
|
||||||
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
cwd.as_ref().map(|p| p.join("node_modules/tsx/dist/cli.js")),
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.mjs")),
|
||||||
cwd.as_ref().map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.cjs")),
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
cwd.as_ref()
|
||||||
cwd.as_ref().map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
.map(|p| p.join("../node_modules/tsx/dist/cli.js")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.mjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.cjs")),
|
||||||
|
cwd.as_ref()
|
||||||
|
.map(|p| p.join("../../node_modules/tsx/dist/cli.js")),
|
||||||
workspace
|
workspace
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
.map(|p| p.join("node_modules/tsx/dist/cli.mjs")),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use serde_json::json;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
|
||||||
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
|
||||||
use tauri::webview::Webview;
|
use tauri::webview::Webview;
|
||||||
@@ -32,12 +32,10 @@ use std::os::windows::ffi::OsStrExt;
|
|||||||
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
|
||||||
|
|
||||||
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
|
||||||
static LAST_ZOOM_TIME: Mutex<Option<Instant>> = Mutex::new(None);
|
|
||||||
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||||
const ZOOM_STEP: f64 = 0.1;
|
const ZOOM_STEP: f64 = 0.1;
|
||||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||||
const ZOOM_DEBOUNCE_MS: u64 = 50;
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||||
@@ -259,15 +257,6 @@ fn clamp_zoom_level(value: f64) -> f64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
|
||||||
if let Ok(mut last_zoom_time) = LAST_ZOOM_TIME.lock() {
|
|
||||||
if let Some(last_time) = *last_zoom_time {
|
|
||||||
if last_time.elapsed().as_millis() < ZOOM_DEBOUNCE_MS as u128 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*last_zoom_time = Some(Instant::now());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
let normalized = clamp_zoom_level(next_zoom);
|
let normalized = clamp_zoom_level(next_zoom);
|
||||||
if window.set_zoom(normalized).is_ok() {
|
if window.set_zoom(normalized).is_ok() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"identifier": "ai.neuralnomads.codenomad.client",
|
"identifier": "ai.neuralnomads.codenomad.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev:bootstrap",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": true,
|
"decorations": true,
|
||||||
"transparent": true,
|
|
||||||
"theme": "Dark",
|
"theme": "Dark",
|
||||||
"backgroundColor": "#1a1a1a",
|
"backgroundColor": "#1a1a1a",
|
||||||
"zoomHotkeysEnabled": true
|
"zoomHotkeysEnabled": true
|
||||||
@@ -53,11 +52,10 @@
|
|||||||
],
|
],
|
||||||
"targets": [
|
"targets": [
|
||||||
"app",
|
"app",
|
||||||
"zip",
|
|
||||||
"appimage",
|
"appimage",
|
||||||
"deb",
|
"deb",
|
||||||
"rpm",
|
"rpm",
|
||||||
"nsis"
|
"nsis"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.13.3",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -629,13 +629,12 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const lastAssistantIdx = props.lastAssistantIndex()
|
const lastAssistantIdx = props.lastAssistantIndex()
|
||||||
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
const isQueued = current.role === "user" && (lastAssistantIdx === -1 || index > lastAssistantIdx)
|
||||||
|
|
||||||
// Intentionally untracked: messageInfoVersion updates should not trigger
|
const messageInfoVersion = props.store().state.messageInfoVersion[current.id] ?? 0
|
||||||
// a full message block rebuild; record revision is the invalidation key.
|
|
||||||
const info = untrack(messageInfo)
|
|
||||||
|
|
||||||
const cacheSignature = [
|
const cacheSignature = [
|
||||||
current.id,
|
current.id,
|
||||||
current.revision,
|
current.revision,
|
||||||
|
messageInfoVersion,
|
||||||
isQueued ? 1 : 0,
|
isQueued ? 1 : 0,
|
||||||
props.showThinking() ? 1 : 0,
|
props.showThinking() ? 1 : 0,
|
||||||
props.thinkingDefaultExpanded() ? 1 : 0,
|
props.thinkingDefaultExpanded() ? 1 : 0,
|
||||||
@@ -647,6 +646,9 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
return cachedBlock.block
|
return cachedBlock.block
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only capture info after cache check fails - ensures fresh data on version bump
|
||||||
|
const info = untrack(messageInfo)
|
||||||
|
|
||||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, current)
|
||||||
const items: MessageBlockItem[] = []
|
const items: MessageBlockItem[] = []
|
||||||
const blockContentKeys: string[] = []
|
const blockContentKeys: string[] = []
|
||||||
@@ -1108,17 +1110,23 @@ function StepCard(props: StepCardProps) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
if (!info || info.role !== "assistant" || !info.tokens) {
|
const part = props.part as any
|
||||||
|
|
||||||
|
// step-finish parts have tokens embedded; also check messageInfo
|
||||||
|
const partTokens = part?.tokens
|
||||||
|
const infoTokens = info && info.role === "assistant" ? info.tokens : undefined
|
||||||
|
const tokens = partTokens ?? infoTokens
|
||||||
|
if (!tokens) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const tokens = info.tokens
|
|
||||||
return {
|
return {
|
||||||
input: tokens.input ?? 0,
|
input: tokens.input ?? 0,
|
||||||
output: tokens.output ?? 0,
|
output: tokens.output ?? 0,
|
||||||
reasoning: tokens.reasoning ?? 0,
|
reasoning: tokens.reasoning ?? 0,
|
||||||
cacheRead: tokens.cache?.read ?? 0,
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
cacheWrite: tokens.cache?.write ?? 0,
|
cacheWrite: tokens.cache?.write ?? 0,
|
||||||
cost: info.cost ?? 0,
|
cost: (part?.cost ?? (info && info.role === "assistant" ? info.cost : 0)) ?? 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1336,9 +1344,7 @@ function ReasoningStreamOutput(props: {
|
|||||||
if (preRef && preRef.textContent !== nextText) {
|
if (preRef && preRef.textContent !== nextText) {
|
||||||
preRef.textContent = nextText
|
preRef.textContent = nextText
|
||||||
}
|
}
|
||||||
if (followScroll.autoScroll()) {
|
followScroll.restoreAfterRender()
|
||||||
followScroll.restoreAfterRender({ forceBottom: true })
|
|
||||||
}
|
|
||||||
notifyContentRendered()
|
notifyContentRendered()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, onCleanup, on, untrack } from "solid-js"
|
||||||
import { MoreHorizontal, Trash, X } from "lucide-solid"
|
import { MoreHorizontal, Pause, Trash, X } from "lucide-solid"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import MessageBlock from "./message-block"
|
import MessageBlock from "./message-block"
|
||||||
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
import { getMessageAnchorId, getMessageIdFromAnchorId } from "./message-anchors"
|
||||||
@@ -16,12 +16,14 @@ import { showAlertDialog } from "../stores/alerts"
|
|||||||
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
import { deleteMessage, deleteMessagePart } from "../stores/session-actions"
|
||||||
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
|
||||||
import type { DeleteHoverState } from "../types/delete-hover"
|
import type { DeleteHoverState } from "../types/delete-hover"
|
||||||
|
import { partHasRenderableText } from "../types/message"
|
||||||
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
|
||||||
import { getPartCharCount } from "../lib/token-utils"
|
import { getPartCharCount } from "../lib/token-utils"
|
||||||
|
|
||||||
const SCROLL_SENTINEL_MARGIN_PX = 8
|
const SCROLL_SENTINEL_MARGIN_PX = 8
|
||||||
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
|
||||||
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
const QUOTE_SELECTION_MAX_LENGTH = 2000
|
||||||
|
const STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX = 8
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
export interface MessageSectionProps {
|
export interface MessageSectionProps {
|
||||||
@@ -40,10 +42,11 @@ export interface MessageSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MessageSection(props: MessageSectionProps) {
|
export default function MessageSection(props: MessageSectionProps) {
|
||||||
const { preferences } = useConfig()
|
const { preferences, updatePreferences } = useConfig()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
const showUsagePreference = () => preferences().showUsageMetrics ?? true
|
||||||
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
const showTimelineToolsPreference = () => preferences().showTimelineTools ?? true
|
||||||
|
const holdLongAssistantRepliesEnabled = () => preferences().holdLongAssistantReplies ?? 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))
|
||||||
const visibleMessageIds = createMemo(() => {
|
const visibleMessageIds = createMemo(() => {
|
||||||
@@ -594,7 +597,10 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
|
||||||
const followToken = createMemo(() => `${sessionRevision()}|${preferenceSignature()}`)
|
// Only preferences should force a follow-token re-anchor. Message/session
|
||||||
|
// revision churn at the end of a turn (message.updated, session.idle, etc.)
|
||||||
|
// should not trigger an immediate scroll-to-bottom.
|
||||||
|
const followToken = createMemo(() => preferenceSignature())
|
||||||
|
|
||||||
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
const initialScrollSnapshot = createMemo(() => store().getScrollSnapshot(props.sessionId, MESSAGE_SCROLL_CACHE_SCOPE))
|
||||||
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
const initialAutoScroll = createMemo(() => initialScrollSnapshot()?.atBottom ?? true)
|
||||||
@@ -624,6 +630,35 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
|
|
||||||
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
|
||||||
|
|
||||||
|
const lastVisibleMessageId = createMemo(() => {
|
||||||
|
const ids = visibleMessageIds()
|
||||||
|
return ids[ids.length - 1] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const autoPinHoldTargetKey = createMemo(() => {
|
||||||
|
if (!holdLongAssistantRepliesEnabled()) return null
|
||||||
|
const messageId = lastVisibleMessageId()
|
||||||
|
return isAssistantTextMessage(messageId) ? messageId : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleHoldLongAssistantReplies() {
|
||||||
|
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAssistantTextMessage(messageId: string | null | undefined) {
|
||||||
|
if (!messageId) return false
|
||||||
|
const resolvedStore = store()
|
||||||
|
const record = resolvedStore.getMessage(messageId)
|
||||||
|
if (!record || record.role !== "assistant") return false
|
||||||
|
|
||||||
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||||
|
return orderedParts.some((part) => {
|
||||||
|
if ((part as any)?.type !== "text") return false
|
||||||
|
if (partHasRenderableText(part)) return true
|
||||||
|
return typeof (part as { text?: unknown }).text === "string"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const api = listApi()
|
const api = listApi()
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -1044,6 +1079,12 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
initialAutoScroll={initialAutoScroll}
|
initialAutoScroll={initialAutoScroll}
|
||||||
resetKey={() => props.sessionId}
|
resetKey={() => props.sessionId}
|
||||||
followToken={followToken}
|
followToken={followToken}
|
||||||
|
autoPinHoldTargetKey={autoPinHoldTargetKey}
|
||||||
|
autoPinHoldTopThresholdPx={STREAMING_TEXT_HOLD_TOP_THRESHOLD_PX}
|
||||||
|
resolveAutoPinHoldElement={(itemWrapper, key) => {
|
||||||
|
const candidates = Array.from(itemWrapper.querySelectorAll<HTMLElement>(`.message-item-base[data-message-id="${key}"][data-message-role="assistant"]`))
|
||||||
|
return candidates[candidates.length - 1] ?? null
|
||||||
|
}}
|
||||||
onScroll={() => {
|
onScroll={() => {
|
||||||
clearQuoteSelection()
|
clearQuoteSelection()
|
||||||
scrollCache.persist(streamElement())
|
scrollCache.persist(streamElement())
|
||||||
@@ -1074,6 +1115,52 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
registerApi={(api) => setListApi(api)}
|
registerApi={(api) => setListApi(api)}
|
||||||
registerState={(state) => setListState(state)}
|
registerState={(state) => setListState(state)}
|
||||||
|
renderControls={(state, api) => (
|
||||||
|
<div class="message-scroll-button-wrapper">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
data-active={holdLongAssistantRepliesEnabled() ? "true" : "false"}
|
||||||
|
onClick={toggleHoldLongAssistantReplies}
|
||||||
|
aria-label={
|
||||||
|
holdLongAssistantRepliesEnabled()
|
||||||
|
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||||
|
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
holdLongAssistantRepliesEnabled()
|
||||||
|
? t("messageSection.scroll.disableHoldAriaLabel")
|
||||||
|
: t("messageSection.scroll.enableHoldAriaLabel")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pause class="message-scroll-icon message-scroll-icon--toggle w-4 h-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<Show when={state.showScrollTopButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => api.scrollToTop()}
|
||||||
|
aria-label={t("messageSection.scroll.toFirstAriaLabel")}
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↑
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
<Show when={state.showScrollBottomButton()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="message-scroll-button"
|
||||||
|
onClick={() => api.scrollToBottom()}
|
||||||
|
aria-label={t("messageSection.scroll.toLatestAriaLabel")}
|
||||||
|
>
|
||||||
|
<span class="message-scroll-icon" aria-hidden="true">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
renderBeforeItems={() => (
|
renderBeforeItems={() => (
|
||||||
<>
|
<>
|
||||||
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
<Show when={!props.loading && visibleMessageIds().length === 0}>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component, type Accessor } from "solid-js"
|
||||||
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
import { Portal } from "solid-js/web"
|
||||||
import MessagePreview from "./message-preview"
|
import MessagePreview from "./message-preview"
|
||||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import type { ClientPart } from "../types/message"
|
import type { ClientPart } from "../types/message"
|
||||||
@@ -54,6 +56,7 @@ const MAX_TOOLTIP_LENGTH = 220
|
|||||||
const LONG_PRESS_MS = 500
|
const LONG_PRESS_MS = 500
|
||||||
const JITTER_THRESHOLD = 10
|
const JITTER_THRESHOLD = 10
|
||||||
const ABSOLUTE_TOKEN_CAP = 10000
|
const ABSOLUTE_TOKEN_CAP = 10000
|
||||||
|
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
|
||||||
|
|
||||||
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
|
||||||
|
|
||||||
@@ -66,6 +69,13 @@ interface PendingSegment {
|
|||||||
hasPrimaryText: boolean
|
hasPrimaryText: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSegmentState {
|
||||||
|
deleteHovered: boolean
|
||||||
|
deleteSelected: boolean
|
||||||
|
hasActivePermission: boolean
|
||||||
|
hidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
function truncateText(value: string): string {
|
function truncateText(value: string): string {
|
||||||
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
if (value.length <= MAX_TOOLTIP_LENGTH) {
|
||||||
return value
|
return value
|
||||||
@@ -351,6 +361,13 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearHoverPreview = () => {
|
||||||
|
clearHoverTimer()
|
||||||
|
clearCloseTimer()
|
||||||
|
setHoveredSegment(null)
|
||||||
|
setHoverAnchorRect(null)
|
||||||
|
}
|
||||||
|
|
||||||
const scheduleClose = () => {
|
const scheduleClose = () => {
|
||||||
if (typeof window === "undefined") return
|
if (typeof window === "undefined") return
|
||||||
clearHoverTimer()
|
clearHoverTimer()
|
||||||
@@ -358,8 +375,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// Small delay so the pointer can travel from the segment to the tooltip.
|
// Small delay so the pointer can travel from the segment to the tooltip.
|
||||||
closeTimer = window.setTimeout(() => {
|
closeTimer = window.setTimeout(() => {
|
||||||
closeTimer = null
|
closeTimer = null
|
||||||
setHoveredSegment(null)
|
clearHoverPreview()
|
||||||
setHoverAnchorRect(null)
|
|
||||||
}, 160)
|
}, 160)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,8 +415,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
clearHoverTimer()
|
clearHoverPreview()
|
||||||
clearCloseTimer()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Selection & histogram rib state ---
|
// --- Selection & histogram rib state ---
|
||||||
@@ -418,6 +433,8 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
// on activation, resize, or expansion — NOT on every scroll frame.
|
// on activation, resize, or expansion — NOT on every scroll frame.
|
||||||
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
const [badgeOffsets, setBadgeOffsets] = createSignal<Record<string, { layoutTop: number; height: number }>>({})
|
||||||
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
const [windowWidth, setWindowWidth] = createSignal(typeof window !== "undefined" ? window.innerWidth : 1200)
|
||||||
|
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
|
||||||
let scrollContainerRef: HTMLDivElement | undefined
|
let scrollContainerRef: HTMLDivElement | undefined
|
||||||
let xrayOverlayRef: HTMLDivElement | undefined
|
let xrayOverlayRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
@@ -449,6 +466,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
if (renderVirtualizedTimeline()) {
|
||||||
|
if (hoveredSegment()) {
|
||||||
|
clearHoverPreview()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!isSelectionActive()) return
|
if (!isSelectionActive()) return
|
||||||
if (!scrollContainerRef || !xrayOverlayRef) return
|
if (!scrollContainerRef || !xrayOverlayRef) return
|
||||||
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
xrayOverlayRef.style.setProperty("--xray-scroll-y", `${-scrollContainerRef.scrollTop}px`)
|
||||||
@@ -477,6 +500,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const renderVirtualizedTimeline = createMemo(() => !isSelectionActive())
|
||||||
|
|
||||||
|
createEffect(on(renderVirtualizedTimeline, () => {
|
||||||
|
clearHoverPreview()
|
||||||
|
}))
|
||||||
|
|
||||||
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
const maxRibWidth = createMemo(() => Math.round(windowWidth() * 0.5))
|
||||||
|
|
||||||
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
// Compute fresh char counts from the store. segment.totalChars can be stale for
|
||||||
@@ -579,7 +608,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
wasLongPress = true
|
wasLongPress = true
|
||||||
|
|
||||||
// Scroll anchoring: preserve visual position of the pressed badge.
|
// Scroll anchoring: preserve visual position of the pressed badge.
|
||||||
const btn = buttonRefs.get(segment.id)
|
const btn = renderVirtualizedTimeline() ? null : buttonRefs.get(segment.id)
|
||||||
let anchorOffset: number | null = null
|
let anchorOffset: number | null = null
|
||||||
if (btn && scrollContainerRef) {
|
if (btn && scrollContainerRef) {
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
||||||
@@ -631,9 +660,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
|
|
||||||
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
createEffect(on(() => props.activeSegmentId, (activeId) => {
|
||||||
if (!activeId) return
|
if (!activeId) return
|
||||||
const element = buttonRefs.get(activeId)
|
|
||||||
if (!element) return
|
|
||||||
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
const timer = typeof window !== "undefined" ? window.setTimeout(() => {
|
||||||
|
if (renderVirtualizedTimeline()) {
|
||||||
|
const index = segmentIndexById().get(activeId)
|
||||||
|
if (index !== undefined) {
|
||||||
|
virtualizerHandle()?.scrollToIndex(index, { align: "nearest", smooth: true })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = buttonRefs.get(activeId)
|
||||||
|
if (!element) return
|
||||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||||
}, 120) : null
|
}, 120) : null
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
@@ -684,60 +721,239 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
return map
|
return map
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const segmentIndexById = createMemo(() => {
|
||||||
|
const map = new Map<string, number>()
|
||||||
|
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const segmentStates = createMemo(() => {
|
||||||
|
const hover = deleteHover()
|
||||||
|
const selectedMessages = props.selectedMessageIds?.()
|
||||||
|
const expandedMessages = props.expandedMessageIds?.()
|
||||||
|
const resolvedStore = store()
|
||||||
|
const indexMap = messageIdToSessionIndex()
|
||||||
|
const selectionActive = isSelectionActive()
|
||||||
|
const result = new Map<string, TimelineSegmentState>()
|
||||||
|
|
||||||
|
for (const segment of props.segments) {
|
||||||
|
let deleteHovered = false
|
||||||
|
if (hover.kind === "message") {
|
||||||
|
deleteHovered = hover.messageId === segment.messageId
|
||||||
|
} else if (hover.kind === "deleteUpTo") {
|
||||||
|
const targetIndex = indexMap.get(hover.messageId)
|
||||||
|
const segmentIndex = indexMap.get(segment.messageId)
|
||||||
|
deleteHovered = targetIndex !== undefined && segmentIndex !== undefined && segmentIndex >= targetIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSelected = selectedMessages?.has(segment.messageId) ?? false
|
||||||
|
|
||||||
|
let hasActivePermission = false
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
const partIds = segment.toolPartIds ?? []
|
||||||
|
for (const partId of partIds) {
|
||||||
|
const permissionState = resolvedStore.getPermissionState(segment.messageId, partId)
|
||||||
|
if (permissionState?.active) {
|
||||||
|
hasActivePermission = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = segment.type === "tool" && !(
|
||||||
|
showTools()
|
||||||
|
|| expandedMessages?.has(segment.messageId)
|
||||||
|
|| selectionActive
|
||||||
|
|| props.activeSegmentId === segment.id
|
||||||
|
|| hasActivePermission
|
||||||
|
|| deleteHovered
|
||||||
|
|| deleteSelected
|
||||||
|
)
|
||||||
|
|
||||||
|
result.set(segment.id, {
|
||||||
|
deleteHovered,
|
||||||
|
deleteSelected,
|
||||||
|
hasActivePermission,
|
||||||
|
hidden,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const segmentStateFor = (segmentId: string): TimelineSegmentState => {
|
||||||
|
return segmentStates().get(segmentId) ?? {
|
||||||
|
deleteHovered: false,
|
||||||
|
deleteSelected: false,
|
||||||
|
hasActivePermission: false,
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentSpacerHeights = createMemo(() => {
|
||||||
|
const states = segmentStates()
|
||||||
|
const result = new Map<string, string>()
|
||||||
|
let previousVisible: TimelineSegment | null = null
|
||||||
|
|
||||||
|
for (let index = 0; index < props.segments.length; index += 1) {
|
||||||
|
const segment = props.segments[index]
|
||||||
|
const state = states.get(segment.id)
|
||||||
|
|
||||||
|
if (state?.hidden) {
|
||||||
|
result.set(segment.id, "0")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previousVisible) {
|
||||||
|
result.set(segment.id, "0")
|
||||||
|
previousVisible = segment
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousRaw = index > 0 ? props.segments[index - 1] : null
|
||||||
|
const startsVisibleToolGroup = segment.type === "tool"
|
||||||
|
&& (previousVisible.type !== "tool" || previousVisible.messageId !== segment.messageId)
|
||||||
|
const startsCollapsedToolGroup = segment.type === "assistant"
|
||||||
|
&& previousVisible.messageId !== segment.messageId
|
||||||
|
&& messagesWithTools().has(segment.messageId)
|
||||||
|
&& previousRaw?.type === "tool"
|
||||||
|
&& previousRaw.messageId === segment.messageId
|
||||||
|
const followsVisibleGroupParent = (segment.type === "user" || segment.type === "compaction")
|
||||||
|
&& previousVisible.type === "assistant"
|
||||||
|
&& messagesWithTools().has(previousVisible.messageId)
|
||||||
|
|
||||||
|
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
|
||||||
|
result.set(
|
||||||
|
segment.id,
|
||||||
|
gapUnits === 1
|
||||||
|
? "var(--message-timeline-segment-gap)"
|
||||||
|
: "calc(var(--message-timeline-segment-gap) * 2)",
|
||||||
|
)
|
||||||
|
|
||||||
|
previousVisible = segment
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="message-timeline-container">
|
<div class="message-timeline-container">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={(element) => {
|
||||||
|
scrollContainerRef = element
|
||||||
|
setScrollElement(element)
|
||||||
|
}}
|
||||||
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
class={`message-timeline${isSelectionActive() ? " message-timeline--selection-active" : ""}`}
|
||||||
role="navigation"
|
role="navigation"
|
||||||
aria-label={t("messageTimeline.ariaLabel")}
|
aria-label={t("messageTimeline.ariaLabel")}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<For each={props.segments}>
|
<Show
|
||||||
{(segment, segIndex) => {
|
when={renderVirtualizedTimeline()}
|
||||||
onCleanup(() => buttonRefs.delete(segment.id))
|
fallback={(
|
||||||
|
<For each={props.segments}>
|
||||||
|
{(segment, segIndex) => {
|
||||||
|
onCleanup(() => buttonRefs.delete(segment.id))
|
||||||
|
const isActive = () => props.activeSegmentId === segment.id
|
||||||
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
const state = () => segmentStateFor(segment.id)
|
||||||
|
const isDeleteHovered = () => state().deleteHovered
|
||||||
|
const isDeleteSelected = () => state().deleteSelected
|
||||||
|
const hasActivePermission = () => state().hasActivePermission
|
||||||
|
const isHidden = () => state().hidden
|
||||||
|
|
||||||
|
const groupRole = (): "child" | "parent" | "none" => {
|
||||||
|
if (segment.type === "tool") return "child"
|
||||||
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortLabelContent = () => {
|
||||||
|
if (segment.type === "tool") {
|
||||||
|
if (hasActivePermission()) {
|
||||||
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
|
}
|
||||||
|
if (segment.type === "compaction") {
|
||||||
|
return <FoldVertical class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
if (segment.type === "user") {
|
||||||
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="message-timeline-item">
|
||||||
|
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||||
|
<button
|
||||||
|
ref={(el) => registerButtonRef(segment.id, el)}
|
||||||
|
type="button"
|
||||||
|
data-variant={segment.variant}
|
||||||
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||||
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (wasLongPress) {
|
||||||
|
wasLongPress = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = buttonRefs.get(segment.id)
|
||||||
|
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||||
|
let anchorOffset: number | null = null
|
||||||
|
if (stableBtn && scrollContainerRef) {
|
||||||
|
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
props.onSelectRange?.(segment.id)
|
||||||
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
|
props.onToggleSelection?.(segment.id)
|
||||||
|
} else if (isMultiSelectActive) {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
} else {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||||
|
const desired = stableBtn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
|
||||||
|
{(segment, index) => {
|
||||||
|
const segIndex = () => index()
|
||||||
const isActive = () => props.activeSegmentId === segment.id
|
const isActive = () => props.activeSegmentId === segment.id
|
||||||
const isSelected = () => props.selectedIds?.().has(segment.id)
|
const isSelected = () => props.selectedIds?.().has(segment.id)
|
||||||
|
const state = () => segmentStateFor(segment.id)
|
||||||
const isDeleteHovered = () => {
|
const isDeleteHovered = () => state().deleteHovered
|
||||||
const hover = deleteHover() as DeleteHoverState
|
const isDeleteSelected = () => state().deleteSelected
|
||||||
if (hover.kind === "message") {
|
const hasActivePermission = () => state().hasActivePermission
|
||||||
return hover.messageId === segment.messageId
|
const isHidden = () => state().hidden
|
||||||
}
|
|
||||||
|
|
||||||
if (hover.kind === "deleteUpTo") {
|
|
||||||
const indexMap = messageIdToSessionIndex()
|
|
||||||
const targetIndex = indexMap.get(hover.messageId)
|
|
||||||
if (targetIndex === undefined) return false
|
|
||||||
const segmentIndex = indexMap.get(segment.messageId)
|
|
||||||
if (segmentIndex === undefined) return false
|
|
||||||
return segmentIndex >= targetIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isDeleteSelected = () => {
|
|
||||||
const selected = props.selectedMessageIds?.()
|
|
||||||
if (!selected) return false
|
|
||||||
return selected.has(segment.messageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasActivePermission = () => {
|
|
||||||
if (segment.type !== "tool") return false
|
|
||||||
const partIds = segment.toolPartIds ?? []
|
|
||||||
if (partIds.length === 0) return false
|
|
||||||
for (const partId of partIds) {
|
|
||||||
const permissionState = store().getPermissionState(segment.messageId, partId)
|
|
||||||
if (permissionState?.active) return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExpanded = () => props.expandedMessageIds?.().has(segment.messageId) ?? false
|
|
||||||
const isHidden = () =>
|
|
||||||
segment.type === "tool" &&
|
|
||||||
!(showTools() || isExpanded() || isSelectionActive() || isActive() || hasActivePermission() || isDeleteHovered() || isDeleteSelected())
|
|
||||||
|
|
||||||
// Group visual indicators: tools belong to the same message as their
|
// Group visual indicators: tools belong to the same message as their
|
||||||
// assistant. Uses messageId for correctness (not positional adjacency).
|
// assistant. Uses messageId for correctness (not positional adjacency).
|
||||||
@@ -746,18 +962,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
if (segment.type === "assistant" && messagesWithTools().has(segment.messageId)) return "parent"
|
||||||
return "none"
|
return "none"
|
||||||
}
|
}
|
||||||
const isGroupStart = () => {
|
|
||||||
if (segment.type !== "tool") return false
|
|
||||||
const idx = segIndex()
|
|
||||||
const prev = idx > 0 ? props.segments[idx - 1] : null
|
|
||||||
// First tool in the message's run: either nothing before, or previous
|
|
||||||
// segment is from a different message or is not a tool.
|
|
||||||
return !prev || prev.type !== "tool" || prev.messageId !== segment.messageId
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortLabelContent = () => {
|
const shortLabelContent = () => {
|
||||||
if (segment.type === "tool") {
|
if (segment.type === "tool") {
|
||||||
if (hasActivePermission()) {
|
if (hasActivePermission()) {
|
||||||
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
return <ShieldAlert class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return segment.shortLabel ?? getToolIcon("tool")
|
return segment.shortLabel ?? getToolIcon("tool")
|
||||||
@@ -767,95 +975,92 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
|
|||||||
}
|
}
|
||||||
if (segment.type === "user") {
|
if (segment.type === "user") {
|
||||||
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
return <UserIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
return <BotIcon class="message-timeline-icon" aria-hidden="true" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div class="message-timeline-item">
|
||||||
ref={(el) => registerButtonRef(segment.id, el)}
|
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
|
||||||
type="button"
|
<button
|
||||||
data-variant={segment.variant}
|
type="button"
|
||||||
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""} ${isGroupStart() ? "message-timeline-group-start" : ""}`}
|
data-variant={segment.variant}
|
||||||
|
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""} ${isSelected() ? "message-timeline-segment-selected" : ""} ${isDeleteSelected() ? "message-timeline-segment-delete-selected" : ""} ${groupRole() !== "none" ? `message-timeline-group-${groupRole()}` : ""}`}
|
||||||
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
data-delete-hover={isDeleteHovered() || isDeleteSelected() || isSelected() ? "true" : undefined}
|
||||||
|
aria-current={isActive() ? "true" : undefined}
|
||||||
aria-current={isActive() ? "true" : undefined}
|
aria-hidden={isHidden() ? "true" : undefined}
|
||||||
aria-hidden={isHidden() ? "true" : undefined}
|
onClick={(event) => {
|
||||||
onClick={(event) => {
|
if (wasLongPress) {
|
||||||
if (wasLongPress) {
|
wasLongPress = false
|
||||||
wasLongPress = false
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture scroll anchor before selection changes may toggle
|
|
||||||
// tool segment visibility, which shifts timeline layout.
|
|
||||||
const btn = buttonRefs.get(segment.id)
|
|
||||||
let anchorOffset: number | null = null
|
|
||||||
if (btn && scrollContainerRef) {
|
|
||||||
anchorOffset = btn.offsetTop - scrollContainerRef.scrollTop
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
|
||||||
|
|
||||||
if (event.shiftKey) {
|
|
||||||
props.onSelectRange?.(segment.id)
|
|
||||||
} else if (event.ctrlKey || event.metaKey) {
|
|
||||||
props.onToggleSelection?.(segment.id)
|
|
||||||
} else if (isMultiSelectActive) {
|
|
||||||
// In selection mode, plain click scrolls to the message
|
|
||||||
// instead of clearing. Selection is cleared by clicking
|
|
||||||
// anywhere inside the chat container or pressing Esc.
|
|
||||||
props.onSegmentClick?.(segment)
|
|
||||||
} else {
|
|
||||||
props.onSegmentClick?.(segment)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore scroll anchor: keep the clicked badge at the same
|
|
||||||
// visual position after hidden tools appear or disappear.
|
|
||||||
if (anchorOffset !== null && btn && scrollContainerRef) {
|
|
||||||
const desired = btn.offsetTop - anchorOffset
|
|
||||||
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
|
||||||
scrollContainerRef.scrollTop = desired
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}}
|
const btn = buttonRefs.get(segment.id)
|
||||||
onPointerDown={(e) => handlePointerDown(segment, e)}
|
const stableBtn = renderVirtualizedTimeline() ? null : btn
|
||||||
onPointerUp={handlePointerUp}
|
let anchorOffset: number | null = null
|
||||||
onPointerCancel={handlePointerUp}
|
if (stableBtn && scrollContainerRef) {
|
||||||
onPointerMove={handlePointerMove}
|
anchorOffset = stableBtn.offsetTop - scrollContainerRef.scrollTop
|
||||||
onContextMenu={handleContextMenu}
|
}
|
||||||
onMouseEnter={(event) => handleMouseEnter(segment, event)}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
const isMultiSelectActive = (props.selectedIds?.().size ?? 0) > 0
|
||||||
>
|
|
||||||
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
|
if (event.shiftKey) {
|
||||||
<span class="message-timeline-label message-timeline-label-short">{shortLabelContent()}</span>
|
props.onSelectRange?.(segment.id)
|
||||||
</button>
|
} else if (event.ctrlKey || event.metaKey) {
|
||||||
)
|
props.onToggleSelection?.(segment.id)
|
||||||
}}
|
} else if (isMultiSelectActive) {
|
||||||
</For>
|
props.onSegmentClick?.(segment)
|
||||||
|
} else {
|
||||||
|
props.onSegmentClick?.(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorOffset !== null && stableBtn && scrollContainerRef) {
|
||||||
|
const desired = stableBtn.offsetTop - anchorOffset
|
||||||
|
if (Math.abs(scrollContainerRef.scrollTop - desired) > 1) {
|
||||||
|
scrollContainerRef.scrollTop = desired
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => handlePointerDown(segment, e)}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerUp}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onContextMenu={handleContextMenu}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Virtualizer>
|
||||||
|
</Show>
|
||||||
<Show when={previewData()}>
|
<Show when={previewData()}>
|
||||||
{(data) => {
|
{(data) => {
|
||||||
onCleanup(() => setTooltipElement(null))
|
onCleanup(() => setTooltipElement(null))
|
||||||
return (
|
return (
|
||||||
<div
|
<Portal>
|
||||||
ref={(element) => setTooltipElement(element)}
|
<div
|
||||||
class="message-timeline-tooltip"
|
ref={(element) => setTooltipElement(element)}
|
||||||
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
class="message-timeline-tooltip"
|
||||||
onMouseEnter={() => clearCloseTimer()}
|
style={{ top: `${tooltipCoords().top}px`, left: `${tooltipCoords().left}px` }}
|
||||||
onMouseLeave={() => scheduleClose()}
|
onMouseEnter={() => clearCloseTimer()}
|
||||||
>
|
onMouseLeave={() => scheduleClose()}
|
||||||
<MessagePreview
|
>
|
||||||
messageId={data().messageId}
|
<MessagePreview
|
||||||
instanceId={props.instanceId}
|
messageId={data().messageId}
|
||||||
sessionId={props.sessionId}
|
instanceId={props.instanceId}
|
||||||
store={store}
|
sessionId={props.sessionId}
|
||||||
deleteHover={props.deleteHover}
|
store={store}
|
||||||
onDeleteHoverChange={props.onDeleteHoverChange}
|
deleteHover={props.deleteHover}
|
||||||
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
onDeleteHoverChange={props.onDeleteHoverChange}
|
||||||
selectedMessageIds={props.selectedMessageIds}
|
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
|
||||||
/>
|
selectedMessageIds={props.selectedMessageIds}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
|||||||
const before = current.slice(0, start)
|
const before = current.slice(0, start)
|
||||||
const after = current.slice(end)
|
const after = current.slice(end)
|
||||||
const prefix = ""
|
const prefix = ""
|
||||||
const suffix = after.length > 0 && !after.startsWith("\n") ? "\n" : ""
|
const suffix = after.length > 0 ? (/^\s/.test(after) ? "" : " ") : " "
|
||||||
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
const nextValue = `${before}${prefix}${text}${suffix}${after}`
|
||||||
const cursor = before.length + prefix.length + text.length + suffix.length
|
const cursor = before.length + prefix.length + text.length + suffix.length
|
||||||
|
|
||||||
|
|||||||
@@ -79,11 +79,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
requestAnimationFrame(() => scrollToBottomHandle?.())
|
requestAnimationFrame(() => scrollToBottomHandle?.())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
createEffect(() => {
|
createEffect(
|
||||||
if (!props.isActive) return
|
on(
|
||||||
if (!shouldScrollToBottomOnActivate()) return
|
() => props.isActive,
|
||||||
scheduleScrollToBottom()
|
(isActive, wasActive) => {
|
||||||
})
|
if (!isActive) return
|
||||||
|
if (wasActive === true) return
|
||||||
|
if (!shouldScrollToBottomOnActivate()) return
|
||||||
|
scheduleScrollToBottom()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
@@ -332,16 +338,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
|||||||
loading={messagesLoading()}
|
loading={messagesLoading()}
|
||||||
onRevert={handleRevert}
|
onRevert={handleRevert}
|
||||||
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
|
||||||
onFork={handleFork}
|
onFork={handleFork}
|
||||||
isActive={props.isActive}
|
isActive={props.isActive}
|
||||||
registerScrollToBottom={(fn) => {
|
registerScrollToBottom={(fn) => {
|
||||||
scrollToBottomHandle = fn
|
scrollToBottomHandle = fn
|
||||||
if (props.isActive) {
|
}}
|
||||||
if (shouldScrollToBottomOnActivate()) {
|
|
||||||
scheduleScrollToBottom()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ const Field: Component<{
|
|||||||
<div class="settings-toggle-title">{props.label}</div>
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
<div class="settings-toggle-caption">{props.caption}</div>
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
|
<div class="flex items-center gap-2 w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||||
{props.icon}
|
{props.icon}
|
||||||
<input
|
<input
|
||||||
type={props.type ?? "text"}
|
type={props.type ?? "text"}
|
||||||
@@ -361,7 +361,7 @@ const SelectField: Component<{
|
|||||||
<div class="settings-toggle-title">{props.label}</div>
|
<div class="settings-toggle-title">{props.label}</div>
|
||||||
<div class="settings-toggle-caption">{props.caption}</div>
|
<div class="settings-toggle-caption">{props.caption}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-[18rem] max-w-[24rem] w-full">
|
<div class="w-full min-w-0 sm:min-w-[18rem] sm:max-w-[24rem]">
|
||||||
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
|
||||||
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ function ToolCallDetails(props: {
|
|||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (followScroll.autoScroll()) {
|
if (followScroll.autoScroll()) {
|
||||||
scrollHelpers.restoreAfterRender({ forceBottom: true })
|
scrollHelpers.restoreAfterRender()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export interface ToolScrollHelpers {
|
|||||||
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
registerContainer(element: HTMLDivElement | null, options?: { disableTracking?: boolean }): void
|
||||||
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
handleScroll(event: Event & { currentTarget: HTMLDivElement }): void
|
||||||
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
renderSentinel(options?: { disableTracking?: boolean }): JSXElement | null
|
||||||
restoreAfterRender(options?: { forceBottom?: boolean }): void
|
restoreAfterRender(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolRendererContext {
|
export interface ToolRendererContext {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Show, createEffect, createMemo, createSignal, onCleanup, type Accessor,
|
|||||||
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
|
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||||
|
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
||||||
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"])
|
||||||
|
|
||||||
@@ -85,6 +87,28 @@ export interface VirtualFollowListProps<T> {
|
|||||||
*/
|
*/
|
||||||
followToken?: Accessor<string | number>
|
followToken?: Accessor<string | number>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional item key whose geometry can temporarily hold auto-follow when the
|
||||||
|
* rendered item grows taller than the viewport and reaches the top edge.
|
||||||
|
*/
|
||||||
|
autoPinHoldTargetKey?: Accessor<string | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional resolver for the specific element inside an item wrapper that
|
||||||
|
* should be measured for hold-target geometry.
|
||||||
|
*/
|
||||||
|
resolveAutoPinHoldElement?: (itemWrapper: HTMLDivElement, key: string) => HTMLElement | null | undefined
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-edge threshold for the hold target in pixels.
|
||||||
|
*/
|
||||||
|
autoPinHoldTopThresholdPx?: number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily suppress automatic bottom pinning while keeping follow mode enabled.
|
||||||
|
*/
|
||||||
|
suspendAutoPinToBottom?: Accessor<boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional hooks to render content inside the scroll container.
|
* Optional hooks to render content inside the scroll container.
|
||||||
* Useful for empty/loading states that should scroll with the list.
|
* Useful for empty/loading states that should scroll with the list.
|
||||||
@@ -130,13 +154,19 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
const scrollToBottomOnActivate = () => (props.scrollToBottomOnActivate ? props.scrollToBottomOnActivate() : true)
|
||||||
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
const initialScrollToBottom = () => (props.initialScrollToBottom ? props.initialScrollToBottom() : true)
|
||||||
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
const initialAutoScroll = () => (props.initialAutoScroll ? props.initialAutoScroll() : true)
|
||||||
|
const externalSuspendAutoPinToBottom = () => (props.suspendAutoPinToBottom ? props.suspendAutoPinToBottom() : false)
|
||||||
|
const holdTargetKey = () => (props.autoPinHoldTargetKey ? props.autoPinHoldTargetKey() : null)
|
||||||
|
const holdTargetTopThresholdPx = () => props.autoPinHoldTopThresholdPx ?? DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX
|
||||||
|
|
||||||
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
const [autoScroll, setAutoScroll] = createSignal(Boolean(initialAutoScroll()))
|
||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||||
|
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
|
||||||
|
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
|
||||||
|
|
||||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
|
const itemElements = new Map<string, HTMLDivElement>()
|
||||||
|
|
||||||
let userScrollIntentUntil = 0
|
let userScrollIntentUntil = 0
|
||||||
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
let lastUserScrollIntentDirection: "up" | "down" | null = null
|
||||||
@@ -220,6 +250,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
|
if (atBottom && heldItemCount() !== null) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
}
|
||||||
if (atBottom && !autoScroll()) {
|
if (atBottom && !autoScroll()) {
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
} else if (!atBottom && autoScroll()) {
|
} else if (!atBottom && autoScroll()) {
|
||||||
@@ -253,6 +286,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateScrollButtons()
|
updateScrollButtons()
|
||||||
|
updateAutoPinHold()
|
||||||
props.onScroll?.()
|
props.onScroll?.()
|
||||||
|
|
||||||
// Find active key (roughly the first visible item)
|
// Find active key (roughly the first visible item)
|
||||||
@@ -270,6 +304,68 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerItemElement(key: string, element: HTMLDivElement | null | undefined) {
|
||||||
|
if (!element) {
|
||||||
|
itemElements.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
itemElements.set(key, element)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnchorIdForKey(key: string) {
|
||||||
|
return props.getAnchorId ? props.getAnchorId(key) : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAutoPinHold() {
|
||||||
|
const element = scrollElement()
|
||||||
|
const itemCount = props.items().length
|
||||||
|
const heldCount = heldItemCount()
|
||||||
|
if (!element) return
|
||||||
|
|
||||||
|
if (heldCount !== null) {
|
||||||
|
if (itemCount > heldCount) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
if (autoScroll()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!autoScroll()) return
|
||||||
|
scrollToBottom(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemCount < heldCount) {
|
||||||
|
setHeldItemCount(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!autoScroll()) return
|
||||||
|
if (externalSuspendAutoPinToBottom()) return
|
||||||
|
|
||||||
|
const targetKey = holdTargetKey()
|
||||||
|
if (!targetKey) return
|
||||||
|
|
||||||
|
const itemWrapper = itemElements.get(targetKey)
|
||||||
|
if (!itemWrapper) return
|
||||||
|
const target = props.resolveAutoPinHoldElement?.(itemWrapper, targetKey) ?? itemWrapper
|
||||||
|
|
||||||
|
const containerRect = element.getBoundingClientRect()
|
||||||
|
const targetRect = target.getBoundingClientRect()
|
||||||
|
const relativeTop = targetRect.top - containerRect.top
|
||||||
|
const exceedsViewport = targetRect.height > element.clientHeight
|
||||||
|
|
||||||
|
if (
|
||||||
|
exceedsViewport &&
|
||||||
|
relativeTop <= holdTargetTopThresholdPx() &&
|
||||||
|
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
||||||
|
) {
|
||||||
|
setHeldItemCount(itemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const api: VirtualFollowListApi = {
|
const api: VirtualFollowListApi = {
|
||||||
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
scrollToTop: (opts) => scrollToTop(opts?.immediate ?? true),
|
||||||
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
scrollToBottom: (opts) => scrollToBottom(opts?.immediate ?? true, { suppressAutoAnchor: opts?.suppressAutoAnchor }),
|
||||||
@@ -281,7 +377,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
virtuaHandle()?.scrollToIndex(index, { align: opts?.block ?? "start", smooth: opts?.behavior === "smooth" })
|
||||||
},
|
},
|
||||||
notifyContentRendered: () => {
|
notifyContentRendered: () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
if (heldItemCount() !== null) return
|
||||||
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -294,9 +392,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
createEffect(() => props.registerApi?.(api))
|
createEffect(() => props.registerApi?.(api))
|
||||||
createEffect(() => props.registerState?.(state))
|
createEffect(() => props.registerState?.(state))
|
||||||
|
|
||||||
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
|
itemElements.clear()
|
||||||
|
setHeldItemCount(null)
|
||||||
|
}))
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
// Handle autoScroll (Follow) on items change
|
||||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !suppressAutoScrollOnce) {
|
updateAutoPinHold()
|
||||||
|
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
}
|
}
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
@@ -304,11 +408,16 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Handle followToken change
|
// Handle followToken change
|
||||||
createEffect(on(() => props.followToken?.(), () => {
|
createEffect(on(() => props.followToken?.(), () => {
|
||||||
if (autoScroll()) {
|
updateAutoPinHold()
|
||||||
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
}, { defer: true }))
|
}, { defer: true }))
|
||||||
|
|
||||||
|
createEffect(on(() => holdTargetKey(), () => {
|
||||||
|
updateAutoPinHold()
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
// Reset state on resetKey change
|
// Reset state on resetKey change
|
||||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||||
if (nextKey === lastResetKey) return
|
if (nextKey === lastResetKey) return
|
||||||
@@ -331,6 +440,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
const handleResize = () => updateAutoPinHold()
|
||||||
|
window.addEventListener("resize", handleResize)
|
||||||
|
onCleanup(() => window.removeEventListener("resize", handleResize))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||||
setShellElement(shellElement)
|
setShellElement(shellElement)
|
||||||
@@ -356,7 +472,15 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
bufferSize={props.overscanPx ?? 400}
|
bufferSize={props.overscanPx ?? 400}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{(item, index) => props.renderItem(item, index())}
|
{(item, index) => {
|
||||||
|
const key = props.getKey(item, index())
|
||||||
|
const anchorId = getAnchorIdForKey(key)
|
||||||
|
return (
|
||||||
|
<div id={anchorId} data-virtual-follow-key={key} ref={(element) => registerItemElement(key, element)}>
|
||||||
|
{props.renderItem(item, index())}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export interface FollowScrollHelpers {
|
|||||||
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
registerContainer: (element: HTMLDivElement | null | undefined, options?: { disableTracking?: boolean }) => void
|
||||||
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
handleScroll: (event: Event & { currentTarget: HTMLDivElement }) => void
|
||||||
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
renderSentinel: (options?: { disableTracking?: boolean }) => JSXElement | null
|
||||||
restoreAfterRender: (options?: { forceBottom?: boolean }) => void
|
restoreAfterRender: () => void
|
||||||
autoScroll: Accessor<boolean>
|
autoScroll: Accessor<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||||||
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
return <div ref={setBottomSentinel} aria-hidden="true" class={options.sentinelClassName} style={{ height: "1px" }} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoreAfterRender = (config?: { forceBottom?: boolean }) => {
|
const restoreAfterRender = () => {
|
||||||
const container = scrollContainerRef
|
const container = scrollContainerRef
|
||||||
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
if (container && hasUserScrollIntent() && !isAtBottom(container)) {
|
||||||
if (autoScroll()) {
|
if (autoScroll()) {
|
||||||
@@ -195,7 +195,10 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldFollow = config?.forceBottom ?? autoScroll()
|
// Never let a render-time caller force follow mode back on after the user
|
||||||
|
// has already escaped it. Staying pinned should depend on the current
|
||||||
|
// follow state, not on a caller opting into forceBottom.
|
||||||
|
const shouldFollow = autoScroll()
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
restoreScrollPosition(shouldFollow)
|
restoreScrollPosition(shouldFollow)
|
||||||
if (shouldFollow) {
|
if (shouldFollow) {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Loading messages...",
|
"messageSection.loading.messages": "Loading messages...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
"messageSection.scroll.toFirstAriaLabel": "Scroll to first message",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
"messageSection.scroll.toLatestAriaLabel": "Scroll to latest message",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Enable hold for long assistant replies",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Disable hold for long assistant replies",
|
||||||
"messageSection.quote.addAsQuote": "Add as quote",
|
"messageSection.quote.addAsQuote": "Add as quote",
|
||||||
"messageSection.quote.addAsCode": "Add as code",
|
"messageSection.quote.addAsCode": "Add as code",
|
||||||
"messageSection.quote.copy": "Copy",
|
"messageSection.quote.copy": "Copy",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Cargando mensajes...",
|
"messageSection.loading.messages": "Cargando mensajes...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
"messageSection.scroll.toFirstAriaLabel": "Desplazarse al primer mensaje",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
"messageSection.scroll.toLatestAriaLabel": "Desplazarse al último mensaje",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Activar pausa para respuestas largas del asistente",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Desactivar pausa para respuestas largas del asistente",
|
||||||
"messageSection.quote.addAsQuote": "Añadir como cita",
|
"messageSection.quote.addAsQuote": "Añadir como cita",
|
||||||
"messageSection.quote.addAsCode": "Añadir como código",
|
"messageSection.quote.addAsCode": "Añadir como código",
|
||||||
"messageSection.quote.copy": "Copiar",
|
"messageSection.quote.copy": "Copiar",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Chargement des messages...",
|
"messageSection.loading.messages": "Chargement des messages...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
|
"messageSection.scroll.toFirstAriaLabel": "Aller au premier message",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
"messageSection.scroll.toLatestAriaLabel": "Aller au dernier message",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Activer le maintien pour les longues réponses de l'assistant",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Désactiver le maintien pour les longues réponses de l'assistant",
|
||||||
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
"messageSection.quote.addAsQuote": "Ajouter en citation",
|
||||||
"messageSection.quote.addAsCode": "Ajouter en code",
|
"messageSection.quote.addAsCode": "Ajouter en code",
|
||||||
"messageSection.quote.copy": "Copier",
|
"messageSection.quote.copy": "Copier",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "טוען הודעות...",
|
"messageSection.loading.messages": "טוען הודעות...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
|
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
|
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "הפעל עצירה לתגובות עוזר ארוכות",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "כבה עצירה לתגובות עוזר ארוכות",
|
||||||
"messageSection.quote.addAsQuote": "הוסף כציטוט",
|
"messageSection.quote.addAsQuote": "הוסף כציטוט",
|
||||||
"messageSection.quote.addAsCode": "הוסף כקוד",
|
"messageSection.quote.addAsCode": "הוסף כקוד",
|
||||||
"messageSection.quote.copy": "העתק",
|
"messageSection.quote.copy": "העתק",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "メッセージを読み込み中...",
|
"messageSection.loading.messages": "メッセージを読み込み中...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
|
"messageSection.scroll.toFirstAriaLabel": "最初のメッセージへスクロール",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
"messageSection.scroll.toLatestAriaLabel": "最新のメッセージへスクロール",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "長いアシスタント返信の保持を有効にする",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "長いアシスタント返信の保持を無効にする",
|
||||||
"messageSection.quote.addAsQuote": "引用として追加",
|
"messageSection.quote.addAsQuote": "引用として追加",
|
||||||
"messageSection.quote.addAsCode": "コードとして追加",
|
"messageSection.quote.addAsCode": "コードとして追加",
|
||||||
"messageSection.quote.copy": "コピー",
|
"messageSection.quote.copy": "コピー",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "Загрузка сообщений…",
|
"messageSection.loading.messages": "Загрузка сообщений…",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
|
"messageSection.scroll.toFirstAriaLabel": "Прокрутить к первому сообщению",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
"messageSection.scroll.toLatestAriaLabel": "Прокрутить к последнему сообщению",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "Включить удержание для длинных ответов ассистента",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "Выключить удержание для длинных ответов ассистента",
|
||||||
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
"messageSection.quote.addAsQuote": "Добавить как цитату",
|
||||||
"messageSection.quote.addAsCode": "Добавить как код",
|
"messageSection.quote.addAsCode": "Добавить как код",
|
||||||
"messageSection.quote.copy": "Копировать",
|
"messageSection.quote.copy": "Копировать",
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const messagingMessages = {
|
|||||||
"messageSection.loading.messages": "正在加载消息...",
|
"messageSection.loading.messages": "正在加载消息...",
|
||||||
"messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息",
|
"messageSection.scroll.toFirstAriaLabel": "滚动到第一条消息",
|
||||||
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
"messageSection.scroll.toLatestAriaLabel": "滚动到最新消息",
|
||||||
|
"messageSection.scroll.enableHoldAriaLabel": "启用长助手回复保持",
|
||||||
|
"messageSection.scroll.disableHoldAriaLabel": "禁用长助手回复保持",
|
||||||
"messageSection.quote.addAsQuote": "作为引用添加",
|
"messageSection.quote.addAsQuote": "作为引用添加",
|
||||||
"messageSection.quote.addAsCode": "作为代码添加",
|
"messageSection.quote.addAsCode": "作为代码添加",
|
||||||
"messageSection.quote.copy": "复制",
|
"messageSection.quote.copy": "复制",
|
||||||
|
|||||||
@@ -116,18 +116,11 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
|
|||||||
// Prefer explicit input limits when provided by the API.
|
// Prefer explicit input limits when provided by the API.
|
||||||
// This is used by the UI "Avail" chip.
|
// This is used by the UI "Avail" chip.
|
||||||
contextAvailableTokens = modelInputLimit
|
contextAvailableTokens = modelInputLimit
|
||||||
}
|
} else if (contextWindow > 0) {
|
||||||
|
// When no explicit input limit, show full context window capacity.
|
||||||
if (!contextAvailableFromPrevious && contextAvailableTokens === null) {
|
contextAvailableTokens = contextWindow
|
||||||
if (contextWindow > 0) {
|
} else {
|
||||||
if (latestHasContextUsage && actualUsageTokens > 0) {
|
contextAvailableTokens = null
|
||||||
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
|
|
||||||
} else {
|
|
||||||
contextAvailableTokens = contextWindow
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contextAvailableTokens = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface UiSettings {
|
|||||||
showKeyboardShortcutHints: boolean
|
showKeyboardShortcutHints: boolean
|
||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
showTimelineTools: boolean
|
showTimelineTools: boolean
|
||||||
|
holdLongAssistantReplies: boolean
|
||||||
promptSubmitOnEnter: boolean
|
promptSubmitOnEnter: boolean
|
||||||
showPromptVoiceInput: boolean
|
showPromptVoiceInput: boolean
|
||||||
locale?: string
|
locale?: string
|
||||||
@@ -133,6 +134,7 @@ const defaultUiSettings: UiSettings = {
|
|||||||
showKeyboardShortcutHints: true,
|
showKeyboardShortcutHints: true,
|
||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
showTimelineTools: true,
|
showTimelineTools: true,
|
||||||
|
holdLongAssistantReplies: true,
|
||||||
promptSubmitOnEnter: false,
|
promptSubmitOnEnter: false,
|
||||||
showPromptVoiceInput: true,
|
showPromptVoiceInput: true,
|
||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
@@ -166,6 +168,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||||||
sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints,
|
sanitized.showKeyboardShortcutHints ?? defaultUiSettings.showKeyboardShortcutHints,
|
||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
||||||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||||
|
holdLongAssistantReplies: sanitized.holdLongAssistantReplies ?? defaultUiSettings.holdLongAssistantReplies,
|
||||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||||
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||||
|
|||||||
@@ -526,14 +526,49 @@
|
|||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.settings-screen-frame {
|
.settings-screen-frame {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-surface.settings-screen-shell {
|
.modal-surface.settings-screen-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-surface.settings-screen-shell .settings-screen-nav,
|
||||||
|
.modal-surface.settings-screen-shell .settings-screen-nav-list,
|
||||||
|
.modal-surface.settings-screen-shell .settings-screen-content,
|
||||||
|
.modal-surface.settings-screen-shell .settings-screen-scroll,
|
||||||
|
.modal-surface.settings-screen-shell .settings-section-stack,
|
||||||
|
.modal-surface.settings-screen-shell .settings-stack,
|
||||||
|
.modal-surface.settings-screen-shell .settings-card,
|
||||||
|
.modal-surface.settings-screen-shell .settings-card-content,
|
||||||
|
.modal-surface.settings-screen-shell .settings-toggle-row,
|
||||||
|
.modal-surface.settings-screen-shell .settings-toggle-row > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-surface.settings-screen-shell .selector-trigger,
|
||||||
|
.modal-surface.settings-screen-shell .selector-input,
|
||||||
|
.modal-surface.settings-screen-shell .selector-button {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-surface.settings-screen-shell .settings-toggle-caption,
|
||||||
|
.modal-surface.settings-screen-shell .settings-inline-note,
|
||||||
|
.modal-surface.settings-screen-shell .remote-address-url,
|
||||||
|
.modal-surface.settings-screen-shell code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-surface.settings-screen-shell .whitespace-nowrap {
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-screen-content-header,
|
.settings-screen-content-header,
|
||||||
|
|||||||
@@ -242,6 +242,10 @@
|
|||||||
color: var(--accent-primary);
|
color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-scroll-button[data-active="false"] .message-scroll-icon--toggle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.message-quote-popover {
|
.message-quote-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
|
|||||||
@@ -66,10 +66,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.message-timeline {
|
.message-timeline {
|
||||||
|
--message-timeline-segment-gap: 0.35rem;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0;
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
@@ -114,6 +115,17 @@
|
|||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-timeline-item-spacer {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.message-timeline-segment[data-delete-hover="true"]::before {
|
.message-timeline-segment[data-delete-hover="true"]::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -319,18 +331,7 @@
|
|||||||
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
border-inline-start: 3px solid color-mix(in oklab, var(--accent-primary) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Extra spacing before the first tool in a group to separate from the
|
/* Spacing is rendered by the measured item wrapper so virtua can account for it. */
|
||||||
preceding user/assistant badge. */
|
|
||||||
.message-timeline-group-start {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle extra spacing after the group parent (assistant) to separate
|
|
||||||
from the next user badge below. Uses adjacent sibling targeting. */
|
|
||||||
.message-timeline-group-parent + .message-timeline-user,
|
|
||||||
.message-timeline-group-parent + .message-timeline-compaction {
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-timeline-container {
|
.message-timeline-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
Reference in New Issue
Block a user