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:
ben
2026-03-19 20:15:45 -07:00
committed by GitHub
parent fb5ab39baa
commit ad846acbef
5 changed files with 355 additions and 53 deletions

View File

@@ -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

View File

@@ -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(),

View 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,
};
}

View File

@@ -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>

View File

@@ -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;