-
- {messages.length === 0 ? (
-
- ) : (
-
-
-
-
- {messages.map((message, index) => {
- const messageKey =
- message.id ||
- `${message.role}-${index}-${message.parts.length}`
- return (
-
- {
- void addToolApprovalResponse({
- id: approvalId,
- approved,
- options: {
- body: {
- agentId: agent.id,
- sessionId: activeSessionId,
- model: resolvedModel,
- thinkingLevel,
+
+
+
+ {messages.length === 0 ? (
+
+ ) : (
+
+
+
+
+ {messages.map((message, index) => {
+ const messageKey =
+ message.id ||
+ `${message.role}-${index}-${message.parts.length}`
+ return (
+
+ {
+ void addToolApprovalResponse({
+ id: approvalId,
+ approved,
+ options: {
+ body: {
+ agentId: agent.id,
+ sessionId: activeSessionId,
+ model: resolvedModel,
+ thinkingLevel,
+ },
},
- },
- })
- }}
+ })
+ }}
+ />
+
+ )
+ })}
+ {sessionArtifacts.length > 0 ? (
+
+
+ Session artifacts
+
+ {sessionArtifacts.map((artifact) => (
+
+ ))}
+
+ ) : null}
+ {showThinking && (
+
+
- )
- })}
- {showThinking && (
-
-
-
- )}
-
-
-
-
-
- )}
-
-
- {!activeSessionId ? (
-
{
- const previousPath = workspacePath
- setWorkspacePath(path)
- writeRecentWorkspaces(
- [path, previousPath, ...recentWorkspaces]
- .filter(
- (item): item is string =>
- Boolean(item) && String(item) === item
- )
- .filter(
- (item, index, items) => items.indexOf(item) === index
- )
- .slice(0, 8)
- )
- }}
- onRemoveRecent={(path) => {
- const next = recentWorkspaces.filter((item) => item !== path)
- writeRecentWorkspaces(next)
- }}
- />
- ) : null}
- {error && (
-
- Request failed
- {error.message}
-
+ )}
+
+
+
+
+
)}
- {activeSessionId ? (
- {
- const response = await fetch(
- `/api/checkpoints/${checkpointId}/resume`,
- {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- followUpInstruction: instruction,
- model: resolvedModel,
- thinkingLevel,
- }),
+
+ {!activeSessionId ? (
+
{
+ const previousPath = workspacePath
+ setWorkspacePath(path)
+ writeRecentWorkspaces(
+ [path, previousPath, ...recentWorkspaces]
+ .filter(
+ (item): item is string =>
+ Boolean(item) && String(item) === item
+ )
+ .filter(
+ (item, index, items) => items.indexOf(item) === index
+ )
+ .slice(0, 8)
+ )
+ }}
+ onRemoveRecent={(path) => {
+ const next = recentWorkspaces.filter((item) => item !== path)
+ writeRecentWorkspaces(next)
+ }}
+ />
+ ) : null}
+ {error && (
+
+ Request failed
+ {error.message}
+
+ )}
+ {activeSessionId ? (
+ {
+ const response = await fetch(
+ `/api/checkpoints/${checkpointId}/resume`,
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ followUpInstruction: instruction,
+ model: resolvedModel,
+ thinkingLevel,
+ }),
+ }
+ )
+ if (!response.ok) {
+ const payload: { error?: string } = await response
+ .json()
+ .catch(() => ({}))
+ throw new Error(
+ payload.error ?? "The checkpoint could not be resumed."
+ )
}
- )
- if (!response.ok) {
- const payload: { error?: string } = await response
- .json()
- .catch(() => ({}))
- throw new Error(
- payload.error ?? "The checkpoint could not be resumed."
+ const payload: { checkpoint?: { id: string } } =
+ await response.json()
+ if (!payload.checkpoint?.id) {
+ throw new Error("The checkpoint could not be resumed.")
+ }
+ await submitPrompt(
+ instruction,
+ payload.checkpoint.id,
+ instruction
)
+ }}
+ />
+ ) : null}
+ {
+ setModelOverride(nextModel)
+ const next = visibleModels.find((item) => item.id === nextModel)
+ if (
+ thinkingLevel !== "auto" &&
+ !next?.thinkingLevels.includes(thinkingLevel)
+ ) {
+ setThinkingLevel("auto")
}
- const payload: { checkpoint?: { id: string } } =
- await response.json()
- if (!payload.checkpoint?.id) {
- throw new Error("The checkpoint could not be resumed.")
- }
- await submitPrompt(
- instruction,
- payload.checkpoint.id,
- instruction
- )
}}
+ thinkingLevel={thinkingLevel}
+ onThinkingLevelChange={setThinkingLevel}
+ isBusy={isBusy}
+ agentName={agent.name}
+ onSubmit={(text) => {
+ void submitPrompt(text)
+ }}
+ onStop={() => stop()}
/>
- ) : null}
- {
- setModelOverride(nextModel)
- const next = visibleModels.find((item) => item.id === nextModel)
- if (
- thinkingLevel !== "auto" &&
- !next?.thinkingLevels.includes(thinkingLevel)
- ) {
- setThinkingLevel("auto")
- }
- }}
- thinkingLevel={thinkingLevel}
- onThinkingLevelChange={setThinkingLevel}
- isBusy={isBusy}
- agentName={agent.name}
- onSubmit={(text) => {
- void submitPrompt(text)
- }}
- onStop={() => stop()}
- />
+
-
-
+
+ {inspectedArtifact ? (
+ <>
+
+
+ setInspectedArtifact(null)}
+ />
+
+ >
+ ) : null}
+
)
}
diff --git a/components/checkpoint-history.tsx b/components/checkpoint-history.tsx
index 47fbdc9..a4d77f5 100644
--- a/components/checkpoint-history.tsx
+++ b/components/checkpoint-history.tsx
@@ -8,13 +8,13 @@ import { Textarea } from "@/components/ui/textarea"
import { CheckpointList, type CheckpointRecord } from "@/lib/alluka/schema"
import { Schema } from "effect"
-const labels: Record
= {
+const labels = {
running: "running",
completed: "completed",
interrupted: "interrupted",
failed: "failed",
cancelled: "cancelled",
-}
+} satisfies Record
export function CheckpointHistory({
sessionId,
diff --git a/components/parts/artifact-part.tsx b/components/parts/artifact-part.tsx
index a85b303..ad6de56 100644
--- a/components/parts/artifact-part.tsx
+++ b/components/parts/artifact-part.tsx
@@ -1,15 +1,34 @@
+"use client"
+
+import * as React from "react"
import { FileOutput } from "lucide-react"
import { ToolResult, ToolResultOutput } from "@/components/agents/tool-result"
+import { ArtifactCard } from "@/components/artifact-card"
+import type { ArtifactReference } from "@/components/artifact-card"
import type { SaveArtifactToolPart } from "@/tools"
export function ArtifactPart({
part,
stopped = false,
+ sessionId,
+ agentId,
+ onSaved,
+ onOpen,
}: {
part: SaveArtifactToolPart
stopped?: boolean
+ sessionId?: string | null
+ agentId?: string
+ onSaved?: () => void
+ onOpen?: (artifact: ArtifactReference) => void
}) {
+ const artifactId =
+ part.state === "output-available" ? part.output.artifactId : null
+ React.useEffect(() => {
+ if (artifactId) onSaved?.()
+ }, [artifactId, onSaved])
+
if (part.state === "input-streaming" || part.state === "input-available") {
return (
+ )
+ }
return (
+ )
+}
+
+function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
+ return
+}
+
+function ResizableHandle({
+ withHandle,
+ className,
+ ...props
+}: ResizablePrimitive.SeparatorProps & {
+ withHandle?: boolean
+}) {
+ return (
+ div]:rotate-90",
+ className
+ )}
+ {...props}
+ >
+ {withHandle && (
+
+ )}
+
+ )
+}
+
+export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
diff --git a/lib/alluka/artifact-preview.test.ts b/lib/alluka/artifact-preview.test.ts
new file mode 100644
index 0000000..4ff4ce6
--- /dev/null
+++ b/lib/alluka/artifact-preview.test.ts
@@ -0,0 +1,25 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+
+import {
+ createIsolatedHtmlDocument,
+ isHtmlArtifact,
+} from "./artifact-preview.ts"
+
+test("HTML artifact previews allow inline scripts but deny network and navigation", () => {
+ const preview = createIsolatedHtmlDocument(
+ 'Open'
+ )
+
+ assert.match(preview, /default-src 'none'/)
+ assert.match(preview, /connect-src 'none'/)
+ assert.match(preview, /script-src 'unsafe-inline'/)
+ assert.match(preview, /navigate-to 'none'/)
+ assert.match(preview, //)
+})
+
+test("HTML artifact detection accepts media types and common extensions", () => {
+ assert.equal(isHtmlArtifact("report.txt", "text/html; charset=utf-8"), true)
+ assert.equal(isHtmlArtifact("report.HTML", "text/plain"), true)
+ assert.equal(isHtmlArtifact("report.md", "text/markdown"), false)
+})
diff --git a/lib/alluka/artifact-preview.ts b/lib/alluka/artifact-preview.ts
new file mode 100644
index 0000000..a748203
--- /dev/null
+++ b/lib/alluka/artifact-preview.ts
@@ -0,0 +1,64 @@
+const ARTIFACT_PREVIEW_CSP = [
+ "default-src 'none'",
+ "base-uri 'none'",
+ "connect-src 'none'",
+ "font-src data:",
+ "form-action 'none'",
+ "frame-src 'none'",
+ "img-src data:",
+ "media-src data:",
+ "object-src 'none'",
+ "script-src 'unsafe-inline'",
+ "style-src 'unsafe-inline'",
+ "navigate-to 'none'",
+].join("; ")
+
+export function isHtmlArtifact(name: string, mediaType: string) {
+ return (
+ mediaType.toLowerCase().split(";", 1)[0] === "text/html" ||
+ /\.html?$/i.test(name)
+ )
+}
+
+function removeUnsafeMarkup(content: string) {
+ const parser = globalThis.DOMParser
+ ? new globalThis.DOMParser().parseFromString(content, "text/html")
+ : null
+ if (!parser) return content
+
+ parser
+ .querySelectorAll(
+ "base, embed, form, frame, iframe, link, meta, object, portal"
+ )
+ .forEach((element) => element.remove())
+ parser.querySelectorAll("*").forEach((element) => {
+ for (const attribute of Array.from(element.attributes, (item) => ({
+ name: item.name,
+ value: item.value,
+ }))) {
+ const name = attribute.name.toLowerCase()
+ if (
+ name === "action" ||
+ name === "data" ||
+ name === "formaction" ||
+ name === "href" ||
+ name.endsWith(":href") ||
+ name === "poster" ||
+ (name === "src" && !attribute.value.startsWith("data:")) ||
+ name === "srcset"
+ ) {
+ element.removeAttribute(attribute.name)
+ }
+ }
+ })
+ parser
+ .querySelectorAll("img:not([src])")
+ .forEach((element) => element.remove())
+ return parser.body.innerHTML
+}
+
+export function createIsolatedHtmlDocument(content: string) {
+ // The iframe has an opaque origin and permits scripts only inside that sandbox.
+ // Its CSP blocks all connections, navigation, and application-origin resource access.
+ return `${removeUnsafeMarkup(content)}`
+}
diff --git a/lib/alluka/checkpoint.ts b/lib/alluka/checkpoint.ts
index 55df0ba..98f118b 100644
--- a/lib/alluka/checkpoint.ts
+++ b/lib/alluka/checkpoint.ts
@@ -8,14 +8,11 @@ export function checkpointConfigurationMatches(
)
}
-export function automationFailureFinalization(checkpointId: string | null): {
- finishAutomation: true
- finishCheckpoint: boolean
-} {
+export function automationFailureFinalization(checkpointId: string | null) {
return { finishAutomation: true, finishCheckpoint: checkpointId !== null }
}
-export function sanitizeCheckpointReason(raw: unknown): string {
+export function sanitizeCheckpointReason(raw: Error | string): string {
let value = raw instanceof Error ? raw.message : String(raw)
value = value
.replace(/Authorization:\s*Bearer\s+\S+/gi, "Authorization: [REDACTED]")
diff --git a/lib/alluka/repository.ts b/lib/alluka/repository.ts
index 77f4db0..e632a89 100644
--- a/lib/alluka/repository.ts
+++ b/lib/alluka/repository.ts
@@ -1180,6 +1180,28 @@ export class AllukaRepository extends Context.Service()(
}
),
+ readArtifact: Effect.fn("AllukaRepository.readArtifact")(function* (
+ id: string,
+ sessionId: string,
+ agentId: string
+ ) {
+ const rows = yield* sql.unsafe(
+ `${artifactSelect} WHERE id = ? AND session_id = ? AND agent_id = ? LIMIT 1`,
+ [id, sessionId, agentId]
+ )
+ const artifacts =
+ yield* Schema.decodeUnknownEffect(ArtifactList)(rows)
+ const artifact = artifacts[0]
+ if (!artifact) return null
+
+ const fs = yield* FileSystem.FileSystem
+ const storedName = path.basename(artifact.relativePath)
+ const content = yield* fs.readFileString(
+ path.join(ALLUKA_ARTIFACTS_DIR, artifact.sessionId, storedName)
+ )
+ return { artifact, content }
+ }),
+
listArtifacts: Effect.fn("AllukaRepository.listArtifacts")(function* (
agentId?: string
) {
diff --git a/package.json b/package.json
index c978be8..382059f 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"eve:build": "./node_modules/node/bin/node ./node_modules/eve/bin/eve.js build",
"lint": "eslint",
"oxlint": "oxlint",
+ "eval": "node --experimental-strip-types --test lib/alluka/artifact-preview.test.ts",
"format": "prettier --write \"**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
"eval:skills": "node --experimental-strip-types --test lib/alluka/skill-utils.test.ts",
@@ -50,6 +51,7 @@
"react": "19.2.4",
"react-dom": "19.2.4",
"react-markdown": "^10.1.0",
+ "react-resizable-panels": "^4.12.2",
"remark-gfm": "^4.0.1",
"shiki": "^4.4.3",
"tailwind-merge": "^3.6.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f2f1063..de53260 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.18)(react@19.2.4)(supports-color@7.2.0)
+ react-resizable-panels:
+ specifier: ^4.12.2
+ version: 4.12.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1(supports-color@7.2.0)
@@ -3735,6 +3738,12 @@ packages:
'@types/react': '>=18'
react: '>=18'
+ react-resizable-panels@4.12.2:
+ resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+
react@19.2.4:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
@@ -8269,6 +8278,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ react-resizable-panels@4.12.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
react@19.2.4: {}
readable-stream@3.6.2:
diff --git a/tsconfig.json b/tsconfig.json
index 7474927..2397ed3 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,7 @@
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
+ "allowImportingTsExtensions": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,