refactor: extract app state slices (#114)

This commit is contained in:
ben
2026-01-19 09:31:41 -08:00
committed by GitHub
parent 08f985d4bc
commit 7e16b4e6eb
5 changed files with 1199 additions and 1108 deletions

View File

@@ -12,6 +12,7 @@
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:health": "node scripts/health.mjs",
"test:sessions": "node scripts/sessions.mjs",
"test:refactor": "pnpm typecheck && pnpm test:health && pnpm test:sessions",
"test:events": "node scripts/events.mjs",
"test:todos": "node scripts/todos.mjs",
"test:permissions": "node scripts/permissions.mjs",

File diff suppressed because it is too large Load Diff

277
src/app/demo-state.ts Normal file
View File

@@ -0,0 +1,277 @@
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js";
import type { Session } from "@opencode-ai/sdk/v2/client";
import type { DemoSequence, MessageWithParts, TodoItem, WorkspaceDisplay } from "./types";
import { deriveArtifacts, deriveWorkingFiles } from "./utils";
export function createDemoState(options: {
sessions: Accessor<Session[]>;
sessionStatusById: Accessor<Record<string, string>>;
messages: Accessor<MessageWithParts[]>;
todos: Accessor<TodoItem[]>;
selectedSessionId: Accessor<string | null>;
}) {
const [demoMode, setDemoMode] = createSignal(false);
const [demoSequence, setDemoSequence] = createSignal<DemoSequence>("cold-open");
const [demoSessions, setDemoSessions] = createSignal<Session[]>([]);
const [demoSessionStatusById, setDemoSessionStatusById] = createSignal<Record<string, string>>({});
const [demoMessages, setDemoMessages] = createSignal<MessageWithParts[]>([]);
const [demoTodos, setDemoTodos] = createSignal<TodoItem[]>([]);
const [demoArtifacts, setDemoArtifacts] = createSignal<ReturnType<typeof deriveArtifacts>>([]);
const [demoSelectedSessionId, setDemoSelectedSessionId] = createSignal<string | null>(null);
const [demoWorkingFiles, setDemoWorkingFiles] = createSignal<string[]>([]);
const [demoAuthorizedDirs, setDemoAuthorizedDirs] = createSignal<string[]>([]);
const [demoActiveWorkspaceDisplay, setDemoActiveWorkspaceDisplay] = createSignal<WorkspaceDisplay>({
id: "demo",
name: "Demo",
path: "~/OpenWork Demo",
preset: "starter",
});
const isDemoMode = createMemo(() => demoMode());
function setDemoSequenceState(sequence: DemoSequence) {
const now = Date.now();
setDemoSelectedSessionId(null);
const makeToolPart = (tool: string, title: string, output: string, path?: string) =>
({
id: `tool-${sequence}-${Math.random().toString(36).slice(2, 8)}`,
type: "tool",
sessionID: `demo-${sequence}`,
messageID: `msg-${sequence}-assistant`,
tool,
state: {
status: "completed",
title,
output,
...(path ? { path } : {}),
},
} as any);
const makeTextPart = (text: string) =>
({
id: `text-${sequence}-${Math.random().toString(36).slice(2, 8)}`,
type: "text",
sessionID: `demo-${sequence}`,
messageID: `msg-${sequence}-assistant`,
text,
} as any);
const baseSession = {
id: `demo-${sequence}`,
slug: "demo",
title: "Demo run",
directory: "~/OpenWork Demo",
time: { updated: now },
} as any;
const baseUser = {
id: `msg-${sequence}-user`,
sessionID: baseSession.id,
role: "user",
time: { created: now - 120000 },
} as any;
const baseAssistant = {
id: `msg-${sequence}-assistant`,
sessionID: baseSession.id,
role: "assistant",
time: { created: now - 90000 },
} as any;
if (sequence === "cold-open") {
const parts = [
makeTextPart("Scheduled weekly finance recap and prepared the grocery draft."),
makeToolPart("schedule_job", "Scheduled weekly finance recap", "Next run: Monday 9:00 AM"),
makeToolPart(
"read",
"Summarized meeting notes",
"Generated notes summary: highlights + follow-ups.",
"notes/summary.md",
),
makeToolPart("write", "Prepared grocery order", "Cart ready with 14 items.", "home/grocery-list.md"),
];
const messages = [
{ info: baseUser, parts: [{ type: "text", text: "Run the weekly stack." } as any] },
{ info: baseAssistant, parts },
];
setDemoActiveWorkspaceDisplay({
id: "demo",
name: "Home",
path: "~/OpenWork Demo",
preset: "starter",
});
setDemoSessions([baseSession]);
setDemoSessionStatusById({ [baseSession.id]: "completed" });
setDemoMessages(messages);
setDemoTodos([
{ id: "cold-1", content: "Schedule recurring recap", status: "completed", priority: "high" },
{ id: "cold-2", content: "Summarize notes", status: "completed", priority: "medium" },
{ id: "cold-3", content: "Prepare grocery order", status: "completed", priority: "medium" },
]);
setDemoAuthorizedDirs(["~/OpenWork Demo", "~/Documents/Notes"]);
setDemoSelectedSessionId(baseSession.id);
const derived = deriveArtifacts(messages as MessageWithParts[]);
setDemoArtifacts(derived);
setDemoWorkingFiles(deriveWorkingFiles(derived));
return;
}
if (sequence === "scheduler") {
const parts = [
makeTextPart("Scheduled finance recap and weekly report export."),
makeToolPart("schedule_job", "Weekly finance recap", "Next run: Monday 9:00 AM"),
makeToolPart("schedule_job", "Weekly report export", "Next run: Friday 4:00 PM"),
];
const messages = [
{ info: baseUser, parts: [{ type: "text", text: "Set up weekly finance jobs." } as any] },
{ info: baseAssistant, parts },
];
setDemoActiveWorkspaceDisplay({
id: "demo-finance",
name: "Finance",
path: "~/OpenWork Demo/finance",
preset: "starter",
});
setDemoSessions([{ ...baseSession, title: "Weekly finance recap" }]);
setDemoSessionStatusById({ [baseSession.id]: "completed" });
setDemoMessages(messages);
setDemoTodos([
{ id: "sched-1", content: "Create weekly recap", status: "completed", priority: "high" },
{ id: "sched-2", content: "Schedule export", status: "completed", priority: "medium" },
]);
setDemoAuthorizedDirs(["~/OpenWork Demo/finance"]);
setDemoSelectedSessionId(baseSession.id);
const derived = deriveArtifacts(messages as MessageWithParts[]);
setDemoArtifacts(derived);
setDemoWorkingFiles(deriveWorkingFiles(derived));
return;
}
if (sequence === "summaries") {
const parts = [
makeTextPart("Compiled the latest meeting notes and flagged action items."),
makeToolPart(
"read",
"Summarized Q1 planning notes",
"Summary saved with 6 action items.",
"notes/summary.md",
),
makeToolPart(
"write",
"Created follow-up list",
"Action items captured in follow-ups.md",
"notes/follow-ups.md",
),
];
const messages = [
{ info: baseUser, parts: [{ type: "text", text: "Summarize the latest notes." } as any] },
{ info: baseAssistant, parts },
];
setDemoActiveWorkspaceDisplay({
id: "demo-notes",
name: "Notes",
path: "~/OpenWork Demo/notes",
preset: "starter",
});
setDemoSessions([{ ...baseSession, title: "Notes summary" }]);
setDemoSessionStatusById({ [baseSession.id]: "completed" });
setDemoMessages(messages);
setDemoTodos([
{ id: "sum-1", content: "Read recent notes", status: "completed", priority: "high" },
{ id: "sum-2", content: "Create summary", status: "completed", priority: "medium" },
{ id: "sum-3", content: "Publish follow-ups", status: "completed", priority: "medium" },
]);
setDemoAuthorizedDirs(["~/OpenWork Demo/notes"]);
setDemoSelectedSessionId(baseSession.id);
const derived = deriveArtifacts(messages as MessageWithParts[]);
setDemoArtifacts(derived);
setDemoWorkingFiles(deriveWorkingFiles(derived));
return;
}
const parts = [
makeTextPart("Prepared a checkout-ready grocery cart from this week's meal plan."),
makeToolPart("read", "Parsed meal plan", "Identified 14 ingredients needed.", "home/meal-plan.md"),
makeToolPart("write", "Generated grocery list", "Grocery list ready for review.", "home/grocery-list.md"),
makeToolPart("tool.browser", "Built Instacart draft", "Cart ready with 14 items."),
];
const messages = [
{ info: baseUser, parts: [{ type: "text", text: "Prep grocery order for this week." } as any] },
{ info: baseAssistant, parts },
];
setDemoActiveWorkspaceDisplay({
id: "demo-home",
name: "Home",
path: "~/OpenWork Demo/home",
preset: "starter",
});
setDemoSessions([{ ...baseSession, title: "Grocery order" }]);
setDemoSessionStatusById({ [baseSession.id]: "completed" });
setDemoMessages(messages);
setDemoTodos([
{ id: "gro-1", content: "Read meal plan", status: "completed", priority: "high" },
{ id: "gro-2", content: "Generate list", status: "completed", priority: "medium" },
{ id: "gro-3", content: "Prepare checkout cart", status: "completed", priority: "medium" },
]);
setDemoAuthorizedDirs(["~/OpenWork Demo/home"]);
setDemoSelectedSessionId(baseSession.id);
const derived = deriveArtifacts(messages as MessageWithParts[]);
setDemoArtifacts(derived);
setDemoWorkingFiles(deriveWorkingFiles(derived));
}
const artifacts = createMemo(() => deriveArtifacts(options.messages()));
const workingFiles = createMemo(() => deriveWorkingFiles(artifacts()));
const activeSessionId = createMemo(() => (isDemoMode() ? demoSelectedSessionId() : options.selectedSessionId()));
const activeSessions = createMemo(() => (isDemoMode() ? demoSessions() : options.sessions()));
const activeSessionStatusById = createMemo(() =>
isDemoMode() ? demoSessionStatusById() : options.sessionStatusById(),
);
const activeMessages = createMemo(() => (isDemoMode() ? demoMessages() : options.messages()));
const activeTodos = createMemo(() => (isDemoMode() ? demoTodos() : options.todos()));
const activeArtifacts = createMemo(() => (isDemoMode() ? demoArtifacts() : artifacts()));
const activeWorkingFiles = createMemo(() => (isDemoMode() ? demoWorkingFiles() : workingFiles()));
const selectDemoSession = (sessionId: string) => {
setDemoSelectedSessionId(sessionId);
};
createEffect(() => {
if (!isDemoMode()) return;
setDemoSequenceState(demoSequence());
});
return {
demoMode,
setDemoMode,
demoSequence,
setDemoSequence,
isDemoMode,
demoAuthorizedDirs,
demoActiveWorkspaceDisplay,
activeSessionId,
activeSessions,
activeSessionStatusById,
activeMessages,
activeTodos,
activeArtifacts,
activeWorkingFiles,
selectDemoSession,
setDemoSelectedSessionId,
demoSelectedSessionId,
};
}

459
src/app/system-state.ts Normal file
View File

@@ -0,0 +1,459 @@
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js";
import type { Provider, Session } from "@opencode-ai/sdk/v2/client";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import type { Client, Mode, PluginScope, ReloadReason, ResetOpenworkMode, UpdateHandle } from "./types";
import { addOpencodeCacheHint, isTauriRuntime, safeStringify } from "./utils";
import { createUpdaterState } from "./updater";
import { resetOpenworkState, resetOpencodeCache } from "../lib/tauri";
import { unwrap, waitForHealthy } from "../lib/opencode";
export type NotionState = {
status: Accessor<"disconnected" | "connecting" | "connected" | "error">;
setStatus: (value: "disconnected" | "connecting" | "connected" | "error") => void;
statusDetail: Accessor<string | null>;
setStatusDetail: (value: string | null) => void;
skillInstalled: Accessor<boolean>;
setTryPromptVisible: (value: boolean) => void;
};
export function createSystemState(options: {
client: Accessor<Client | null>;
mode: Accessor<Mode | null>;
sessions: Accessor<Session[]>;
sessionStatusById: Accessor<Record<string, string>>;
refreshPlugins: (scopeOverride?: PluginScope) => Promise<void>;
refreshSkills: () => Promise<void>;
setProviders: (value: Provider[]) => void;
setProviderDefaults: (value: Record<string, string>) => void;
setProviderConnectedIds: (value: string[]) => void;
setError: (value: string | null) => void;
notion?: NotionState;
}) {
const [reloadRequired, setReloadRequired] = createSignal(false);
const [reloadReasons, setReloadReasons] = createSignal<ReloadReason[]>([]);
const [reloadLastTriggeredAt, setReloadLastTriggeredAt] = createSignal<number | null>(null);
const [reloadBusy, setReloadBusy] = createSignal(false);
const [reloadError, setReloadError] = createSignal<string | null>(null);
const [cacheRepairBusy, setCacheRepairBusy] = createSignal(false);
const [cacheRepairResult, setCacheRepairResult] = createSignal<string | null>(null);
const updater = createUpdaterState();
const {
updateAutoCheck,
setUpdateAutoCheck,
updateStatus,
setUpdateStatus,
pendingUpdate,
setPendingUpdate,
updateEnv,
setUpdateEnv,
} = updater;
const [resetModalOpen, setResetModalOpen] = createSignal(false);
const [resetModalMode, setResetModalMode] = createSignal<ResetOpenworkMode>("onboarding");
const [resetModalText, setResetModalText] = createSignal("");
const [resetModalBusy, setResetModalBusy] = createSignal(false);
const resetModalTextValue = resetModalText;
const anyActiveRuns = createMemo(() => {
const statuses = options.sessionStatusById();
return options.sessions().some((s) => statuses[s.id] === "running" || statuses[s.id] === "retry");
});
function clearOpenworkLocalStorage() {
if (typeof window === "undefined") return;
try {
const keys = Object.keys(window.localStorage);
for (const key of keys) {
if (key.startsWith("openwork.")) {
window.localStorage.removeItem(key);
}
}
// Legacy compatibility key
window.localStorage.removeItem("openwork_mode_pref");
} catch {
// ignore
}
}
function openResetModal(mode: ResetOpenworkMode) {
if (anyActiveRuns()) {
options.setError("Stop active runs before resetting.");
return;
}
options.setError(null);
setResetModalMode(mode);
setResetModalText("");
setResetModalOpen(true);
}
async function confirmReset() {
if (resetModalBusy()) return;
if (anyActiveRuns()) {
options.setError("Stop active runs before resetting.");
return;
}
if (resetModalTextValue().trim().toUpperCase() !== "RESET") return;
setResetModalBusy(true);
options.setError(null);
try {
if (isTauriRuntime()) {
await resetOpenworkState(resetModalMode());
}
clearOpenworkLocalStorage();
if (isTauriRuntime()) {
await relaunch();
} else {
window.location.reload();
}
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
setResetModalBusy(false);
}
}
function markReloadRequired(reason: ReloadReason) {
setReloadRequired(true);
setReloadLastTriggeredAt(Date.now());
setReloadReasons((current) => (current.includes(reason) ? current : [...current, reason]));
}
function clearReloadRequired() {
setReloadRequired(false);
setReloadReasons([]);
setReloadError(null);
}
const reloadCopy = createMemo(() => {
const reasons = reloadReasons();
if (!reasons.length) {
return {
title: "Reload required",
body: "OpenWork detected changes that require reloading the OpenCode instance.",
};
}
if (reasons.length === 1 && reasons[0] === "plugins") {
return {
title: "Reload required",
body: "OpenCode loads npm plugins at startup. Reload the engine to apply opencode.json changes.",
};
}
if (reasons.length === 1 && reasons[0] === "skills") {
return {
title: "Reload required",
body: "OpenCode can cache skill discovery/state. Reload the engine to make newly installed skills available.",
};
}
if (reasons.length === 1 && reasons[0] === "mcp") {
return {
title: "Reload required",
body: "OpenCode loads MCP servers at startup. Reload the engine to activate the new connection.",
};
}
return {
title: "Reload required",
body: "OpenWork detected plugin/skill/MCP changes. Reload the engine to apply them.",
};
});
const canReloadEngine = createMemo(() => {
if (!reloadRequired()) return false;
if (!options.client()) return false;
if (reloadBusy()) return false;
if (anyActiveRuns()) return false;
if (options.mode() !== "host") return false;
return true;
});
// Keep this mounted so the reload banner UX remains in the app.
createEffect(() => {
reloadRequired();
});
async function reloadEngineInstance() {
const c = options.client();
if (!c) return;
if (options.mode() !== "host") {
setReloadError("Reload is only available in Host mode.");
return;
}
if (anyActiveRuns()) {
setReloadError("A run is in progress. Stop it before reloading the engine.");
return;
}
setReloadBusy(true);
setReloadError(null);
try {
unwrap(await c.instance.dispose());
await waitForHealthy(c, { timeoutMs: 12_000 });
try {
const providerList = unwrap(await c.provider.list());
options.setProviders(providerList.all as unknown as Provider[]);
options.setProviderDefaults(providerList.default);
options.setProviderConnectedIds(providerList.connected);
} catch {
try {
const cfg = unwrap(await c.config.providers());
options.setProviders(cfg.providers);
options.setProviderDefaults(cfg.default);
options.setProviderConnectedIds([]);
} catch {
options.setProviders([]);
options.setProviderDefaults({});
options.setProviderConnectedIds([]);
}
}
await options.refreshPlugins("project").catch(() => undefined);
await options.refreshSkills().catch(() => undefined);
if (options.notion) {
let nextStatus = options.notion.status();
if (nextStatus === "connecting") {
nextStatus = "connected";
options.notion.setStatus(nextStatus);
}
if (nextStatus === "connected") {
options.notion.setStatusDetail(options.notion.statusDetail() ?? "Workspace connected");
}
try {
window.localStorage.setItem("openwork.notionStatus", nextStatus);
if (nextStatus === "connected" && options.notion.statusDetail()) {
window.localStorage.setItem("openwork.notionStatusDetail", options.notion.statusDetail() || "");
}
} catch {
// ignore
}
}
clearReloadRequired();
if (options.notion && options.notion.status() === "connected" && options.notion.skillInstalled()) {
options.notion.setTryPromptVisible(true);
}
} catch (e) {
setReloadError(e instanceof Error ? e.message : safeStringify(e));
} finally {
setReloadBusy(false);
}
}
async function repairOpencodeCache() {
if (!isTauriRuntime()) {
setCacheRepairResult("Cache repair requires the desktop app.");
return;
}
if (cacheRepairBusy()) return;
setCacheRepairBusy(true);
setCacheRepairResult(null);
options.setError(null);
try {
const result = await resetOpencodeCache();
if (result.errors.length) {
setCacheRepairResult(result.errors[0]);
return;
}
if (result.removed.length) {
setCacheRepairResult("OpenCode cache repaired. Restart the engine if it was running.");
} else {
setCacheRepairResult("No OpenCode cache found. Nothing to repair.");
}
} catch (e) {
setCacheRepairResult(e instanceof Error ? e.message : safeStringify(e));
} finally {
setCacheRepairBusy(false);
}
}
async function checkForUpdates(optionsCheck?: { quiet?: boolean }) {
if (!isTauriRuntime()) return;
const env = updateEnv();
if (env && !env.supported) {
if (!optionsCheck?.quiet) {
setUpdateStatus({
state: "error",
lastCheckedAt:
updateStatus().state === "idle"
? (updateStatus() as { state: "idle"; lastCheckedAt: number | null }).lastCheckedAt
: null,
message: env.reason ?? "Updates are not supported in this environment.",
});
}
return;
}
const prev = updateStatus();
setUpdateStatus({ state: "checking", startedAt: Date.now() });
try {
const update = (await check({ timeout: 8_000 })) as unknown as UpdateHandle | null;
const checkedAt = Date.now();
if (!update) {
setPendingUpdate(null);
setUpdateStatus({ state: "idle", lastCheckedAt: checkedAt });
return;
}
const notes = typeof update.body === "string" ? update.body : undefined;
setPendingUpdate({ update, version: update.version, notes });
setUpdateStatus({
state: "available",
lastCheckedAt: checkedAt,
version: update.version,
date: update.date,
notes,
});
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
if (optionsCheck?.quiet) {
setUpdateStatus(prev);
return;
}
setPendingUpdate(null);
setUpdateStatus({ state: "error", lastCheckedAt: null, message });
}
}
async function downloadUpdate() {
const pending = pendingUpdate();
if (!pending) return;
options.setError(null);
const state = updateStatus();
const lastCheckedAt = state.state === "available" ? state.lastCheckedAt : Date.now();
setUpdateStatus({
state: "downloading",
lastCheckedAt,
version: pending.version,
totalBytes: null,
downloadedBytes: 0,
notes: pending.notes,
});
try {
await pending.update.download((event: any) => {
if (!event || typeof event !== "object") return;
const record = event as Record<string, any>;
setUpdateStatus((current) => {
if (current.state !== "downloading") return current;
if (record.event === "Started") {
const total =
record.data && typeof record.data.contentLength === "number" ? record.data.contentLength : null;
return { ...current, totalBytes: total };
}
if (record.event === "Progress") {
const chunk = record.data && typeof record.data.chunkLength === "number" ? record.data.chunkLength : 0;
return { ...current, downloadedBytes: current.downloadedBytes + chunk };
}
return current;
});
});
setUpdateStatus({
state: "ready",
lastCheckedAt,
version: pending.version,
notes: pending.notes,
});
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
setUpdateStatus({ state: "error", lastCheckedAt, message });
}
}
async function installUpdateAndRestart() {
const pending = pendingUpdate();
if (!pending) return;
if (anyActiveRuns()) {
options.setError("Stop active runs before installing an update.");
return;
}
options.setError(null);
try {
await pending.update.install();
await pending.update.close();
await relaunch();
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
setUpdateStatus({ state: "error", lastCheckedAt: null, message });
}
}
return {
reloadRequired,
reloadReasons,
reloadLastTriggeredAt,
reloadBusy,
reloadError,
reloadCopy,
canReloadEngine,
markReloadRequired,
clearReloadRequired,
reloadEngineInstance,
cacheRepairBusy,
cacheRepairResult,
repairOpencodeCache,
updateAutoCheck,
setUpdateAutoCheck,
updateStatus,
setUpdateStatus,
pendingUpdate,
setPendingUpdate,
updateEnv,
setUpdateEnv,
checkForUpdates,
downloadUpdate,
installUpdateAndRestart,
resetModalOpen,
setResetModalOpen,
resetModalMode,
setResetModalMode,
resetModalText: resetModalTextValue,
setResetModalText,
resetModalBusy,
openResetModal,
confirmReset,
anyActiveRuns,
};
}

316
src/app/template-state.ts Normal file
View File

@@ -0,0 +1,316 @@
import { createMemo, createSignal, type Accessor } from "solid-js";
import type { Client, ModelRef, WorkspaceTemplate } from "./types";
import { buildTemplateDraft, createTemplateRecord, resetTemplateDraft } from "./templates";
import { addOpencodeCacheHint, isTauriRuntime, parseTemplateFrontmatter, safeParseJson, safeStringify } from "./utils";
import { workspaceTemplateDelete, workspaceTemplateWrite } from "../lib/tauri";
import { unwrap } from "../lib/opencode";
export function createTemplateState(options: {
client: Accessor<Client | null>;
selectedSession: Accessor<{ title?: string } | null>;
prompt: Accessor<string>;
lastPromptSent: Accessor<string>;
loadSessions: (scopeRoot?: string) => Promise<void>;
selectSession: (id: string) => Promise<void>;
setSessionModelById: (value: Record<string, ModelRef> | ((current: Record<string, ModelRef>) => Record<string, ModelRef>)) => void;
defaultModel: Accessor<ModelRef>;
modelVariant: Accessor<string | null>;
setView: (view: "onboarding" | "dashboard" | "session") => void;
isDemoMode: Accessor<boolean>;
activeWorkspaceRoot: Accessor<string>;
setBusy: (value: boolean) => void;
setBusyLabel: (value: string | null) => void;
setBusyStartedAt: (value: number | null) => void;
setError: (value: string | null) => void;
}) {
const [templates, setTemplates] = createSignal<WorkspaceTemplate[]>([]);
const [workspaceTemplatesLoaded, setWorkspaceTemplatesLoaded] = createSignal(false);
const [globalTemplatesLoaded, setGlobalTemplatesLoaded] = createSignal(false);
const [templateModalOpen, setTemplateModalOpen] = createSignal(false);
const [templateDraftTitle, setTemplateDraftTitle] = createSignal("");
const [templateDraftDescription, setTemplateDraftDescription] = createSignal("");
const [templateDraftPrompt, setTemplateDraftPrompt] = createSignal("");
const [templateDraftScope, setTemplateDraftScope] = createSignal<"workspace" | "global">("workspace");
const workspaceTemplates = createMemo(() => templates().filter((t) => t.scope === "workspace"));
const globalTemplates = createMemo(() => templates().filter((t) => t.scope === "global"));
function openTemplateModal() {
const seedTitle = options.selectedSession()?.title ?? "";
const seedPrompt = options.lastPromptSent() || options.prompt();
const nextDraft = buildTemplateDraft({ seedTitle, seedPrompt, scope: "workspace" });
resetTemplateDraft(
{
setTitle: setTemplateDraftTitle,
setDescription: setTemplateDraftDescription,
setPrompt: setTemplateDraftPrompt,
setScope: setTemplateDraftScope,
},
nextDraft.scope,
);
setTemplateDraftTitle(nextDraft.title);
setTemplateDraftPrompt(nextDraft.prompt);
setTemplateModalOpen(true);
}
async function saveTemplate() {
const draft = buildTemplateDraft({ scope: templateDraftScope() });
draft.title = templateDraftTitle().trim();
draft.description = templateDraftDescription().trim();
draft.prompt = templateDraftPrompt().trim();
if (!draft.title || !draft.prompt) {
options.setError("Template title and prompt are required.");
return;
}
if (draft.scope === "workspace") {
if (!isTauriRuntime()) {
options.setError("Workspace templates require the desktop app.");
return;
}
if (!options.activeWorkspaceRoot().trim()) {
options.setError("Pick a workspace folder first.");
return;
}
}
options.setBusy(true);
options.setBusyLabel(draft.scope === "workspace" ? "Saving workspace template" : "Saving template");
options.setBusyStartedAt(Date.now());
options.setError(null);
try {
const template = createTemplateRecord(draft);
if (draft.scope === "workspace") {
const workspaceRoot = options.activeWorkspaceRoot().trim();
await workspaceTemplateWrite({ workspacePath: workspaceRoot, template });
await loadWorkspaceTemplates({ workspaceRoot, quiet: true });
} else {
setTemplates((current) => [template, ...current]);
setGlobalTemplatesLoaded(true);
}
setTemplateModalOpen(false);
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
} finally {
options.setBusy(false);
options.setBusyLabel(null);
options.setBusyStartedAt(null);
}
}
async function deleteTemplate(templateId: string) {
const scope = templates().find((t) => t.id === templateId)?.scope;
if (scope === "workspace") {
if (!isTauriRuntime()) return;
const workspaceRoot = options.activeWorkspaceRoot().trim();
if (!workspaceRoot) return;
options.setBusy(true);
options.setBusyLabel("Deleting template");
options.setBusyStartedAt(Date.now());
options.setError(null);
try {
await workspaceTemplateDelete({ workspacePath: workspaceRoot, templateId });
await loadWorkspaceTemplates({ workspaceRoot, quiet: true });
} catch (e) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
} finally {
options.setBusy(false);
options.setBusyLabel(null);
options.setBusyStartedAt(null);
}
return;
}
setTemplates((current) => current.filter((t) => t.id !== templateId));
setGlobalTemplatesLoaded(true);
}
async function runTemplate(template: WorkspaceTemplate) {
if (options.isDemoMode()) {
options.setView("session");
return;
}
const c = options.client();
if (!c) return;
options.setBusy(true);
options.setError(null);
try {
const session = unwrap(
await c.session.create({ title: template.title, directory: options.activeWorkspaceRoot().trim() }),
);
await options.loadSessions(options.activeWorkspaceRoot().trim());
await options.selectSession(session.id);
options.setView("session");
const model = options.defaultModel();
await c.session.promptAsync({
sessionID: session.id,
model,
variant: options.modelVariant() ?? undefined,
parts: [{ type: "text", text: template.prompt }],
});
options.setSessionModelById((current) => ({
...current,
[session.id]: model,
}));
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";
options.setError(addOpencodeCacheHint(message));
} finally {
options.setBusy(false);
}
}
async function loadWorkspaceTemplates(optionsLoad?: { workspaceRoot?: string; quiet?: boolean }) {
const c = options.client();
const root = (optionsLoad?.workspaceRoot ?? options.activeWorkspaceRoot()).trim();
if (!c || !root) return;
try {
const templatesPath = ".openwork/templates";
const nodes = unwrap(await c.file.list({ directory: root, path: templatesPath }));
const entries = nodes.filter((n) => !n.ignored);
const templateFiles = entries.filter((n) => n.type === "file");
const templateDirs = entries.filter((n) => n.type === "directory");
const loaded: WorkspaceTemplate[] = [];
const seenIds = new Set<string>();
const pushTemplate = (template: WorkspaceTemplate) => {
if (seenIds.has(template.id)) return;
seenIds.add(template.id);
loaded.push(template);
};
const parseTemplateContent = (raw: string, fallbackId: string) => {
const parsedFrontmatter = parseTemplateFrontmatter(raw);
if (parsedFrontmatter) {
const meta = parsedFrontmatter.data;
const title = typeof meta.title === "string" ? meta.title : "Untitled";
const promptText = parsedFrontmatter.body ?? "";
if (!promptText.trim()) return false;
const createdAtValue = Number(meta.createdAt);
pushTemplate({
id: typeof meta.id === "string" ? meta.id : fallbackId,
title,
description: typeof meta.description === "string" ? meta.description : "",
prompt: promptText,
createdAt: Number.isFinite(createdAtValue) && createdAtValue > 0 ? createdAtValue : Date.now(),
scope: "workspace",
});
return true;
}
const parsed = safeParseJson<Partial<WorkspaceTemplate> & Record<string, unknown>>(raw);
if (!parsed) return false;
const title = typeof parsed.title === "string" ? parsed.title : "Untitled";
const promptText = typeof parsed.prompt === "string" ? parsed.prompt : "";
if (!promptText.trim()) return false;
pushTemplate({
id: typeof parsed.id === "string" ? parsed.id : fallbackId,
title,
description: typeof parsed.description === "string" ? parsed.description : "",
prompt: promptText,
createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : Date.now(),
scope: "workspace",
});
return true;
};
const readTemplatePath = async (path: string, fallbackId: string) => {
try {
const content = unwrap(await c.file.read({ directory: root, path }));
if (content.type !== "text") return false;
return parseTemplateContent(content.content, fallbackId);
} catch {
return false;
}
};
for (const dir of templateDirs) {
const basePath = `${templatesPath}/${dir.name}`;
const candidates = [`${basePath}/template.yml`, `${basePath}/template.yaml`, `${basePath}/template.json`];
for (const candidate of candidates) {
const loadedTemplate = await readTemplatePath(candidate, dir.name);
if (loadedTemplate) break;
}
}
const frontmatterFiles = templateFiles.filter((n) => /\.(yml|yaml)$/i.test(n.name));
const jsonFiles = templateFiles.filter((n) => n.name.toLowerCase().endsWith(".json"));
for (const node of frontmatterFiles) {
const fallbackId = node.name.replace(/\.(yml|yaml)$/i, "");
await readTemplatePath(node.path, fallbackId);
}
for (const node of jsonFiles) {
const fallbackId = node.name.replace(/\.json$/i, "");
await readTemplatePath(node.path, fallbackId);
}
const stable = loaded.slice().sort((a, b) => b.createdAt - a.createdAt);
setTemplates((current) => {
const globals = current.filter((t) => t.scope === "global");
return [...stable, ...globals];
});
setWorkspaceTemplatesLoaded(true);
} catch (e) {
setWorkspaceTemplatesLoaded(true);
if (!optionsLoad?.quiet) {
const message = e instanceof Error ? e.message : safeStringify(e);
options.setError(addOpencodeCacheHint(message));
}
}
}
return {
templates,
setTemplates,
workspaceTemplatesLoaded,
setWorkspaceTemplatesLoaded,
globalTemplatesLoaded,
setGlobalTemplatesLoaded,
templateModalOpen,
setTemplateModalOpen,
templateDraftTitle,
setTemplateDraftTitle,
templateDraftDescription,
setTemplateDraftDescription,
templateDraftPrompt,
setTemplateDraftPrompt,
templateDraftScope,
setTemplateDraftScope,
workspaceTemplates,
globalTemplates,
openTemplateModal,
saveTemplate,
deleteTemplate,
runTemplate,
loadWorkspaceTemplates,
};
}