Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ jobs:

- name: Test changed runnable UI behavior
run: >-
node --import tsx --test
node --import tsx --test --test-force-exit
packages/ui/src/components/browser-frame-security.test.ts
packages/ui/src/components/message-history-pagination.test.ts
packages/ui/src/components/message-timeline-v2.test.ts
Expand All @@ -115,18 +115,23 @@ jobs:
packages/ui/src/components/session/session-bottom-pin-intent.test.ts
packages/ui/src/components/session/session-idle-attention.test.ts
packages/ui/src/components/session-list-visibility.test.ts
packages/ui/src/components/tool-call/permission-block.test.ts
packages/ui/src/components/tool-call/render-memory.test.ts
packages/ui/src/components/unified-picker-path.test.ts
packages/ui/src/components/virtual-follow-behavior.test.ts
packages/ui/src/lib/client-identity.test.ts
packages/ui/src/lib/filesystem-events.test.ts
packages/ui/src/lib/global-cache.test.ts
packages/ui/src/lib/hooks/use-app-session-capture.test.ts
packages/ui/src/lib/hooks/use-instance-metadata.test.ts
packages/ui/src/lib/hooks/use-foreground-refresh.test.ts
packages/ui/src/lib/hooks/use-electron-folder-launch.test.ts
packages/ui/src/lib/launch-errors.test.ts
packages/ui/src/lib/message-selection-position.test.ts
packages/ui/src/lib/model-visibility.test.ts
packages/ui/src/lib/retained-size.test.ts
packages/ui/src/lib/runtime-env.test.ts
packages/ui/src/lib/session-transcript-lru.test.ts
packages/ui/src/lib/trailing-resync.test.ts
packages/ui/src/stores/abort-created-workspace-cleanup.test.ts
packages/ui/src/stores/app-session-reconciliation.test.ts
Expand Down Expand Up @@ -158,11 +163,13 @@ jobs:
node --conditions=browser --import tsx --test --test-force-exit
packages/ui/src/components/form-request-tool-target.test.ts
packages/ui/src/components/form-request.test.ts
packages/ui/src/components/tool-call/renderer-copy.test.ts
packages/ui/src/components/session/provider-usage-panel.test.ts
packages/ui/src/lib/hooks/use-active-session-message-load.test.ts
packages/ui/src/stores/app-tabs.test.ts
packages/ui/src/stores/forms.test.ts
packages/ui/src/stores/instances-restore-ownership.test.ts
packages/ui/src/stores/message-v2/bus.test.ts
packages/ui/src/stores/opencode-data.test.ts
packages/ui/src/stores/permission-lifecycle.test.ts
packages/ui/src/stores/shell-store-reactivity.test.ts
Expand Down
40 changes: 23 additions & 17 deletions packages/server/src/opencode/automation-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path"
import test from "node:test"
import {
AUTOMATION_BRIDGE_PATH,
automationBridgeDirectory,
automationBridgeDirectories,
createAutomationBridgeRegistration,
parseDeveloperAction,
Expand Down Expand Up @@ -50,6 +51,19 @@ function closeServer(server: http.Server | undefined): Promise<void> {
return new Promise((resolve) => server?.close(() => resolve()) ?? resolve())
}

function scopeAutomationBridgeRoot(root: string): () => void {
const previousLocalAppData = process.env.LOCALAPPDATA
const previousXdgRuntimeDir = process.env.XDG_RUNTIME_DIR
process.env.LOCALAPPDATA = root
process.env.XDG_RUNTIME_DIR = root
return () => {
if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = previousLocalAppData
if (previousXdgRuntimeDir === undefined) delete process.env.XDG_RUNTIME_DIR
else process.env.XDG_RUNTIME_DIR = previousXdgRuntimeDir
}
}

test("validates Developer Mode actions", () => {
assert.deepEqual(parseDeveloperAction({ action: "type", ref: "e4", text: "CodeNomad" }), {
action: "type",
Expand Down Expand Up @@ -100,8 +114,7 @@ test("removes only the generated legacy global plugin shim", async () => {

test("restart waits for a new native generation and returns a fresh inspection", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-restart-"))
const previousLocalAppData = process.env.LOCALAPPDATA
process.env.LOCALAPPDATA = root
const restoreAutomationBridgeRoot = scopeAutomationBridgeRoot(root)
const definitions: ToolDefinition[] = []
let removeOld: (() => Promise<void>) | undefined
let removeNew: (() => Promise<void>) | undefined
Expand Down Expand Up @@ -162,16 +175,14 @@ test("restart waits for a new native generation and returns a fresh inspection",
await closeServer(newServer)
await closeServer(preexistingServer)
await Promise.all(distractorServers.map(closeServer))
if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = previousLocalAppData
restoreAutomationBridgeRoot()
await rm(root, { recursive: true, force: true })
}
})

test("keeps inspected targets isolated per plugin setup", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-isolation-"))
const previousLocalAppData = process.env.LOCALAPPDATA
process.env.LOCALAPPDATA = root
const restoreAutomationBridgeRoot = scopeAutomationBridgeRoot(root)
let removeBridge: (() => Promise<void>) | undefined
let server: http.Server | undefined
try {
Expand All @@ -190,16 +201,14 @@ test("keeps inspected targets isolated per plugin setup", async () => {
} finally {
await removeBridge?.()
await closeServer(server)
if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = previousLocalAppData
restoreAutomationBridgeRoot()
await rm(root, { recursive: true, force: true })
}
})

test("pins parallel sessions to their independently inspected bridges", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-sessions-"))
const previousLocalAppData = process.env.LOCALAPPDATA
process.env.LOCALAPPDATA = root
const restoreAutomationBridgeRoot = scopeAutomationBridgeRoot(root)
const removals: Array<() => Promise<void>> = []
const servers: http.Server[] = []
try {
Expand All @@ -220,16 +229,14 @@ test("pins parallel sessions to their independently inspected bridges", async ()
} finally {
await Promise.all(removals.map((remove) => remove()))
await Promise.all(servers.map(closeServer))
if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = previousLocalAppData
restoreAutomationBridgeRoot()
await rm(root, { recursive: true, force: true })
}
})

test("prunes stale registry pressure before limiting discovery", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "codenomad-automation-stale-"))
const previousLocalAppData = process.env.LOCALAPPDATA
process.env.LOCALAPPDATA = root
const restoreAutomationBridgeRoot = scopeAutomationBridgeRoot(root)
let removeBridge: (() => Promise<void>) | undefined
let server: http.Server | undefined
try {
Expand All @@ -238,7 +245,7 @@ test("prunes stale registry pressure before limiting discovery", async () => {
: { result: { target: { id: "live", title: "Live", url: "http://app.test" }, nodes: [], diagnostics: [] } })
server = bridge.server
removeBridge = await publishAutomationBridge(createAutomationBridgeRegistration(bridge.url))
const directory = path.join(root, "CodeNomad", "automation-bridges")
const directory = automationBridgeDirectory()
const base = Date.now() + 10_000
for (let index = 0; index < 70; index += 1) {
const startedAt = base + index
Expand All @@ -257,8 +264,7 @@ test("prunes stale registry pressure before limiting discovery", async () => {
} finally {
await removeBridge?.()
await closeServer(server)
if (previousLocalAppData === undefined) delete process.env.LOCALAPPDATA
else process.env.LOCALAPPDATA = previousLocalAppData
restoreAutomationBridgeRoot()
await rm(root, { recursive: true, force: true })
}
})
1 change: 1 addition & 0 deletions packages/ui/src/components/instance/instance-shell2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId: () => props.instance.id,
instanceSessions: allInstanceSessions,
activeSessionId: activeSessionIdForInstance,
isActiveInstance: () => Boolean(props.isActiveInstance),
})

const showEmbeddedSidebarToggle = createMemo(() => !leftPinned() && !leftOpen())
Expand Down
101 changes: 22 additions & 79 deletions packages/ui/src/components/instance/shell/useSessionCache.ts
Original file line number Diff line number Diff line change
@@ -1,98 +1,41 @@
import { createEffect, createSignal, type Accessor } from "solid-js"
import { messageStoreBus } from "../../../stores/message-v2/bus"
import { clearSessionRenderCache } from "../../message-block"
import { getLogger } from "../../../lib/logger"
import { invalidateSessionMessageLoad } from "../../../stores/session-state"

const log = getLogger("session")

const SESSION_CACHE_LIMIT = 5
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import {
reconcileSessionTranscriptBudget,
setSessionTranscriptVisible,
touchSessionTranscript,
} from "../../../stores/session-transcript-memory"

type SessionCacheOptions = {
instanceId: Accessor<string>
instanceSessions: Accessor<Map<string, unknown>>
activeSessionId: Accessor<string | null>
isActiveInstance: Accessor<boolean>
}

type SessionCacheState = {
cachedSessionIds: Accessor<string[]>
}

export function useSessionCache(options: SessionCacheOptions): SessionCacheState {
const [cachedSessionIds, setCachedSessionIds] = createSignal<string[]>([])
const [pendingEvictions, setPendingEvictions] = createSignal<string[]>([])

const evictSession = (sessionId: string) => {
if (!sessionId) return
const instanceId = options.instanceId()
log.info("Evicting cached session", { instanceId, sessionId })
const store = messageStoreBus.getInstance(instanceId)
invalidateSessionMessageLoad(instanceId, sessionId)
store?.clearSession(sessionId, { preserveScroll: true, notify: false })
clearSessionRenderCache(instanceId, sessionId)
}

const scheduleEvictions = (ids: string[]) => {
if (!ids.length) return
setPendingEvictions((current) => {
const existing = new Set(current)
const next = [...current]
ids.forEach((id) => {
if (!existing.has(id)) {
next.push(id)
existing.add(id)
}
})
return next
})
}

createEffect(() => {
const pending = pendingEvictions()
if (!pending.length) return
const cached = new Set(cachedSessionIds())
const remaining: string[] = []
pending.forEach((id) => {
if (cached.has(id)) {
remaining.push(id)
} else {
evictSession(id)
}
})
if (remaining.length !== pending.length) {
setPendingEvictions(remaining)
}
})

createEffect(() => {
const cachedSessionIds = createMemo(() => {
const instanceSessions = options.instanceSessions()
const activeId = options.activeSessionId()
if (!options.isActiveInstance() || !activeId || activeId === "info" || !instanceSessions.has(activeId)) return []
return [activeId]
})

setCachedSessionIds((current) => {
const next = current.filter((id) => id !== "info" && instanceSessions.has(id))

const touch = (id: string | null) => {
if (!id || id === "info") return
if (!instanceSessions.has(id)) return

const index = next.indexOf(id)
if (index !== -1) {
next.splice(index, 1)
}
next.unshift(id)
}

touch(activeId)

const trimmed = next.length > SESSION_CACHE_LIMIT ? next.slice(0, SESSION_CACHE_LIMIT) : next
createEffect(() => {
const instanceId = options.instanceId()
const [sessionId] = cachedSessionIds()
if (!sessionId) return
setSessionTranscriptVisible(instanceId, sessionId, true)
touchSessionTranscript(instanceId, sessionId)
reconcileSessionTranscriptBudget()
onCleanup(() => setSessionTranscriptVisible(instanceId, sessionId, false))
})

const trimmedSet = new Set(trimmed)
const removed = current.filter((id) => !trimmedSet.has(id))
if (removed.length) {
scheduleEvictions(removed)
}
return trimmed
})
onCleanup(() => {
reconcileSessionTranscriptBudget()
})

return {
Expand Down
45 changes: 33 additions & 12 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { useGlobalCache } from "../lib/hooks/use-global-cache"
import type { TextPart, RenderCache } from "../types/message"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { limitToolOutputForRender, TOOL_OUTPUT_RENDER_CHARACTER_LIMIT } from "./tool-call/utils"

const log = getLogger("session")

Expand Down Expand Up @@ -89,6 +90,10 @@ function renderFallbackHtml(content: string): string {
return escapeHtml(content).replace(/\n/g, "<br />")
}

export function getMarkdownTextForRender(content: string): string {
return limitToolOutputForRender(content)
}

interface MarkdownProps {
part: TextPart
instanceId?: string
Expand Down Expand Up @@ -158,7 +163,7 @@ export function Markdown(props: MarkdownProps) {
const resolved = createMemo(() => {
const part = props.part
const rawText = typeof part.text === "string" ? part.text : ""
const text = decodeHtmlEntitiesLocally(rawText)
const text = decodeHtmlEntitiesLocally(getMarkdownTextForRender(rawText))
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
Expand Down Expand Up @@ -346,15 +351,31 @@ export function Markdown(props: MarkdownProps) {
})

return (
<div
ref={containerRef}
class="markdown-body"
dir="auto"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
<>
<div
ref={containerRef}
class="markdown-body"
dir="auto"
data-view="markdown"
data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey}
data-markdown-highlight={resolved().highlightEnabled ? "true" : "false"}
innerHTML={html()}
/>
<Show when={(typeof props.part.text === "string" ? props.part.text.length : 0) > TOOL_OUTPUT_RENDER_CHARACTER_LIMIT}>
<button
type="button"
class="message-action-button markdown-source-copy"
onClick={() => void copyToClipboard(typeof props.part.text === "string" ? props.part.text : "")}
aria-label={t("messageItem.actions.copyTitle")}
title={t("messageItem.actions.copyTitle")}
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
</button>
</Show>
</>
)
}
Loading
Loading