mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
refactor: extract app state slices (#114)
This commit is contained in:
@@ -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",
|
||||
|
||||
1254
src/App.tsx
1254
src/App.tsx
File diff suppressed because it is too large
Load Diff
277
src/app/demo-state.ts
Normal file
277
src/app/demo-state.ts
Normal 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
459
src/app/system-state.ts
Normal 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
316
src/app/template-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user