Compare commits

...

37 Commits

Author SHA1 Message Date
Shantur Rathore
1a1aee8f91 Group message action buttons 2025-12-01 23:42:01 +00:00
Shantur Rathore
5384ff8e80 Merge message parts props 2025-12-01 23:20:44 +00:00
Shantur Rathore
6d5836ce1f Always render step usage chips 2025-12-01 23:12:31 +00:00
Shantur Rathore
d3dc170e02 Lazy render tool-call bodies 2025-12-01 23:09:22 +00:00
Shantur Rathore
983c8cc4a3 Streamline tool-call header DOM 2025-12-01 22:48:22 +00:00
Shantur Rathore
757c587b17 Simplify message header markup 2025-12-01 22:41:30 +00:00
Shantur Rathore
5f9cf397b9 Reduce step-finish usage chip DOM 2025-12-01 22:36:03 +00:00
Shantur Rathore
78ab17d148 bump version to 0.2.7 2025-12-01 19:54:12 +00:00
Shantur Rathore
e91923ad99 Improve message stream auto-scroll during streaming 2025-12-01 19:46:16 +00:00
Shantur Rathore
fd23ea54b6 Simplify message cache pruning and diff/markdown initialization 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e7969eaba Localize ToolCall expansion and diagnostics state 2025-12-01 19:46:16 +00:00
Shantur Rathore
77bfe41a8e Reduce message cloning and gate scroll work on load 2025-12-01 19:46:16 +00:00
Shantur Rathore
6d134e4dec Render stream with Index to limit churn on inserts/removes 2025-12-01 19:46:16 +00:00
Shantur Rathore
9423326193 Make MessageBlock rerender via keyed Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
c5011e4ece Make message blocks reactive to session signals 2025-12-01 19:46:16 +00:00
Shantur Rathore
66c270151a Stream change token and scroll checks use ids only 2025-12-01 19:46:16 +00:00
Shantur Rathore
5ce41217e9 Make MessageBlock output reactive via Show 2025-12-01 19:46:16 +00:00
Shantur Rathore
1e4d949d35 Add message stream debug logging 2025-12-01 19:46:16 +00:00
Shantur Rathore
6bb9e8e414 Render message stream per message id 2025-12-01 19:46:16 +00:00
Shantur Rathore
1efc49b67b Prune reverted messages from session store 2025-12-01 19:46:16 +00:00
Shantur Rathore
f0ed98222a Skip reverted messages from display caches 2025-12-01 19:46:16 +00:00
Shantur Rathore
ddd8ce341a Add diff viewer fallback and extract message block 2025-12-01 19:46:16 +00:00
Shantur Rathore
b7721ba3e7 Merge pull request #13 from alexispurslane/blank-session-cleanup
Blank session cleanup
2025-12-01 19:45:09 +00:00
Alexis Purslane
0554018980 update message 2025-12-01 12:37:22 -05:00
Alexis Purslane
ca18942bfd confirmation dialogue 2025-12-01 11:43:04 -05:00
Alexis Purslane
c9c1f69b82 further improvements 2025-12-01 11:35:03 -05:00
Alexis Purslane
aa0c31fa1e improve PR 2025-11-29 21:44:15 -05:00
Alexis Purslane
96b88dbcdc update blank session cleanup code for now session store logic 2025-11-27 20:18:22 -05:00
Alexis Dumas
50676416ed blank session cleanup improvements
- make the blank session cleanup system optionally fetch full message histories for each session to better judge if it's blank
- make a command that does the deep clean, keep the clean that happens on new session creation shallow
2025-11-27 18:18:24 -05:00
Alexis Purslane
f633d75005 Blank session cleanup 2025-11-27 18:18:24 -05:00
Shantur Rathore
4085f6d6b9 Avoid npm ci pruning during prebuild 2025-11-27 20:40:21 +00:00
Shantur Rathore
ae288833e1 Install server deps before build in prebuild 2025-11-27 20:29:08 +00:00
Shantur Rathore
f16e244265 Handle rollup platform binaries in prebuild 2025-11-27 20:25:04 +00:00
Shantur Rathore
b6e43c899b Improve dialog text wrapping and sizing 2025-11-27 20:11:38 +00:00
Shantur Rathore
9fa436b0b8 Ensure rollup linux binary in tauri prebuild 2025-11-27 20:11:02 +00:00
Shantur Rathore
ccd65fbc74 publish-server after build in dev 2025-11-27 19:48:35 +00:00
Shantur Rathore
daa7e3a6d1 Only publish server after successful builds 2025-11-27 19:45:58 +00:00
33 changed files with 1179 additions and 731 deletions

View File

@@ -57,7 +57,9 @@ jobs:
secrets: inherit
publish-server:
needs: prepare-dev
needs:
- prepare-dev
- build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-dev.outputs.version }}

View File

@@ -77,6 +77,7 @@ jobs:
needs:
- prepare-release
- build-and-upload
if: ${{ needs.build-and-upload.result == 'success' }}
uses: ./.github/workflows/manual-npm-publish.yml
with:
version: ${{ needs.prepare-release.outputs.version }}

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ release/
.vite/
.electron-vite/
out/
.dir-locals.el

82
dev-docs/solidjs-llms.txt Normal file
View File

