mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat(app): add server-backed session empty states (#1057)
* feat(app): seed blueprint-driven session empty states * fix(app): use server-backed session blueprints
This commit is contained in:
@@ -30,6 +30,23 @@ Auto-detection can exist as a convenience, but should be tiered and explainable:
|
||||
|
||||
The readiness check should be a clear, single command (e.g. `docker info`) and the UI should show the exact error output when it fails.
|
||||
|
||||
## Filesystem mutation policy
|
||||
|
||||
OpenWork should route filesystem mutations through the OpenWork server whenever possible.
|
||||
|
||||
Why:
|
||||
|
||||
- the server is the one place that can apply the same behavior for both local and remote workspaces
|
||||
- server-routed writes keep permission checks, approvals, audit trails, and reload events consistent
|
||||
- Tauri-only filesystem mutations only work in desktop host mode and break parity with remote execution
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Any UI feature that changes workspace files or config should call an OpenWork server endpoint first.
|
||||
- Local Tauri filesystem commands are a host-mode fallback, not the primary product surface.
|
||||
- If a feature cannot yet write through the OpenWork server, treat that as an architecture gap and close it before depending on direct local writes.
|
||||
- Reads can fall back locally when necessary, but writes should be designed around the OpenWork server path.
|
||||
|
||||
## opencode primitives
|
||||
how to pick the right extension abstraction for
|
||||
@opencode
|
||||
|
||||
@@ -72,6 +72,10 @@ import {
|
||||
mapConfigProvidersToList,
|
||||
providerPriorityRank,
|
||||
} from "./utils/providers";
|
||||
import {
|
||||
buildDefaultWorkspaceBlueprint,
|
||||
normalizeWorkspaceOpenworkConfig,
|
||||
} from "./lib/workspace-blueprints";
|
||||
import { SYNTHETIC_SESSION_ERROR_MESSAGE_PREFIX } from "./types";
|
||||
import type {
|
||||
Client,
|
||||
@@ -105,6 +109,7 @@ import type {
|
||||
OpencodeConnectStatus,
|
||||
ScheduledJob,
|
||||
WorkspacePreset,
|
||||
WorkspaceOpenworkConfig,
|
||||
} from "./types";
|
||||
import {
|
||||
clearStartupPreference,
|
||||
@@ -936,6 +941,8 @@ export default function App() {
|
||||
const [openworkAuditStatus, setOpenworkAuditStatus] = createSignal<"idle" | "loading" | "error">("idle");
|
||||
const [openworkAuditError, setOpenworkAuditError] = createSignal<string | null>(null);
|
||||
const [devtoolsWorkspaceId, setDevtoolsWorkspaceId] = createSignal<string | null>(null);
|
||||
const [activeWorkspaceServerConfig, setActiveWorkspaceServerConfig] =
|
||||
createSignal<WorkspaceOpenworkConfig | null>(null);
|
||||
|
||||
const openworkServerBaseUrl = createMemo(() => {
|
||||
const pref = startupPreference();
|
||||
@@ -5014,6 +5021,9 @@ export default function App() {
|
||||
|
||||
const activeAuthorizedDirs = createMemo(() => workspaceStore.authorizedDirs());
|
||||
const activeWorkspaceDisplay = createMemo(() => workspaceStore.activeWorkspaceDisplay());
|
||||
const resolvedActiveWorkspaceConfig = createMemo(
|
||||
() => activeWorkspaceServerConfig() ?? workspaceStore.workspaceConfig(),
|
||||
);
|
||||
const activePermissionMemo = createMemo(() => activePermission());
|
||||
const migrationRepairUnavailableReason = createMemo<string | null>(() => {
|
||||
if (workspaceStore.canRepairOpencodeMigration()) return null;
|
||||
@@ -5046,6 +5056,57 @@ export default function App() {
|
||||
});
|
||||
const [autoConnectAttempted, setAutoConnectAttempted] = createSignal(false);
|
||||
|
||||
createEffect(() => {
|
||||
const workspace = activeWorkspaceDisplay();
|
||||
const openworkClient = openworkServerClient();
|
||||
const workspaceId = openworkServerWorkspaceId();
|
||||
const capabilities = resolvedOpenworkCapabilities();
|
||||
const canReadConfig =
|
||||
openworkServerStatus() === "connected" &&
|
||||
Boolean(openworkClient && workspaceId && capabilities?.config?.read);
|
||||
|
||||
if (!canReadConfig || !openworkClient || !workspaceId) {
|
||||
setActiveWorkspaceServerConfig(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const loadWorkspaceConfig = async () => {
|
||||
try {
|
||||
const config = await openworkClient.getConfig(workspaceId);
|
||||
if (cancelled) return;
|
||||
|
||||
const normalized = normalizeWorkspaceOpenworkConfig(
|
||||
config.openwork,
|
||||
workspace.preset,
|
||||
);
|
||||
|
||||
if (!normalized.blueprint) {
|
||||
setActiveWorkspaceServerConfig({
|
||||
...normalized,
|
||||
blueprint: buildDefaultWorkspaceBlueprint(
|
||||
normalized.workspace?.preset ?? workspace.preset ?? "starter",
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveWorkspaceServerConfig(normalized);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setActiveWorkspaceServerConfig(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadWorkspaceConfig();
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
|
||||
const [appVersion, setAppVersion] = createSignal<string | null>(null);
|
||||
const [launchUpdateCheckTriggered, setLaunchUpdateCheckTriggered] = createSignal(false);
|
||||
|
||||
@@ -7284,6 +7345,7 @@ export default function App() {
|
||||
toggleSettings: () => toggleSettingsView("general"),
|
||||
activeWorkspaceDisplay: activeWorkspaceDisplay(),
|
||||
activeWorkspaceRoot: workspaceStore.activeWorkspaceRoot().trim(),
|
||||
activeWorkspaceConfig: resolvedActiveWorkspaceConfig(),
|
||||
workspaces: workspaceStore.workspaces(),
|
||||
activeWorkspaceId: workspaceStore.activeWorkspaceId(),
|
||||
connectingWorkspaceId: workspaceStore.connectingWorkspaceId(),
|
||||
|
||||
122
apps/app/src/app/lib/workspace-blueprints.ts
Normal file
122
apps/app/src/app/lib/workspace-blueprints.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { WorkspaceBlueprint, WorkspaceBlueprintStarter, WorkspaceOpenworkConfig } from "../types";
|
||||
import { parseTemplateFrontmatter } from "../utils";
|
||||
|
||||
import browserSetupTemplate from "../data/commands/browser-setup.md?raw";
|
||||
|
||||
const BROWSER_AUTOMATION_QUICKSTART_PROMPT = (() => {
|
||||
const parsed = parseTemplateFrontmatter(browserSetupTemplate);
|
||||
return (parsed?.body ?? browserSetupTemplate).trim();
|
||||
})();
|
||||
|
||||
export const DEFAULT_EMPTY_STATE_COPY = {
|
||||
title: "What do you want to do?",
|
||||
body: "Pick a starting point or just type below.",
|
||||
};
|
||||
|
||||
export function defaultBlueprintStartersForPreset(preset: string): WorkspaceBlueprintStarter[] {
|
||||
switch (preset.trim().toLowerCase()) {
|
||||
case "automation":
|
||||
return [
|
||||
{
|
||||
id: "automation-command",
|
||||
kind: "prompt",
|
||||
title: "Create a reusable command",
|
||||
description: "Turn a repeated workflow into a slash command for this workspace.",
|
||||
prompt:
|
||||
"Help me create a reusable /command for this workspace. Ask what workflow I want to automate, then draft the command.",
|
||||
},
|
||||
{
|
||||
id: "automation-blueprint",
|
||||
kind: "session",
|
||||
title: "Plan an automation blueprint",
|
||||
description: "Design a repeatable workflow with skills, commands, and handoff steps.",
|
||||
prompt:
|
||||
"Help me design a reusable automation blueprint for this workspace. Ask what should be standardized, then propose the workflow.",
|
||||
},
|
||||
];
|
||||
case "minimal":
|
||||
return [
|
||||
{
|
||||
id: "minimal-explore",
|
||||
kind: "prompt",
|
||||
title: "Explore this workspace",
|
||||
description: "Summarize the files and suggest the best first task to tackle.",
|
||||
prompt: "Summarize this workspace, point out the most important files, and suggest the best first task.",
|
||||
},
|
||||
];
|
||||
default:
|
||||
return [
|
||||
{
|
||||
id: "starter-connect-anthropic",
|
||||
kind: "action",
|
||||
title: "Connect Claude",
|
||||
description: "Add your Anthropic provider so Claude models are ready in new sessions.",
|
||||
action: "connect-anthropic",
|
||||
},
|
||||
{
|
||||
id: "starter-browser",
|
||||
kind: "session",
|
||||
title: "Automate your browser",
|
||||
description: "Set up browser actions and run reliable web tasks from OpenWork.",
|
||||
prompt: BROWSER_AUTOMATION_QUICKSTART_PROMPT,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultBlueprintCopyForPreset(preset: string) {
|
||||
switch (preset.trim().toLowerCase()) {
|
||||
case "automation":
|
||||
return {
|
||||
title: "What do you want to automate?",
|
||||
body: "Start from a reusable workflow or type your own task below.",
|
||||
};
|
||||
case "minimal":
|
||||
return {
|
||||
title: "Start with a task",
|
||||
body: "Ask a question about this workspace or use a starter prompt.",
|
||||
};
|
||||
default:
|
||||
return DEFAULT_EMPTY_STATE_COPY;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDefaultWorkspaceBlueprint(preset: string): WorkspaceBlueprint {
|
||||
const copy = defaultBlueprintCopyForPreset(preset);
|
||||
return {
|
||||
emptyState: {
|
||||
title: copy.title,
|
||||
body: copy.body,
|
||||
starters: defaultBlueprintStartersForPreset(preset),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceOpenworkConfig(
|
||||
value: unknown,
|
||||
preset?: string | null,
|
||||
): WorkspaceOpenworkConfig {
|
||||
const candidate =
|
||||
value && typeof value === "object"
|
||||
? (value as Partial<WorkspaceOpenworkConfig>)
|
||||
: {};
|
||||
|
||||
const normalizedPreset =
|
||||
candidate.workspace?.preset?.trim() || preset?.trim() || null;
|
||||
|
||||
return {
|
||||
version: typeof candidate.version === "number" ? candidate.version : 1,
|
||||
workspace:
|
||||
candidate.workspace || normalizedPreset
|
||||
? {
|
||||
...(candidate.workspace ?? {}),
|
||||
preset: normalizedPreset,
|
||||
}
|
||||
: null,
|
||||
authorizedRoots: Array.isArray(candidate.authorizedRoots)
|
||||
? candidate.authorizedRoots.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
blueprint: candidate.blueprint ?? null,
|
||||
reload: candidate.reload ?? null,
|
||||
};
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import type {
|
||||
View,
|
||||
WorkspaceConnectionState,
|
||||
WorkspaceDisplay,
|
||||
WorkspaceOpenworkConfig,
|
||||
WorkspaceSessionGroup,
|
||||
} from "../types";
|
||||
|
||||
@@ -101,12 +102,13 @@ import {
|
||||
isTauriRuntime,
|
||||
isWindowsPlatform,
|
||||
normalizeDirectoryPath,
|
||||
parseTemplateFrontmatter,
|
||||
} from "../utils";
|
||||
import { finishPerf, perfNow, recordPerfLog } from "../lib/perf-log";
|
||||
import { normalizeLocalFilePath } from "../lib/local-file-path";
|
||||
|
||||
import browserSetupTemplate from "../data/commands/browser-setup.md?raw";
|
||||
import {
|
||||
defaultBlueprintCopyForPreset,
|
||||
defaultBlueprintStartersForPreset,
|
||||
} from "../lib/workspace-blueprints";
|
||||
|
||||
import MessageList from "../components/session/message-list";
|
||||
import Composer from "../components/session/composer";
|
||||
@@ -126,6 +128,7 @@ export type SessionViewProps = {
|
||||
toggleSettings: () => void;
|
||||
activeWorkspaceDisplay: WorkspaceDisplay;
|
||||
activeWorkspaceRoot: string;
|
||||
activeWorkspaceConfig: WorkspaceOpenworkConfig | null;
|
||||
workspaces: WorkspaceInfo[];
|
||||
activeWorkspaceId: string;
|
||||
connectingWorkspaceId: string | null;
|
||||
@@ -316,10 +319,14 @@ type SkillsSetBundleV1 = {
|
||||
};
|
||||
};
|
||||
|
||||
const BROWSER_AUTOMATION_QUICKSTART_PROMPT = (() => {
|
||||
const parsed = parseTemplateFrontmatter(browserSetupTemplate);
|
||||
return (parsed?.body ?? browserSetupTemplate).trim();
|
||||
})();
|
||||
type ResolvedEmptyStateStarter = {
|
||||
id: string;
|
||||
kind: "prompt" | "session" | "action";
|
||||
title: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
action?: "connect-anthropic";
|
||||
};
|
||||
|
||||
const INITIAL_MESSAGE_WINDOW = 140;
|
||||
const MESSAGE_WINDOW_LOAD_CHUNK = 120;
|
||||
@@ -3504,19 +3511,6 @@ export default function SessionView(props: SessionViewProps) {
|
||||
props.sendPromptAsync(draft).catch(() => undefined);
|
||||
};
|
||||
|
||||
const handleBrowserAutomationQuickstart = () => {
|
||||
const text =
|
||||
BROWSER_AUTOMATION_QUICKSTART_PROMPT ||
|
||||
"Try Chrome DevTools MCP now. If it is unavailable, explain how to connect Control Chrome in OpenWork and ask me to retry.";
|
||||
handleSendPrompt({
|
||||
mode: "prompt",
|
||||
text,
|
||||
resolvedText: text,
|
||||
parts: [{ type: "text", text }],
|
||||
attachments: [],
|
||||
});
|
||||
};
|
||||
|
||||
const isSandboxWorkspace = createMemo(() =>
|
||||
Boolean(
|
||||
(props.activeWorkspaceDisplay as any)?.sandboxContainerName?.trim(),
|
||||
@@ -3923,6 +3917,98 @@ export default function SessionView(props: SessionViewProps) {
|
||||
(props.providerConnectedIds ?? []).some((id) => id.trim().toLowerCase() === "anthropic")
|
||||
);
|
||||
const showNewSessionProviderCta = createMemo(() => !hasAnthropicProviderConnected());
|
||||
const emptyStatePreset = createMemo(
|
||||
() =>
|
||||
props.activeWorkspaceConfig?.workspace?.preset?.trim() ||
|
||||
props.activeWorkspaceDisplay.preset ||
|
||||
"starter",
|
||||
);
|
||||
const blueprintEmptyState = createMemo(
|
||||
() => props.activeWorkspaceConfig?.blueprint?.emptyState ?? null,
|
||||
);
|
||||
const emptyStateTitle = createMemo(() => {
|
||||
const configured = blueprintEmptyState()?.title?.trim();
|
||||
if (configured) return configured;
|
||||
return defaultBlueprintCopyForPreset(emptyStatePreset()).title;
|
||||
});
|
||||
const emptyStateBody = createMemo(() => {
|
||||
const configured = blueprintEmptyState()?.body?.trim();
|
||||
if (configured) return configured;
|
||||
return defaultBlueprintCopyForPreset(emptyStatePreset()).body;
|
||||
});
|
||||
const emptyStateStarters = createMemo<ResolvedEmptyStateStarter[]>(() => {
|
||||
const configured = blueprintEmptyState()?.starters;
|
||||
const source =
|
||||
Array.isArray(configured)
|
||||
? configured
|
||||
: defaultBlueprintStartersForPreset(emptyStatePreset());
|
||||
|
||||
const resolved: ResolvedEmptyStateStarter[] = [];
|
||||
|
||||
for (const [index, starter] of source.entries()) {
|
||||
const title = starter.title?.trim();
|
||||
const description = starter.description?.trim() || undefined;
|
||||
const prompt = starter.prompt?.trim() || undefined;
|
||||
const action = starter.action ?? undefined;
|
||||
const kind = starter.kind ?? (action ? "action" : "prompt");
|
||||
|
||||
if (!title) continue;
|
||||
if (kind === "action") {
|
||||
if (!action) continue;
|
||||
if (action === "connect-anthropic" && !showNewSessionProviderCta()) {
|
||||
continue;
|
||||
}
|
||||
resolved.push({
|
||||
id: starter.id?.trim() || `starter-${index}`,
|
||||
kind: "action",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!prompt) continue;
|
||||
resolved.push({
|
||||
id: starter.id?.trim() || `starter-${index}`,
|
||||
kind: kind === "session" ? "session" : "prompt",
|
||||
title,
|
||||
description,
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
});
|
||||
const applyStarterPrompt = (text: string) => {
|
||||
props.setPrompt(text);
|
||||
focusComposer();
|
||||
};
|
||||
const runStarterPrompt = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
handleSendPrompt({
|
||||
mode: "prompt",
|
||||
text: trimmed,
|
||||
resolvedText: trimmed,
|
||||
parts: [{ type: "text", text: trimmed }],
|
||||
attachments: [],
|
||||
});
|
||||
};
|
||||
const handleEmptyStateStarter = (starter: ResolvedEmptyStateStarter) => {
|
||||
if (starter.kind === "action") {
|
||||
if (starter.action === "connect-anthropic") {
|
||||
openNewSessionProviderCta();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!starter.prompt) return;
|
||||
if (starter.kind === "session") {
|
||||
runStarterPrompt(starter.prompt);
|
||||
return;
|
||||
}
|
||||
applyStarterPrompt(starter.prompt);
|
||||
};
|
||||
const rightSidebarNavButton = (
|
||||
label: string,
|
||||
icon: any,
|
||||
@@ -4523,43 +4609,34 @@ export default function SessionView(props: SessionViewProps) {
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-xl font-medium">
|
||||
What do you want to do?
|
||||
{emptyStateTitle()}
|
||||
</h3>
|
||||
<p class="text-dls-secondary text-sm max-w-sm mx-auto">
|
||||
Pick a starting point or just type below.
|
||||
{emptyStateBody()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3 max-w-lg mx-auto text-left">
|
||||
<Show when={showNewSessionProviderCta()}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-dls-border bg-dls-hover p-4 transition-all hover:bg-dls-active hover:border-gray-7"
|
||||
onClick={openNewSessionProviderCta}
|
||||
>
|
||||
<div class="text-sm font-semibold text-dls-text">
|
||||
Connect Claude
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-dls-secondary leading-relaxed">
|
||||
Add your Anthropic provider so Claude models are ready in new sessions.
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-dls-border bg-dls-hover p-4 transition-all hover:bg-dls-active hover:border-gray-7"
|
||||
onClick={() => {
|
||||
void handleBrowserAutomationQuickstart();
|
||||
}}
|
||||
>
|
||||
<div class="text-sm font-semibold text-dls-text">
|
||||
Automate your browser
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-dls-secondary leading-relaxed">
|
||||
Set up browser actions and run reliable web tasks
|
||||
from OpenWork.
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<Show when={emptyStateStarters().length > 0}>
|
||||
<div class="grid gap-3 max-w-lg mx-auto text-left">
|
||||
<For each={emptyStateStarters()}>
|
||||
{(starter) => (
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-dls-border bg-dls-hover p-4 transition-all hover:bg-dls-active hover:border-gray-7"
|
||||
onClick={() => handleEmptyStateStarter(starter)}
|
||||
>
|
||||
<div class="text-sm font-semibold text-dls-text">
|
||||
{starter.title}
|
||||
</div>
|
||||
<Show when={starter.description}>
|
||||
<div class="mt-1 text-xs text-dls-secondary leading-relaxed">
|
||||
{starter.description}
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
||||
@@ -169,6 +169,29 @@ export type WorkspaceConnectionState = {
|
||||
|
||||
export type ResetOpenworkMode = "onboarding" | "all";
|
||||
|
||||
export type WorkspaceBlueprintStarterKind = "prompt" | "session" | "action";
|
||||
|
||||
export type WorkspaceBlueprintStarterAction = "connect-anthropic";
|
||||
|
||||
export type WorkspaceBlueprintStarter = {
|
||||
id?: string | null;
|
||||
kind?: WorkspaceBlueprintStarterKind | null;
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
prompt?: string | null;
|
||||
action?: WorkspaceBlueprintStarterAction | null;
|
||||
};
|
||||
|
||||
export type WorkspaceBlueprintEmptyState = {
|
||||
title?: string | null;
|
||||
body?: string | null;
|
||||
starters?: WorkspaceBlueprintStarter[] | null;
|
||||
};
|
||||
|
||||
export type WorkspaceBlueprint = {
|
||||
emptyState?: WorkspaceBlueprintEmptyState | null;
|
||||
};
|
||||
|
||||
export type WorkspaceOpenworkConfig = {
|
||||
version: number;
|
||||
workspace?: {
|
||||
@@ -177,6 +200,7 @@ export type WorkspaceOpenworkConfig = {
|
||||
preset?: string | null;
|
||||
} | null;
|
||||
authorizedRoots: string[];
|
||||
blueprint?: WorkspaceBlueprint | null;
|
||||
reload?: {
|
||||
auto?: boolean;
|
||||
resume?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user