feat(session): open artifacts in live preview editor (#559)

Replace the old markdown artifact sidebar with the Obsidian-style live preview editor panel and wire Artifacts clicks to open it. Remove the scratchpad placeholder editor.
This commit is contained in:
ben
2026-02-13 09:06:58 -08:00
committed by GitHub
parent 902c391485
commit bf02475a87
4 changed files with 60 additions and 423 deletions

View File

@@ -1,12 +1,12 @@
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
import { marked } from "marked";
import { FileText, RefreshCcw, Save, X } from "lucide-solid";
import Button from "../button";
import LiveMarkdownEditor from "../live-markdown-editor";
import type { OpenworkServerClient, OpenworkWorkspaceFileContent, OpenworkWorkspaceFileWriteResult } from "../../lib/openwork-server";
import { OpenworkServerError } from "../../lib/openwork-server";
export type MarkdownEditorSidebarProps = {
export type ArtifactMarkdownEditorProps = {
open: boolean;
path: string | null;
workspaceId: string | null;
@@ -17,116 +17,24 @@ export type MarkdownEditorSidebarProps = {
const isMarkdown = (value: string) => /\.(md|mdx|markdown)$/i.test(value);
const basename = (value: string) => value.split(/[/\\]/).filter(Boolean).pop() ?? value;
const escapeHtml = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const isSafeUrl = (url: string) => {
const normalized = (url || "").trim().toLowerCase();
if (!normalized) return false;
if (normalized.startsWith("javascript:")) return false;
if (normalized.startsWith("data:")) return normalized.startsWith("data:image/");
return true;
};
function createMarkdownRenderer() {
const renderer = new marked.Renderer();
renderer.html = ({ text }) => escapeHtml(text);
renderer.code = ({ text, lang }) => {
const language = lang ? escapeHtml(lang) : "";
return `
<div class="rounded-xl border border-dls-border bg-dls-surface px-4 py-3 my-4">
${
language
? `<div class="text-[10px] uppercase tracking-[0.2em] text-dls-secondary mb-2">${language}</div>`
: ""
}
<pre class="overflow-x-auto whitespace-pre text-[13px] leading-relaxed font-mono"><code>${escapeHtml(text)}</code></pre>
</div>
`;
};
renderer.codespan = ({ text }) => {
return `<code class="rounded-md px-1.5 py-0.5 text-[13px] font-mono bg-dls-active text-dls-text">${escapeHtml(text)}</code>`;
};
renderer.link = ({ href, title, text }) => {
const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "#") : "#";
const safeTitle = title ? escapeHtml(title) : "";
return `
<a
href="${safeHref}"
target="_blank"
rel="noopener noreferrer"
class="underline underline-offset-2 text-dls-accent"
${safeTitle ? `title="${safeTitle}"` : ""}
>
${text}
</a>
`;
};
renderer.image = ({ href, title, text }) => {
const safeHref = isSafeUrl(href ?? "") ? escapeHtml(href ?? "") : "";
const safeTitle = title ? escapeHtml(title) : "";
return `
<img
src="${safeHref}"
alt="${escapeHtml(text || "")}"
${safeTitle ? `title="${safeTitle}"` : ""}
class="max-w-full h-auto rounded-lg my-4"
/>
`;
};
return renderer;
}
function useThrottledValue(value: () => string, delayMs = 120) {
const [state, setState] = createSignal(value());
let timer: number | undefined;
createEffect(() => {
const next = value();
if (!delayMs) {
setState(next);
return;
}
if (timer) window.clearTimeout(timer);
timer = window.setTimeout(() => {
setState(next);
timer = undefined;
}, delayMs);
});
onCleanup(() => {
if (timer) window.clearTimeout(timer);
});
return state;
}
export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps) {
let textareaRef: HTMLTextAreaElement | undefined;
export default function ArtifactMarkdownEditor(props: ArtifactMarkdownEditorProps) {
const [loading, setLoading] = createSignal(false);
const [saving, setSaving] = createSignal(false);
const [error, setError] = createSignal<string | null>(null);
const [original, setOriginal] = createSignal("");
const [draft, setDraft] = createSignal("");
const [loadedPath, setLoadedPath] = createSignal<string | null>(null);
const [view, setView] = createSignal<"write" | "preview">("write");
const [baseUpdatedAt, setBaseUpdatedAt] = createSignal<number | null>(null);
const [confirmDiscardClose, setConfirmDiscardClose] = createSignal(false);
const [confirmDiscardReload, setConfirmDiscardReload] = createSignal(false);
const [confirmOverwrite, setConfirmOverwrite] = createSignal(false);
const [pendingPath, setPendingPath] = createSignal<string | null>(null);
const [pendingReason, setPendingReason] = createSignal<"switch" | "reload" | null>(null);
const [pendingReason, setPendingReason] = createSignal<"switch" | null>(null);
const path = createMemo(() => props.path?.trim() ?? "");
const title = createMemo(() => (path() ? basename(path()) : "Markdown"));
const title = createMemo(() => (path() ? basename(path()) : "Artifact"));
const dirty = createMemo(() => draft() !== original());
const canWrite = createMemo(() => Boolean(props.client && props.workspaceId));
const canSave = createMemo(() => dirty() && !saving() && canWrite());
@@ -135,20 +43,6 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
return "Connect to an OpenWork server worker to edit files.";
});
const previewSource = useThrottledValue(() => (props.open ? draft() : ""), 120);
const previewHtml = createMemo(() => {
const text = previewSource();
if (!props.open) return "";
if (!text.trim()) return "";
try {
const renderer = createMarkdownRenderer();
const result = marked.parse(text, { breaks: true, gfm: true, renderer, async: false });
return typeof result === "string" ? result : "";
} catch {
return "";
}
});
const resetState = () => {
setLoading(false);
setSaving(false);
@@ -156,10 +50,8 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
setOriginal("");
setDraft("");
setLoadedPath(null);
setView("write");
setBaseUpdatedAt(null);
setConfirmDiscardClose(false);
setConfirmDiscardReload(false);
setConfirmOverwrite(false);
setPendingPath(null);
setPendingReason(null);
@@ -187,7 +79,6 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
setDraft(result.content ?? "");
setLoadedPath(target);
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
requestAnimationFrame(() => textareaRef?.focus());
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load file";
setError(message);
@@ -220,6 +111,7 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
baseUpdatedAt: baseUpdatedAt(),
force: options?.force ?? false,
})) as OpenworkWorkspaceFileWriteResult;
setOriginal(draft());
setBaseUpdatedAt(typeof result.updatedAt === "number" ? result.updatedAt : null);
@@ -258,7 +150,8 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
void load(target);
return;
}
setConfirmDiscardReload(true);
// Reload is destructive; reuse the close-discard banner semantics.
setError("Discard changes to reload from disk (close and reopen), or save first.");
};
createEffect(() => {
@@ -269,7 +162,6 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
const target = path();
if (!target) return;
if (loading() || pendingReason() === "switch") return;
const active = loadedPath();
@@ -277,7 +169,6 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
void load(target);
return;
}
if (target === active) return;
if (!dirty()) {
@@ -306,59 +197,28 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
onCleanup(() => window.removeEventListener("keydown", onKeyDown));
});
createEffect(() => {
if (!props.open) return;
if (view() !== "write") return;
requestAnimationFrame(() => textareaRef?.focus());
});
return (
<Show when={props.open}>
<aside class="fixed inset-y-0 right-0 z-50 w-full md:w-[760px] lg:w-[920px] bg-dls-sidebar border-l border-dls-border flex flex-col">
<div class="shrink-0 px-4 py-3 border-b border-dls-border flex items-center gap-3">
<div class="shrink-0 w-9 h-9 rounded-xl bg-dls-hover border border-dls-border flex items-center justify-center text-dls-secondary">
<FileText size={16} />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
<Show when={dirty()}>
<span class="text-[10px] px-2 py-0.5 rounded-full border border-amber-7/40 bg-amber-2/30 text-amber-11">
Unsaved
</span>
</Show>
</div>
<div class="text-[11px] text-dls-secondary font-mono truncate" title={path()}>
{path()}
<div class="flex flex-col h-full min-h-0">
<div class="h-14 px-4 border-b border-dls-border flex items-center justify-between bg-dls-sidebar">
<div class="flex items-center gap-2 min-w-0">
<FileText size={16} class="text-dls-secondary shrink-0" />
<div class="min-w-0">
<div class="flex items-center gap-2">
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
<Show when={dirty()}>
<span class="text-[10px] px-2 py-0.5 rounded-full border border-amber-7/40 bg-amber-2/30 text-amber-11">
Unsaved
</span>
</Show>
</div>
<div class="text-[11px] text-dls-secondary font-mono truncate" title={path()}>
{path()}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-lg border border-dls-border bg-dls-surface p-1">
<button
type="button"
class={`h-7 px-2.5 rounded-md text-xs font-medium transition-colors ${
view() === "write"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text"
}`}
onClick={() => setView("write")}
>
Write
</button>
<button
type="button"
class={`h-7 px-2.5 rounded-md text-xs font-medium transition-colors ${
view() === "preview"
? "bg-dls-active text-dls-text"
: "text-dls-secondary hover:text-dls-text"
}`}
onClick={() => setView("preview")}
>
Preview
</button>
</div>
<Button
variant="outline"
class="text-xs h-9 py-0 px-3"
@@ -369,7 +229,6 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
<RefreshCcw size={14} class={loading() ? "animate-spin" : ""} />
Reload
</Button>
<Button
class="text-xs h-9 py-0 px-3"
onClick={() => void save()}
@@ -379,10 +238,15 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
<Save size={14} class={saving() ? "animate-pulse" : ""} />
{saving() ? "Saving..." : "Save"}
</Button>
<Button variant="ghost" class="!p-2 rounded-full" onClick={requestClose}>
<button
type="button"
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
onClick={requestClose}
title="Close"
aria-label="Close"
>
<X size={16} />
</Button>
</button>
</div>
</div>
@@ -404,15 +268,9 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
<Show when={confirmOverwrite()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">
File changed since load. Overwrite anyway?
</div>
<div class="min-w-0">File changed since load. Overwrite anyway?</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmOverwrite(false)}
>
<Button variant="outline" class="text-xs h-8 py-0 px-3" onClick={() => setConfirmOverwrite(false)}>
Cancel
</Button>
<Button
@@ -429,41 +287,11 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
</div>
</Show>
<Show when={confirmDiscardReload()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">Discard unsaved changes and reload from disk?</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmDiscardReload(false)}
>
Cancel
</Button>
<Button
variant="secondary"
class="text-xs h-8 py-0 px-3"
onClick={() => {
setConfirmDiscardReload(false);
const target = path();
if (target) void load(target);
}}
>
Reload
</Button>
</div>
</div>
</Show>
<Show when={confirmDiscardClose()}>
<div class="shrink-0 px-4 py-2 border-b border-dls-border bg-amber-2/20 text-amber-11 text-xs flex items-center justify-between gap-3">
<div class="min-w-0">Discard unsaved changes and close?</div>
<div class="shrink-0 flex items-center gap-2">
<Button
variant="outline"
class="text-xs h-8 py-0 px-3"
onClick={() => setConfirmDiscardClose(false)}
>
<Button variant="outline" class="text-xs h-8 py-0 px-3" onClick={() => setConfirmDiscardClose(false)}>
Keep
</Button>
<Button
@@ -511,58 +339,24 @@ export default function MarkdownEditorSidebar(props: MarkdownEditorSidebarProps)
>
Discard & switch
</Button>
<Button
class="text-xs h-8 py-0 px-3"
onClick={() => void save()}
disabled={!canSave()}
>
<Button class="text-xs h-8 py-0 px-3" onClick={() => void save()} disabled={!canSave()}>
Save & switch
</Button>
</div>
</div>
</Show>
<div class="flex-1 overflow-hidden">
<div class="h-full p-4">
<Show
when={view() === "preview"}
fallback={
<textarea
ref={textareaRef}
value={draft()}
onInput={(event) => setDraft(event.currentTarget.value)}
disabled={loading()}
spellcheck={false}
class="h-full w-full resize-none rounded-xl border border-dls-border bg-dls-surface px-4 py-3 font-mono text-[13px] leading-relaxed text-dls-text focus:outline-none focus:ring-2 focus:ring-[rgb(var(--dls-accent-rgb)_/_0.25)]"
/>
}
>
<div class="relative h-full">
<div
class="h-full overflow-auto rounded-xl border border-dls-border bg-dls-surface px-5 py-4 text-sm leading-relaxed text-dls-text"
classList={{
"[&_h1]:text-2xl [&_h1]:font-semibold [&_h1]:mt-4 [&_h1]:mb-2": true,
"[&_h2]:text-xl [&_h2]:font-semibold [&_h2]:mt-4 [&_h2]:mb-2": true,
"[&_h3]:text-lg [&_h3]:font-semibold [&_h3]:mt-3 [&_h3]:mb-2": true,
"[&_p]:my-3": true,
"[&_ul]:my-3 [&_ul]:pl-5 [&_ul]:list-disc": true,
"[&_ol]:my-3 [&_ol]:pl-5 [&_ol]:list-decimal": true,
"[&_li]:my-1": true,
"[&_blockquote]:border-l-2 [&_blockquote]:border-dls-border [&_blockquote]:pl-4 [&_blockquote]:text-dls-secondary [&_blockquote]:my-4": true,
"[&_hr]:my-4 [&_hr]:border-dls-border": true,
}}
innerHTML={previewHtml()}
/>
<Show when={!previewHtml()}>
<div class="pointer-events-none absolute inset-x-6 top-6 text-xs text-dls-secondary">
Nothing to preview yet. Start typing markdown in Write mode.
</div>
</Show>
</div>
</Show>
</div>
<div class="flex-1 min-h-0 overflow-hidden">
<LiveMarkdownEditor
value={draft()}
onChange={setDraft}
placeholder=""
ariaLabel="Artifact editor"
class="h-full"
autofocus
/>
</div>
</aside>
</div>
</Show>
);
}