@@ -0,0 +1,82 @@
# SolidJS Documentation
> Solid is a modern JavaScript framework for building user interfaces with fine-grained reactivity. It compiles JSX to real DOM elements and updates only what changes, delivering exceptional performance without a virtual DOM. Solid provides reactive primitives like signals, effects, and stores for predictable state management.
SolidJS is a declarative JavaScript framework that prioritizes performance and developer experience. Unlike frameworks that re-run components on every update, Solid components run once during initialization and set up a reactive system that precisely updates the DOM when dependencies change.
Key principles:
- Fine-grained reactivity: Updates only the specific DOM nodes that depend on changed data
- Compile-time optimization: JSX transforms into efficient DOM operations
- Unidirectional data flow: Props are read-only, promoting predictable state management
- Component lifecycle: Components run once, with reactive primitives handling updates
**Use your web fetch tool on any of the following links to understand the relevant concept**.
## Quick Start
- [Overview](https://docs.solidjs.com/): Framework introduction and key advantages
- [Quick Start](https://docs.solidjs.com/quick-start): Installation and project setup with create-solid
- [Interactive Tutorial](https://www.solidjs.com/tutorial/introduction_basics): Learn Solid basics through guided examples
- [Playground](https://playground.solidjs.com/): Experiment with Solid directly in your browser
## Core Concepts
- [Intro to Reactivity](https://docs.solidjs.com/concepts/intro-to-reactivity): Signals, subscribers, and reactive principles
- [Understanding JSX](https://docs.solidjs.com/concepts/understanding-jsx): How Solid uses JSX and key differences from HTML
- [Components Basics](https://docs.solidjs.com/concepts/components/basics): Component trees, lifecycles, and composition patterns
- [Signals](https://docs.solidjs.com/concepts/signals): Core reactive primitive for state management with getters/setters
- [Effects](https://docs.solidjs.com/concepts/effects): Side effects, dependency tracking, and lifecycle functions
- [Stores](https://docs.solidjs.com/concepts/stores): Complex state management with proxy-based reactivity
- [Context](https://docs.solidjs.com/concepts/context): Cross-component state sharing without prop drilling
## Component APIs
- [Props](https://docs.solidjs.com/concepts/components/props): Passing data and handlers to child components
- [Event Handlers](https://docs.solidjs.com/concepts/components/event-handlers): Managing user interactions
- [Class and Style](https://docs.solidjs.com/concepts/components/class-style): Dynamic styling approaches
- [Refs](https://docs.solidjs.com/concepts/refs): Accessing DOM elements directly
## Control Flow
- [Conditional Rendering](https://docs.solidjs.com/concepts/control-flow/conditional-rendering): Show, Switch, and Match components
- [List Rendering](https://docs.solidjs.com/concepts/control-flow/list-rendering): For, Index, and keyed iteration
- [Dynamic](https://docs.solidjs.com/concepts/control-flow/dynamic): Dynamic component switching
- [Portal](https://docs.solidjs.com/concepts/control-flow/portal): Rendering outside component hierarchy
- [Error Boundary](https://docs.solidjs.com/concepts/control-flow/error-boundary): Graceful error handling
## Derived Values
- [Derived Signals](https://docs.solidjs.com/concepts/derived-values/derived-signals): Computed values from signals
- [Memos](https://docs.solidjs.com/concepts/derived-values/memos): Cached computed values for performance
## State Management
- [Basic State Management](https://docs.solidjs.com/guides/state-management): One-way data flow and lifting state
- [Complex State Management](https://docs.solidjs.com/guides/complex-state-management): Stores for scalable applications
- [Fetching Data](https://docs.solidjs.com/guides/fetching-data): Async data with createResource
## Routing
- [Routing & Navigation](https://docs.solidjs.com/guides/routing-and-navigation): @solidjs/router setup and usage
- [Dynamic Routes](https://docs.solidjs.com/guides/routing-and-navigation#dynamic-routes): Route parameters and validation
- [Nested Routes](https://docs.solidjs.com/guides/routing-and-navigation#nested-routes): Hierarchical route structures
- [Preload Functions](https://docs.solidjs.com/guides/routing-and-navigation#preload-functions): Parallel data fetching
## Advanced Topics
- [Fine-Grained Reactivity](https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity): Deep dive into reactive system
- [TypeScript](https://docs.solidjs.com/configuration/typescript): Type safety and configuration
## Ecosystem
- [Solid Router](https://docs.solidjs.com/solid-router/): File-system routing and data APIs
- [SolidStart](https://docs.solidjs.com/solid-start/): Full-stack meta-framework
- [Solid Meta](https://docs.solidjs.com/solid-meta/): Document head management
- [Templates](https://github.com/solidjs/templates): Starter templates for different setups
## Optional
- [Ecosystem Libraries](https://www.solidjs.com/ecosystem): Community packages and tools
- [API Reference](https://docs.solidjs.com/reference/): Complete API documentation
- [Testing](https://docs.solidjs.com/guides/testing): Testing strategies and utilities
- [Deployment](https://docs.solidjs.com/guides/deploying-your-app): Build and deployment options

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"7zip-bin": "^5.2.0",
"google-auth-library": "^10.5.0"
@@ -8613,7 +8613,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"@codenomad/ui": "file:../ui",
"@neuralnomads/codenomad": "file:../server"
@@ -8641,7 +8641,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",
@@ -8680,14 +8680,14 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.2.6",
"version": "0.2.7",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
}
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.2.6",
"version": "0.2.7",
"private": true,
"description": "CodeNomad monorepo workspace",
"workspaces": {

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.2.6",
"version": "0.2.7",
"description": "CodeNomad - AI coding assistant",
"author": {
"name": "Neural Nomads",

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.6",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.2.6",
"version": "0.2.7",
"dependencies": {
"@fastify/cors": "^8.5.0",
"commander": "^12.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.2.6",
"version": "0.2.7",
"description": "CodeNomad Server",
"author": {
"name": "Neural Nomads",

View File

@@ -18,6 +18,7 @@ const PreferencesSchema = z.object({
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
showUsageMetrics: z.boolean().default(true),
autoCleanupBlankSessions: z.boolean().default(true),
})
const RecentFolderSchema = z.object({

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.2.6",
"version": "0.2.7",
"private": true,
"scripts": {
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",

View File

@@ -16,9 +16,9 @@ const sources = ["dist", "public", "node_modules", "package.json"]
const serverInstallCommand =
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
const serverDevInstallCommand =
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
"npm install --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const uiDevInstallCommand =
"npm ci --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
"npm install --workspace @codenomad/ui --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
const envWithRootBin = {
...process.env,
@@ -114,6 +114,42 @@ function ensureUiDevDependencies() {
})
}
function ensureRollupPlatformBinary() {
const platformKey = `${process.platform}-${process.arch}`
const platformPackages = {
"linux-x64": "@rollup/rollup-linux-x64-gnu",
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
"darwin-arm64": "@rollup/rollup-darwin-arm64",
"darwin-x64": "@rollup/rollup-darwin-x64",
"win32-x64": "@rollup/rollup-win32-x64-msvc",
}
const pkgName = platformPackages[platformKey]
if (!pkgName) {
return
}
const platformPackagePath = path.join(workspaceRoot, "node_modules", "@rollup", pkgName.split("/").pop())
if (fs.existsSync(platformPackagePath)) {
return
}
let rollupVersion = ""
try {
rollupVersion = require(path.join(workspaceRoot, "node_modules", "rollup", "package.json")).version
} catch (error) {
// leave version empty; fallback install will use latest compatible
}
const packageSpec = rollupVersion ? `${pkgName}@${rollupVersion}` : pkgName
console.log("[prebuild] installing rollup platform binary (optional dep workaround)...")
execSync(`npm install ${packageSpec} --no-save --ignore-scripts --fund=false --audit=false`, {
cwd: workspaceRoot,
stdio: "inherit",
})
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
@@ -151,8 +187,9 @@ function copyUiLoadingAssets() {
ensureServerDevDependencies()
ensureUiDevDependencies()
ensureRollupPlatformBinary()
ensureServerDependencies()
ensureServerBuild()
ensureUiBuild()
ensureServerDependencies()
copyServerArtifacts()
copyUiLoadingAssets()

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.2.6",
"version": "0.2.7",
"private": true,
"type": "module",
"scripts": {

View File

@@ -47,6 +47,7 @@ const App: Component = () => {
preferences,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
setDiffViewMode,
setToolOutputExpansion,
@@ -206,6 +207,7 @@ const App: Component = () => {
const { commands: paletteCommands, executeCommand } = useCommands({
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleUsageMetrics,
setDiffViewMode,
@@ -248,7 +250,7 @@ const App: Component = () => {
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Unable to launch OpenCode</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
Install the OpenCode CLI and make sure it is available in your PATH, or pick a custom binary from
Advanced Settings.
</Dialog.Description>

View File

@@ -89,9 +89,9 @@ const AlertDialog: Component = () => {
>
{accent.symbol}
</div>
<div class="flex-1">
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line">
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>

View File

@@ -1,6 +1,7 @@
import { createMemo, Show, createEffect, onCleanup } from "solid-js"
import { DiffView, DiffModeEnum } from "@git-diff-view/solid"
import type { DiffHighlighterLang } from "@git-diff-view/core"
import { ErrorBoundary } from "solid-js"
import { getLanguageFromPath } from "../lib/markdown"
import { normalizeDiffText } from "../lib/diff-utils"
import { setCacheEntry } from "../lib/global-cache"
@@ -36,10 +37,10 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
if (!normalized) {
return null
}
const language = getLanguageFromPath(props.filePath) || "text"
const fileName = props.filePath || "diff"
return {
oldFile: {
fileName,
@@ -52,96 +53,47 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
hunks: [normalized],
}
})
let diffContainerRef: HTMLDivElement | undefined
let pendingCapture: number | undefined
let pendingContext: CaptureContext | undefined
let lastRenderedMarkup: string | undefined
let lastCachedHtml: string | undefined
const clearPendingCapture = () => {
if (pendingCapture !== undefined) {
cancelAnimationFrame(pendingCapture)
pendingCapture = undefined
}
pendingContext = undefined
}
const runCapture = (context: CaptureContext) => {
if (!diffContainerRef) {
props.onRendered?.()
return
}
const markup = diffContainerRef.innerHTML
if (!markup) {
props.onRendered?.()
return
}
const hasChanged = markup !== lastRenderedMarkup
if (hasChanged) {
lastRenderedMarkup = markup
if (context.cacheEntryParams) {
setCacheEntry(context.cacheEntryParams, {
text: context.diffText,
html: markup,
theme: context.theme,
mode: context.mode,
})
}
}
props.onRendered?.()
}
const scheduleCapture = (context: CaptureContext) => {
clearPendingCapture()
pendingContext = context
pendingCapture = requestAnimationFrame(() => {
const activeContext = pendingContext
pendingContext = undefined
pendingCapture = undefined
if (activeContext) {
runCapture(activeContext)
}
})
}
let lastCapturedKey: string | undefined
const contextKey = createMemo(() => {
const data = diffData()
if (!data) return ""
return `${props.theme}|${props.mode}|${props.diffText}`
})
createEffect(() => {
const cachedHtml = props.cachedHtml
if (cachedHtml) {
clearPendingCapture()
if (cachedHtml !== lastCachedHtml) {
lastCachedHtml = cachedHtml
lastRenderedMarkup = cachedHtml
props.onRendered?.()
// When we are given cached HTML, we rely on the caller's cache
// and simply notify once rendered.
props.onRendered?.()
return
}
const key = contextKey()
if (!key) return
if (!diffContainerRef) return
if (lastCapturedKey === key) return
requestAnimationFrame(() => {
if (!diffContainerRef) return
const markup = diffContainerRef.innerHTML
if (!markup) return
lastCapturedKey = key
if (props.cacheEntryParams) {
setCacheEntry(props.cacheEntryParams, {
text: props.diffText,
html: markup,
theme: props.theme,
mode: props.mode,
})
}
return
}
lastCachedHtml = undefined
const data = diffData()
const theme = props.theme
const mode = props.mode
if (!data) {
clearPendingCapture()
return
}
scheduleCapture({
theme,
mode,
diffText: props.diffText,
cacheEntryParams: props.cacheEntryParams,
props.onRendered?.()
})
})
onCleanup(() => {
clearPendingCapture()
})
return (
<div class="tool-call-diff-viewer">
@@ -154,14 +106,19 @@ export function ToolCallDiffViewer(props: ToolCallDiffViewerProps) {
fallback={<pre class="tool-call-diff-fallback">{props.diffText}</pre>}
>
{(data) => (
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
<ErrorBoundary fallback={(error) => {
console.warn("Failed to render diff view", error)
return <pre class="tool-call-diff-fallback">{props.diffText}</pre>
}}>
<DiffView
data={data()}
diffViewMode={props.mode === "split" ? DiffModeEnum.Split : DiffModeEnum.Unified}
diffViewTheme={props.theme}
diffViewHighlight
diffViewWrap={false}
diffViewFontSize={13}
/>
</ErrorBoundary>
)}
</Show>
</div>

View File

@@ -19,7 +19,7 @@ export default function InstanceDisconnectedModal(props: InstanceDisconnectedMod
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-6">
<div>
<Dialog.Title class="text-xl font-semibold text-primary">Instance Disconnected</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-2">
<Dialog.Description class="text-sm text-secondary mt-2 break-words">
{folderLabel} can no longer be reached. Close the tab to continue working.
</Dialog.Description>
</div>

View File

@@ -29,7 +29,8 @@ export function Markdown(props: MarkdownProps) {
latestRequestedText = text
await initMarkdown(dark)
// Markdown initialization is now handled globally in App.
// initMarkdown is idempotent but we avoid per-part calls here.
if (!highlightEnabled) {
part.renderCache = undefined

View File

@@ -10,31 +10,36 @@ interface MessageItemProps {
instanceId: string
sessionId: string
isQueued?: boolean
combinedParts: ClientPart[]
orderedParts: ClientPart[]
parts: ClientPart[]
onRevert?: (messageId: string) => void
onFork?: (messageId?: string) => void
showAgentMeta?: boolean
}
onContentRendered?: () => void
}
export default function MessageItem(props: MessageItemProps) {
export default function MessageItem(props: MessageItemProps) {
const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
const timestamp = () => {
const createdTime = props.messageInfo?.time?.created ?? props.record.createdAt
const date = new Date(createdTime)
const date = new Date(createdTimestamp())
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
const timestampIso = () => new Date(createdTimestamp()).toISOString()
type FilePart = Extract<ClientPart, { type: "file" }> & {
url?: string
mime?: string
filename?: string
}
const combinedParts = () => props.combinedParts
const messageParts = () => props.parts
const fileAttachments = () =>
props.orderedParts.filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
const getAttachmentName = (part: FilePart) => {
if (part.filename && part.filename.trim().length > 0) {
@@ -124,7 +129,7 @@ export default function MessageItem(props: MessageItemProps) {
return true
}
return combinedParts().some((part) => partHasRenderableText(part))
return messageParts().some((part) => partHasRenderableText(part))
}
const isGenerating = () => {
@@ -147,6 +152,8 @@ export default function MessageItem(props: MessageItemProps) {
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
const speakerLabel = () => (isUser() ? "You" : "Assistant")
const agentIdentifier = () => {
if (isUser()) return ""
const info = props.messageInfo
@@ -164,55 +171,63 @@ export default function MessageItem(props: MessageItemProps) {
return modelID
}
const agentMeta = () => {
if (isUser() || !props.showAgentMeta) return ""
const segments: string[] = []
const agent = agentIdentifier()
const model = modelIdentifier()
if (agent) {
segments.push(`Agent: ${agent}`)
}
if (model) {
segments.push(`Model: ${model}`)
}
return segments.join(" • ")
}
return (
<div class={containerClass()}>
<div class={`flex justify-between items-center gap-2.5 ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="flex flex-col">
<Show when={isUser()}>
<span class="font-semibold text-xs text-[var(--message-user-border)]">You</span>
</Show>
<Show when={!isUser()}>
<div class="flex flex-wrap items-center gap-2 text-xs text-[var(--message-assistant-border)]">
<span class="font-semibold">Assistant</span>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Agent: {value()}</span>}</Show>
<Show when={modelIdentifier()}>{(value) => <span class="font-medium text-[var(--message-assistant-border)]">Model: {value()}</span>}</Show>
</span>
</Show>
</div>
</Show>
</div>
<div class="flex items-center gap-2">
<Show when={isUser() && props.onRevert}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert to
</button>
</Show>
<Show when={isUser() && props.onFork}>
<button
class="bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6 hover:bg-[var(--surface-hover)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)] active:scale-95"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
<span class="text-[11px] text-[var(--text-muted)]">{timestamp()}</span>
<div class={containerClass()}>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-speaker">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
<Show when={agentMeta()}>{(meta) => <span class="message-agent-meta">{meta()}</span>}</Show>
</div>
</div>
<div class="message-item-actions">
<Show when={isUser()}>
<div class="message-action-group">
<Show when={props.onRevert}>
<button
class="message-action-button"
onClick={handleRevert}
title="Revert to this message"
aria-label="Revert to this message"
>
Revert
</button>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
onClick={() => props.onFork?.(props.record.id)}
title="Fork from this message"
aria-label="Fork from this message"
>
Fork
</button>
</Show>
</div>
</Show>
<time class="message-timestamp" dateTime={timestampIso()}>{timestamp()}</time>
</div>
</header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]">
<Show when={props.isQueued && isUser()}>
<div class="message-queued-badge">QUEUED</div>
</Show>
@@ -227,13 +242,14 @@ export default function MessageItem(props: MessageItemProps) {
</div>
</Show>
<For each={combinedParts()}>
<For each={messageParts()}>
{(part) => (
<MessagePart
part={part}
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
onRendered={props.onContentRendered}
/>
)}
</For>

View File

@@ -13,8 +13,10 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
}
export default function MessagePart(props: MessagePartProps) {
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
@@ -95,11 +97,17 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!(props.part.type === "text" && props.part.synthetic) && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown part={createTextPartForMarkdown()} isDark={isDark()} size={isAssistantMessage() ? "tight" : "base"} />
</Show>
when={isAssistantMessage()}
fallback={<span>{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
let scrollToBottomHandle: (() => void) | undefined
createEffect(() => {
createEffect(() => {
const currentSession = session()
if (currentSession) {
loadMessages(props.instanceId, currentSession.id).catch(console.error)
@@ -34,6 +36,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
})
async function handleSendMessage(prompt: string, attachments: Attachment[]) {
if (scrollToBottomHandle) {
scrollToBottomHandle()
}
await sendMessage(props.instanceId, props.sessionId, prompt, attachments)
}
@@ -137,12 +142,16 @@ export const SessionView: Component<SessionViewProps> = (props) => {
return (
<div class="session-view">
<MessageStreamV2
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
/>
instanceId={props.instanceId}
sessionId={activeSession.id}
loading={messagesLoading()}
onRevert={handleRevert}
onFork={handleFork}
registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn
}}
/>
<PromptInput
instanceId={props.instanceId}

View File

@@ -1,5 +1,4 @@
import { createSignal, Show, For, createEffect, createMemo, onCleanup } from "solid-js"
import { isToolCallExpanded, toggleToolCallExpanded, setToolCallExpanded } from "../stores/tool-call-state"
import { messageStoreBus } from "../stores/message-v2/bus"
import { Markdown } from "./markdown"
import { ToolCallDiffViewer } from "./diff-viewer"
@@ -57,7 +56,9 @@ interface ToolCallProps {
partVersion?: number
instanceId: string
sessionId: string
}
onContentRendered?: () => void
}
function getToolIcon(tool: string): string {
switch (tool) {
@@ -349,10 +350,28 @@ export default function ToolCall(props: ToolCallProps) {
}
return props.toolCall.pendingPermission
})
const expanded = () => (pendingPermission() ? true : isToolCallExpanded(toolCallId()))
const toolOutputDefaultExpanded = createMemo(() => (preferences().toolOutputExpansion || "expanded") === "expanded")
const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
const [appliedPreference, setAppliedPreference] = createSignal<boolean | null>(null)
const defaultExpandedForTool = createMemo(() => {
const prefExpanded = toolOutputDefaultExpanded()
const toolName = props.toolCall?.tool || ""
if (toolName === "read") {
return false
}
return prefExpanded
})
const [userExpanded, setUserExpanded] = createSignal<boolean | null>(null)
const expanded = () => {
const permission = pendingPermission()
if (permission?.active) return true
const override = userExpanded()
if (override !== null) return override
return defaultExpandedForTool()
}
const permissionDetails = createMemo(() => pendingPermission()?.permission)
const isPermissionActive = createMemo(() => pendingPermission()?.active === true)
const activePermissionKey = createMemo(() => {
@@ -361,7 +380,16 @@ export default function ToolCall(props: ToolCallProps) {
})
const [permissionSubmitting, setPermissionSubmitting] = createSignal(false)
const [permissionError, setPermissionError] = createSignal<string | null>(null)
const [diagnosticsExpanded, setDiagnosticsExpanded] = createSignal(diagnosticsDefaultExpanded())
const [diagnosticsOverride, setDiagnosticsOverride] = createSignal<boolean | undefined>(undefined)
const diagnosticsExpanded = () => {
const permission = pendingPermission()
if (permission?.active) return true
const override = diagnosticsOverride()
if (override !== undefined) return override
return diagnosticsDefaultExpanded()
}
const diagnosticsEntries = createMemo(() => {
const tool = props.toolCall?.tool || ""
const state = props.toolCall?.state
@@ -369,10 +397,6 @@ export default function ToolCall(props: ToolCallProps) {
return extractDiagnostics(tool, state)
})
createEffect(() => {
const preferred = diagnosticsDefaultExpanded()
setDiagnosticsExpanded((prev) => (prev === preferred ? prev : preferred))
})
let scrollContainerRef: HTMLDivElement | undefined
let toolCallRootRef: HTMLDivElement | undefined
@@ -421,21 +445,6 @@ export default function ToolCall(props: ToolCallProps) {
restoreScrollSnapshot(resolvedElement)
}
createEffect(() => {
const id = toolCallId()
if (!id) return
const toolName = props.toolCall?.tool || ""
const desiredExpansion = toolName === "read" ? false : toolOutputDefaultExpanded()
if (appliedPreference() === desiredExpansion) return
setToolCallExpanded(id, desiredExpansion)
setAppliedPreference(desiredExpansion)
})
createEffect(() => {
const id = toolCallId()
if (!id) return
setAppliedPreference((prev) => (prev === null ? prev : null))
})
createEffect(() => {
const permission = permissionDetails()
@@ -447,19 +456,6 @@ export default function ToolCall(props: ToolCallProps) {
}
})
createEffect(() => {
if (props.toolCall?.tool !== "task") return
const state = props.toolCall?.state
const summarySignature = JSON.stringify(
state && (isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state))
? state.metadata?.summary ?? []
: []
)
requestAnimationFrame(() => {
void summarySignature
handleScrollRendered()
})
})
createEffect(() => {
const activeKey = activePermissionKey()
@@ -488,6 +484,11 @@ export default function ToolCall(props: ToolCallProps) {
onCleanup(() => document.removeEventListener("keydown", handler))
})
createEffect(() => {
if (!expanded()) {
scrollContainerRef = undefined
}
})
const statusIcon = () => {
const status = props.toolCall?.state?.status || ""
@@ -516,7 +517,14 @@ export default function ToolCall(props: ToolCallProps) {
}
function toggle() {
toggleToolCallExpanded(toolCallId())
const permission = pendingPermission()
if (permission?.active) {
return
}
setUserExpanded((prev) => {
const current = prev === null ? defaultExpandedForTool() : prev
return !current
})
}
const renderToolAction = () => {
@@ -760,8 +768,10 @@ export default function ToolCall(props: ToolCallProps) {
if (!options?.disableScrollTracking) {
handleScrollRendered()
}
props.onContentRendered?.()
}
return (
<div
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
@@ -825,8 +835,10 @@ export default function ToolCall(props: ToolCallProps) {
const handleMarkdownRendered = () => {
markdownCache.set(markdownPart.renderCache)
handleScrollRendered()
props.onContentRendered?.()
}
return (
<div
class={messageClass}
@@ -1185,21 +1197,25 @@ export default function ToolCall(props: ToolCallProps) {
}}
class={`tool-call ${combinedStatusClass()}`}
>
<button class="tool-call-header" onClick={toggle} aria-expanded={expanded()}>
<span class="tool-call-icon">{expanded() ? "▼" : "▶"}</span>
<span class="tool-call-emoji">{getToolIcon(toolName())}</span>
<span class="tool-call-summary">{renderToolTitle()}</span>
<span class="tool-call-status">{statusIcon()}</span>
<button
class="tool-call-header"
onClick={toggle}
aria-expanded={expanded()}
data-status-icon={statusIcon()}
>
<span class="tool-call-summary" data-tool-icon={getToolIcon(toolName())}>
{renderToolTitle()}
</span>
</button>
<Show when={expanded()}>
{expanded() && (
<div class="tool-call-details">
{renderToolBody()}
{renderError()}
{renderPermissionBlock()}
<Show when={status() === "pending" && !pendingPermission()}>
<div class="tool-call-pending-message">
<span class="spinner-small"></span>
@@ -1207,13 +1223,17 @@ export default function ToolCall(props: ToolCallProps) {
</div>
</Show>
</div>
</Show>
)}
<Show when={diagnosticsEntries().length}>
{renderDiagnosticsSection(
diagnosticsEntries(),
diagnosticsExpanded(),
() => setDiagnosticsExpanded((prev) => !prev),
() => setDiagnosticsOverride((prev) => {
const current = prev === undefined ? diagnosticsDefaultExpanded() : prev
return !current
}),
getToolIcon(toolName()),
diagnosticFileName(diagnosticsEntries()),
)}

View File

@@ -16,11 +16,13 @@ import { showAlertDialog } from "../../stores/alerts"
import type { Instance } from "../../types/instance"
import type { MessageRecord } from "../../stores/message-v2/types"
import { messageStoreBus } from "../../stores/message-v2/bus"
import { cleanupBlankSessions } from "../../stores/session-state"
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -142,6 +144,19 @@ export function useCommands(options: UseCommandsOptions) {
},
})
commandRegistry.register({
id: "cleanup-blank-sessions",
label: "Scrub Sessions",
description: "Remove empty sessions, subagent sessions that have completed their primary task, and extraneous forked sessions.",
category: "Session",
keywords: ["cleanup", "blank", "empty", "sessions", "remove", "delete", "scrub"],
action: async () => {
const instance = activeInstance()
if (!instance) return
cleanupBlankSessions(instance.id, undefined, true)
},
})
commandRegistry.register({
id: "switch-to-info",
label: "Instance Info",
@@ -467,6 +482,18 @@ export function useCommands(options: UseCommandsOptions) {
keywords: ["token", "usage", "cost", "stats"],
action: options.toggleUsageMetrics,
})
commandRegistry.register({
id: "auto-cleanup-blank-sessions",
label: () => {
const enabled = options.preferences().autoCleanupBlankSessions
return `Auto-Cleanup Blank Sessions · ${enabled ? "Enabled" : "Disabled"}`
},
description: "Automatically clean up blank sessions when creating new ones",
category: "System",
keywords: ["auto", "cleanup", "blank", "sessions", "toggle"],
action: options.toggleAutoCleanupBlankSessions,
})
commandRegistry.register({
id: "help",

View File

@@ -618,6 +618,13 @@ function mutateToolPartPermission(
draft.updatedAt = Date.now()
}),
)
// Permission attachment/removal can change the rendered height of the
// message list (e.g., permission blocks or diffs), so bump the
// session revision to ensure auto-scroll reacts.
if (messageRecord.sessionId) {
store.setState("sessionRevisions", messageRecord.sessionId, (value: number = 0) => value + 1)
}
}
function attachPermissionToToolPart(instanceId: string, permission: Permission, active: boolean): void {

View File

@@ -46,36 +46,13 @@ function ensurePartId(messageId: string, part: ClientPart, index: number): strin
const PENDING_PART_MAX_AGE_MS = 30_000
function clonePart(part: ClientPart): ClientPart {
if (!part || typeof part !== "object") {
return part
}
const cloned: Record<string, any> = { ...part }
if ("renderCache" in cloned) {
cloned.renderCache = undefined
}
if ("text" in cloned) {
cloned.text = cloneStructuredValue(cloned.text)
}
if ("thinking" in cloned && typeof cloned.thinking === "object") {
cloned.thinking = cloneStructuredValue(cloned.thinking)
}
if ("content" in cloned && Array.isArray(cloned.content)) {
cloned.content = cloneStructuredValue(cloned.content)
}
return cloned as ClientPart
// Cloning is intentionally disabled; message parts
// are stored as received from the backend.
return part
}
function cloneStructuredValue<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => cloneStructuredValue(item)) as T
}
if (value && typeof value === "object") {
const next: Record<string, any> = {}
Object.entries(value as Record<string, any>).forEach(([key, nested]) => {
next[key] = cloneStructuredValue(nested)
})
return next as T
}
// Legacy helper kept as a no-op to avoid deep copies.
return value
}
@@ -462,10 +439,10 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
bufferPendingPart({ messageId: input.messageId, part: input.part, receivedAt: Date.now() })
return
}
const partId = ensurePartId(input.messageId, input.part, message.partIds.length)
const cloned = clonePart(input.part)
setState(
"messages",
input.messageId,
@@ -486,8 +463,13 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
}
}),
)
// Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes.
bumpSessionRevision(message.sessionId)
}
function flushPendingParts(messageId: string) {
const pending = state.pendingParts[messageId]
if (!pending || pending.length === 0) {
@@ -636,9 +618,60 @@ export function createInstanceMessageStore(instanceId: string): InstanceMessageS
return { entry, active }
}
function pruneMessagesAfterRevert(sessionId: string, revertMessageId: string) {
const session = state.sessions[sessionId]
if (!session) return
const stopIndex = session.messageIds.indexOf(revertMessageId)
if (stopIndex === -1) return
const removedIds = session.messageIds.slice(stopIndex)
const keptIds = session.messageIds.slice(0, stopIndex)
if (removedIds.length === 0) return
setState("sessions", sessionId, "messageIds", keptIds)
setState("messages", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => delete next[id])
return next
})
setState("messageInfoVersion", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => delete next[id])
return next
})
removedIds.forEach((id) => messageInfoCache.delete(id))
setState("pendingParts", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
setState("permissions", "byMessage", (prev) => {
const next = { ...prev }
removedIds.forEach((id) => {
if (next[id]) delete next[id]
})
return next
})
withUsageState(sessionId, (draft) => {
removedIds.forEach((id) => removeUsageEntry(draft, id))
})
bumpSessionRevision(sessionId)
}
function setSessionRevert(sessionId: string, revert?: SessionRecord["revert"] | null) {
if (!sessionId) return
ensureSessionEntry(sessionId)
if (revert?.messageID) {
pruneMessagesAfterRevert(sessionId, revert.messageID)
}
setState("sessions", sessionId, "revert", revert ?? null)
}

View File

@@ -37,6 +37,7 @@ export interface Preferences {
toolOutputExpansion: ExpansionPreference
diagnosticsExpansion: ExpansionPreference
showUsageMetrics: boolean
autoCleanupBlankSessions?: boolean
}
export interface OpenCodeBinary {
@@ -64,6 +65,7 @@ const defaultPreferences: Preferences = {
toolOutputExpansion: "expanded",
diagnosticsExpansion: "expanded",
showUsageMetrics: true,
autoCleanupBlankSessions: true,
}
function deepEqual(a: unknown, b: unknown): boolean {
@@ -98,6 +100,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions,
}
}
@@ -285,6 +288,11 @@ function toggleUsageMetrics(): void {
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
}
function toggleAutoCleanupBlankSessions(): void {
console.log("toggle auto cleanup")
updatePreferences({ autoCleanupBlankSessions: !preferences().autoCleanupBlankSessions })
}
function addRecentFolder(path: string): void {
updateConfig((draft) => {
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
@@ -386,6 +394,7 @@ interface ConfigContextValue {
updateConfig: typeof updateConfig
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
toggleUsageMetrics: typeof toggleUsageMetrics
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
setDiffViewMode: typeof setDiffViewMode
setToolOutputExpansion: typeof setToolOutputExpansion
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
@@ -418,6 +427,7 @@ const configContextValue: ConfigContextValue = {
updateConfig,
toggleShowThinkingBlocks,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -473,6 +483,7 @@ export {
updateConfig,
updatePreferences,
toggleShowThinkingBlocks,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
recentFolders,
addRecentFolder,

View File

@@ -2,15 +2,17 @@ import type { Session } from "../types/session"
import type { Message } from "../types/message"
import { instances, refreshPermissionsForSession } from "./instances"
import { setAgentModelPreference } from "./preferences"
import { preferences, setAgentModelPreference } from "./preferences"
import { setSessionCompactionState } from "./session-compaction"
import {
activeSessionId,
agents,
clearSessionDraftPrompt,
getChildSessions,
isBlankSession,
messagesLoaded,
providers,
pruneDraftPrompts,
providers,
setActiveSessionId,
setAgents,
setMessagesLoaded,
@@ -20,6 +22,7 @@ import {
sessions,
loading,
setLoading,
cleanupBlankSessions,
} from "./session-state"
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
import { normalizeMessagePart } from "./message-v2/normalizers"
@@ -228,6 +231,10 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
return next
})
if (preferences().autoCleanupBlankSessions) {
await cleanupBlankSessions(instanceId, session.id)
}
return session
} catch (error) {
console.error("Failed to create session:", error)

View File

@@ -81,20 +81,25 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (event.type === "message.part.updated") {
const rawPart = event.properties?.part
if (!rawPart) return
const part = normalizeMessagePart(rawPart)
const sessionId = typeof part.sessionID === "string" ? part.sessionID : undefined
const messageId = typeof part.messageID === "string" ? part.messageID : undefined
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const fallbackSessionId = typeof messageInfo?.sessionID === "string" ? messageInfo.sessionID : undefined
const fallbackMessageId = typeof messageInfo?.id === "string" ? messageInfo.id : undefined
const sessionId = typeof part.sessionID === "string" ? part.sessionID : fallbackSessionId
const messageId = typeof part.messageID === "string" ? part.messageID : fallbackMessageId
if (!sessionId || !messageId) return
const session = instanceSessions.get(sessionId)
if (!session) return
const store = messageStoreBus.getOrCreate(instanceId)
const messageInfo = (event as any)?.properties?.message as MessageInfo | undefined
const role: MessageRole = resolveMessageRole(messageInfo)
const createdAt = typeof messageInfo?.time?.created === "number" ? messageInfo.time.created : Date.now()
let record = store.getMessage(messageId)
if (!record) {
const pendingId = findPendingMessageId(store, sessionId, role)
@@ -119,8 +124,9 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (messageInfo) {
upsertMessageInfoV2(instanceId, messageInfo, { status: "streaming" })
}
applyPartUpdateV2(instanceId, { ...part, sessionID: sessionId, messageID: messageId })
applyPartUpdateV2(instanceId, part)
updateSessionInfo(instanceId, sessionId)
refreshPermissionsForSession(instanceId, sessionId)

View File

@@ -1,6 +1,11 @@
import { createSignal } from "solid-js"
import type { Session, Agent, Provider } from "../types/session"
import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus"
import { instances } from "./instances"
import { showConfirmDialog } from "./alerts"
export interface SessionInfo {
cost: number
@@ -221,6 +226,108 @@ function getSessionInfo(instanceId: string, sessionId: string): SessionInfo | un
return sessionInfoByInstance().get(instanceId)?.get(sessionId)
}
async function isBlankSession(session: Session, instanceId: string, fetchIfNeeded = false): Promise<boolean> {
const created = session.time?.created || 0
const updated = session.time?.updated || 0
const hasChildren = getChildSessions(instanceId, session.id).length > 0
const isFreshSession = created === updated && !hasChildren
// Common short-circuit: fresh sessions without children
if (!fetchIfNeeded) {
return isFreshSession
}
// For a more thorough deep clean, we need to look at actual messages
const instance = instances().get(instanceId)
if (!instance?.client) {
return isFreshSession
}
let messages: any[] = []
try {
const response = await instance.client.session.messages({ path: { id: session.id } })
messages = response.data || []
} catch (error) {
console.error(`Failed to fetch messages for session ${session.id}:`, error)
return isFreshSession
}
// Specific logic by session type
if (session.parentId === null) {
// Parent: blank if no messages and no children (fresh !== blank sometimes!)
const hasChildren = getChildSessions(instanceId, session.id).length > 0
return messages.length === 0 && !hasChildren
} else if (session.title?.includes("subagent)")) {
// Subagent: "blank" (really: finished doing its job) if actually blank...
// ... OR no streaming, no pending perms, no tool parts
if (messages.length === 0) return true
const hasStreaming = messages.some((msg) => {
const info = msg.info.status || msg.status
return info === "streaming" || info === "sending"
})
const lastMessage = messages[messages.length - 1]
const lastParts = lastMessage?.parts || []
const hasToolPart = lastParts.some((part: any) =>
part.type === "tool" || part.data?.type === "tool"
)
return !hasStreaming && !session.pendingPermission && !hasToolPart
} else {
// Fork: blank if somehow has no messages or at revert point
if (messages.length === 0) return true
const lastMessage = messages[messages.length - 1]
const lastInfo = lastMessage?.info || lastMessage
return lastInfo?.id === session.revert?.messageID
}
}
async function cleanupBlankSessions(instanceId: string, excludeSessionId?: string, fetchIfNeeded = false): Promise<void> {
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) return
if (fetchIfNeeded) {
const confirmed = await showConfirmDialog(
"This cleanup may be slow, and may delete sessions you didn't intend to delete. Are you sure?",
{
title: "Deep Clean Sessions",
detail: "Deep Clean Sessions will delete all sessions that have no messages, remove any finished sub-agent sessions, and clear out any unused forks of a session.",
confirmLabel: "Continue",
cancelLabel: "Cancel"
}
)
if (!confirmed) return
}
const cleanupPromises = Array.from(instanceSessions)
.filter(([sessionId]) => sessionId !== excludeSessionId)
.map(async ([sessionId, session]) => {
const isBlank = await isBlankSession(session, instanceId, fetchIfNeeded)
if (!isBlank) return false
await deleteSession(instanceId, sessionId).catch((error: Error) => {
console.error(`Failed to delete blank session ${sessionId}:`, error)
})
return true
})
if (cleanupPromises.length > 0) {
console.log(`Cleaning up ${cleanupPromises.length} blank sessions`)
const deletionResults = await Promise.all(cleanupPromises)
const deletedCount = deletionResults.filter(Boolean).length
if (deletedCount > 0) {
showToastNotification({
message: `Cleaned up ${deletedCount} blank session${deletedCount === 1 ? "" : "s"}`,
variant: "info"
})
}
}
}
export {
sessions,
setSessions,
@@ -259,4 +366,6 @@ export {
isSessionBusy,
isSessionMessagesLoading,
getSessionInfo,
isBlankSession,
cleanupBlankSessions,
}

View File

@@ -3,6 +3,56 @@
@apply flex flex-col gap-2 p-3 w-full;
}
.message-item-header {
@apply flex justify-between items-start gap-2.5;
}
.message-speaker {
@apply flex flex-col gap-0.5 text-xs;
}
.message-speaker-label {
font-weight: var(--font-weight-semibold);
}
.message-speaker-label[data-role="user"] {
color: var(--message-user-border);
}
.message-speaker-label[data-role="assistant"] {
color: var(--message-assistant-border);
}
.message-agent-meta {
@apply text-xs text-[var(--message-assistant-border)];
}
.message-item-actions {
@apply flex items-center gap-2;
}
.message-action-group {
@apply flex items-center gap-2;
}
.message-action-button {
@apply bg-transparent border border-[var(--border-base)] text-[var(--text-muted)] cursor-pointer px-3 py-0.5 rounded text-xs font-semibold leading-none transition-all duration-200 flex items-center justify-center h-6;
}
.message-action-button:hover {
background-color: var(--surface-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.message-action-button:active {
transform: scale(0.95);
}
.message-timestamp {
@apply text-[11px] text-[var(--text-muted)];
}
.assistant-message {
/* gap: 0.25rem; */
padding: 0.6rem 0.65rem;
@@ -35,6 +85,22 @@
@apply flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)];
}
.message-step-usage-chip {
@apply inline-flex items-center rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px];
color: var(--text-primary);
font-weight: var(--font-weight-semibold);
}
.message-step-usage-chip::before {
content: attr(data-label);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 9px;
color: var(--text-muted);
font-weight: var(--font-weight-medium);
margin-right: 0.35rem;
}
.message-step-heading {
@apply flex flex-wrap items-center gap-2 text-xs;
color: var(--text-muted);

View File

@@ -85,20 +85,46 @@
border-radius: 0;
}
.tool-call-header::before {
content: "▶";
font-size: 11px;
margin-right: 0.35rem;
color: var(--text-muted);
}
.tool-call-header[aria-expanded="true"]::before {
content: "▼";
}
.tool-call-header::after {
content: attr(data-status-icon);
font-size: 0.95rem;
margin-left: 0.5rem;
}
.tool-call-header[data-status-icon=""]::after {
margin-left: 0;
}
.tool-call-header:hover {
background-color: var(--surface-hover);
}
.tool-call-icon {
@apply text-xs;
}
.tool-call-summary {
@apply flex-1 text-left;
@apply flex-1 text-left inline-flex items-center gap-2;
}
.tool-call-status {
@apply text-sm;
.tool-call-summary::before {
content: attr(data-tool-icon);
}
.tool-call-summary[data-tool-icon=""]::before {
margin-right: 0;
content: "";
}
.tool-call-summary[data-tool-icon]:not([data-tool-icon=""])::before {
margin-right: 0.35rem;
}
.tool-call-status-success {

View File

@@ -8,6 +8,10 @@
@apply rounded-lg shadow-2xl flex flex-col;
background-color: var(--surface-base);
color: var(--text-primary);
max-width: min(100%, calc(100vw - 32px));
overflow-wrap: anywhere;
word-break: break-word;
min-width: 0;
}
.modal-search-container {