mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
BIN
pr/artifact-live-editor/artifact-click-opens-editor.png
Normal file
BIN
pr/artifact-live-editor/artifact-click-opens-editor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Reference in New Issue
Block a user