View File

@@ -1,62 +0,0 @@
import { Show } from "solid-js";
import { FileText, Trash2, X } from "lucide-solid";
import LiveMarkdownEditor from "../live-markdown-editor";
type Props = {
value: string;
onChange: (value: string) => void;
onClose?: () => void;
onClear?: () => void;
title?: string;
};
export default function ScratchpadPanel(props: Props) {
const title = () => props.title ?? "Notes";
const canClear = () => typeof props.onClear === "function";
return (
<div class="flex flex-col h-full min-h-0">
<div class="h-14 px-4 border-b border-dls-border flex items-center justify-between bg-dls-sidebar">
<div class="flex items-center gap-2 min-w-0">
<FileText size={16} class="text-dls-secondary shrink-0" />
<div class="text-sm font-semibold text-dls-text truncate">{title()}</div>
</div>
<div class="flex items-center gap-1.5">
<Show when={canClear()}>
<button
type="button"
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
onClick={() => props.onClear?.()}
title="Clear notes"
aria-label="Clear notes"
>
<Trash2 size={16} />
</button>
</Show>
<Show when={typeof props.onClose === "function"}>
<button
type="button"
class="p-2 rounded-lg text-dls-secondary hover:text-dls-text hover:bg-dls-hover"
onClick={() => props.onClose?.()}
title="Close"
aria-label="Close"
>
<X size={16} />
</button>
</Show>
</div>
</div>
<div class="flex-1 min-h-0 overflow-hidden">
<LiveMarkdownEditor
value={props.value}
onChange={props.onChange}
placeholder="# Write notes like Obsidian\n\n- Use # headings\n- Use *italic* and **bold**\n"
ariaLabel="Scratchpad"
class="h-full"
/>
</div>
</div>
);
}

View File

@@ -28,7 +28,6 @@ import {
ChevronDown,
ChevronRight,
Cpu,
FileText,
HardDrive,
History,
ListTodo,
@@ -65,11 +64,10 @@ import soulSetupTemplate from "../data/commands/give-me-a-soul.md?raw";
import MessageList from "../components/session/message-list";
import Composer from "../components/session/composer";
import type { SidebarSectionState } from "../components/session/sidebar";
import ScratchpadPanel from "../components/session/scratchpad-panel";
import FlyoutItem from "../components/flyout-item";
import QuestionModal from "../components/question-modal";
import ArtifactsPanel from "../components/session/artifacts-panel";
import MarkdownEditorSidebar from "../components/session/markdown-editor-sidebar";
import ArtifactMarkdownEditor from "../components/session/artifact-markdown-editor";
export type SessionViewProps = {
selectedSessionId: string | null;
@@ -241,78 +239,6 @@ export default function SessionView(props: SessionViewProps) {
const [markdownEditorOpen, setMarkdownEditorOpen] = createSignal(false);
const [markdownEditorPath, setMarkdownEditorPath] = createSignal<string | null>(null);
const SCRATCHPAD_OPEN_KEY = "openwork.scratchpad.open.v1";
const SCRATCHPAD_VALUE_PREFIX = "openwork.scratchpad.value.v1";
const readBool = (key: string, fallback: boolean) => {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
if (raw === "true") return true;
if (raw === "false") return false;
return fallback;
} catch {
return fallback;
}
};
const writeBool = (key: string, value: boolean) => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, value ? "true" : "false");
} catch {
// ignore
}
};
const readString = (key: string) => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem(key) ?? "";
} catch {
return "";
}
};
const writeString = (key: string, value: string) => {
if (typeof window === "undefined") return;
try {
const trimmed = value ?? "";
if (!trimmed.trim()) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, trimmed);
}
} catch {
// ignore
}
};
const [scratchpadOpen, setScratchpadOpen] = createSignal(readBool(SCRATCHPAD_OPEN_KEY, true));
createEffect(() => writeBool(SCRATCHPAD_OPEN_KEY, scratchpadOpen()));
const scratchpadStorageKey = createMemo(() => {
const workspaceId = props.activeWorkspaceId || "workspace";
const sessionId = props.selectedSessionId || "draft";
return `${SCRATCHPAD_VALUE_PREFIX}.${workspaceId}.${sessionId}`;
});
const [scratchpadValue, setScratchpadValue] = createSignal("");
createEffect(
on(scratchpadStorageKey, (key) => {
setScratchpadValue(readString(key));
})
);
createEffect(() => {
const key = scratchpadStorageKey();
const value = scratchpadValue();
let timer: number | undefined;
if (typeof window !== "undefined") {
timer = window.setTimeout(() => writeString(key, value), 150);
}
onCleanup(() => {
if (typeof window !== "undefined" && timer) window.clearTimeout(timer);
});
});
// When a session is selected (i.e. we are in SessionView), the right sidebar is
// navigation-only. Avoid showing any tab as "selected" to reduce confusion.
const showRightSidebarSelection = createMemo(() => !props.selectedSessionId);
@@ -2139,20 +2065,6 @@ export default function SessionView(props: SessionViewProps) {
</div>
<div class="flex items-center gap-2">
<button
type="button"
class={`hidden lg:flex h-9 items-center gap-2 rounded-xl border border-dls-border px-3 text-xs font-medium transition-colors ${scratchpadOpen()
? "bg-dls-active text-dls-text"
: "bg-dls-hover text-dls-secondary hover:text-dls-text hover:bg-dls-active"
}`}
onClick={() => setScratchpadOpen((v) => !v)}
title="Toggle notes"
aria-label="Toggle notes"
>
<FileText size={14} class="shrink-0" />
<span>Notes</span>
</button>
<button
type="button"
class={`h-9 w-9 flex items-center justify-center rounded-lg transition-colors ${
@@ -2401,17 +2313,19 @@ export default function SessionView(props: SessionViewProps) {
</Show>
</div>
<Show when={scratchpadOpen()}>
<aside class="hidden lg:flex w-[420px] shrink-0 border-l border-dls-border bg-dls-sidebar">
<ScratchpadPanel
value={scratchpadValue()}
onChange={setScratchpadValue}
onClose={() => setScratchpadOpen(false)}
onClear={() => setScratchpadValue("")}
/>
</aside>
</Show>
</div>
<Show when={markdownEditorOpen()}>
<aside class="hidden lg:flex w-[520px] shrink-0 border-l border-dls-border bg-dls-sidebar">
<ArtifactMarkdownEditor
open={markdownEditorOpen()}
path={markdownEditorPath()}
workspaceId={props.openworkServerWorkspaceId}
client={props.openworkServerClient}
onClose={closeMarkdownEditor}
onToast={(message) => setToastMessage(message)}
/>
</aside>
</Show>
</div>
<Show when={todoCount() > 0}>
<div class="mx-auto w-full max-w-[68ch] px-4">
@@ -2675,15 +2589,6 @@ export default function SessionView(props: SessionViewProps) {
onOpenBots={openConfig}
/>
<MarkdownEditorSidebar
open={markdownEditorOpen()}
path={markdownEditorPath()}
workspaceId={props.openworkServerWorkspaceId}
client={props.openworkServerClient}
onClose={closeMarkdownEditor}
onToast={(message) => setToastMessage(message)}
/>
<Show when={props.activePermission}>
<div class="absolute inset-0 z-50 bg-gray-1/60 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-gray-2 border border-amber-7/30 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden">

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB