feat(daemon): introduce session mode for conversations

- Added a new `session_mode` column to the `conversations` table with a default value of 'design'.
- Implemented logic to handle `session_mode` in conversation creation, updates, and retrieval.
- Enhanced the API to support `session_mode` in conversation requests, allowing for 'chat' or 'design' modes.
- Updated the web application to include a session mode toggle, enabling users to switch between chat and design modes seamlessly.
- Adjusted system prompts to reflect the current session mode, providing context-aware responses.

This feature enhances the user experience by allowing for more flexible conversation management, catering to different interaction styles.
This commit is contained in:
pftom 2026-05-31 16:42:30 +08:00
parent 8cc11e38cc
commit 79c039efdf
21 changed files with 408 additions and 14 deletions

View file

@ -15,6 +15,7 @@ import { migratePlugins } from './plugins/persistence.js';
type SqliteDb = Database.Database;
type DbRow = Record<string, any>;
type JsonObject = Record<string, unknown>;
type ChatSessionMode = 'design' | 'chat';
let dbInstance: SqliteDb | null = null;
let dbFile: string | null = null;
@ -75,6 +76,7 @@ function migrate(db: SqliteDb): void {
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
title TEXT,
session_mode TEXT NOT NULL DEFAULT 'design',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
@ -222,6 +224,10 @@ function migrate(db: SqliteDb): void {
if (!cols.some((c: DbRow) => c.name === 'custom_instructions')) {
db.exec(`ALTER TABLE projects ADD COLUMN custom_instructions TEXT`);
}
const conversationCols = db.prepare(`PRAGMA table_info(conversations)`).all() as DbRow[];
if (!conversationCols.some((c: DbRow) => c.name === 'session_mode')) {
db.exec(`ALTER TABLE conversations ADD COLUMN session_mode TEXT NOT NULL DEFAULT 'design'`);
}
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all() as DbRow[];
if (!messageCols.some((c: DbRow) => c.name === 'agent_id')) {
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
@ -725,7 +731,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
return rows(db
.prepare(
`WITH project_conversations AS (
SELECT id, project_id AS projectId, title,
SELECT id, project_id AS projectId, title, session_mode AS sessionMode,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations
WHERE project_id = ?
@ -753,7 +759,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
)
WHERE rn = 1
)
SELECT c.id, c.projectId, c.title, c.createdAt, c.updatedAt,
SELECT c.id, c.projectId, c.title, c.sessionMode, c.createdAt, c.updatedAt,
lr.latestRunStatus, lr.latestRunStartedAt,
lr.latestRunEndedAt, lr.latestRunEventsJson
FROM project_conversations c
@ -766,7 +772,7 @@ export function listConversations(db: SqliteDb, projectId: string) {
export function getConversation(db: SqliteDb, id: string) {
const r = db
.prepare(
`SELECT id, project_id AS projectId, title,
`SELECT id, project_id AS projectId, title, session_mode AS sessionMode,
created_at AS createdAt, updated_at AS updatedAt
FROM conversations WHERE id = ?`,
)
@ -789,12 +795,17 @@ function normalizeConversation(r: DbRow) {
id: r.id,
projectId: r.projectId,
title: r.title ?? null,
sessionMode: normalizeConversationSessionMode(r.sessionMode),
createdAt: Number(r.createdAt),
updatedAt: Number(r.updatedAt),
latestRun: latestRun ?? undefined,
};
}
export function normalizeConversationSessionMode(value: unknown): ChatSessionMode {
return value === 'chat' ? 'chat' : 'design';
}
function latestConversationRunSummary(db: SqliteDb, conversationId: string) {
const row = db
.prepare(
@ -858,9 +869,16 @@ function latestUsageDurationMs(eventsJson: unknown): number | undefined {
export function insertConversation(db: SqliteDb, c: DbRow) {
db.prepare(
`INSERT INTO conversations
(id, project_id, title, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)`,
).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt);
(id, project_id, title, session_mode, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
).run(
c.id,
c.projectId,
c.title ?? null,
normalizeConversationSessionMode(c.sessionMode),
c.createdAt,
c.updatedAt,
);
return getConversation(db, c.id);
}
@ -870,12 +888,15 @@ export function updateConversation(db: SqliteDb, id: string, patch: DbRow) {
const merged = {
...existing,
...patch,
sessionMode: Object.prototype.hasOwnProperty.call(patch, 'sessionMode')
? normalizeConversationSessionMode(patch.sessionMode)
: existing.sessionMode,
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
};
db.prepare(
`UPDATE conversations
SET title = ?, updated_at = ? WHERE id = ?`,
).run(merged.title ?? null, merged.updatedAt, id);
SET title = ?, session_mode = ?, updated_at = ? WHERE id = ?`,
).run(merged.title ?? null, merged.sessionMode, merged.updatedAt, id);
return getConversation(db, id);
}

View file

@ -1,6 +1,7 @@
import type { Express } from 'express';
import {
defaultScenarioPluginIdForProjectMetadata,
type ChatSessionMode,
type PluginManifest,
} from '@open-design/contracts';
import { createProjectArtifactFile } from './artifact-create.js';
@ -121,6 +122,10 @@ function injectUrlPreviewScrollBridge(html: string): string {
return `${html}${URL_PREVIEW_SCROLL_BRIDGE}`;
}
function normalizeChatSessionMode(value: unknown): ChatSessionMode {
return value === 'chat' ? 'chat' : 'design';
}
export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
@ -322,6 +327,9 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
id: cid,
projectId: id,
title: null,
sessionMode: normalizeChatSessionMode(
req.body?.conversationMode ?? req.body?.sessionMode,
),
createdAt: now,
updatedAt: now,
});
@ -599,10 +607,24 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
}
const { title, seedFromConversationId } = req.body || {};
const now = Date.now();
const hasExplicitSessionMode = Boolean(
req.body && Object.prototype.hasOwnProperty.call(req.body, 'sessionMode'),
);
const sourceConversation =
typeof seedFromConversationId === 'string' && seedFromConversationId
? getConversation(db, seedFromConversationId)
: null;
const sessionMode =
hasExplicitSessionMode
? normalizeChatSessionMode(req.body.sessionMode)
: sourceConversation && sourceConversation.projectId === req.params.id
? normalizeChatSessionMode(sourceConversation.sessionMode)
: 'design';
const conv = insertConversation(db, {
id: randomId(),
projectId: req.params.id,
title: typeof title === 'string' ? title.trim() || null : null,
sessionMode,
createdAt: now,
updatedAt: now,
});
@ -610,7 +632,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
// messages into the fresh conversation. Be defensive — a missing or
// cross-project source id silently yields an empty conversation.
if (conv && typeof seedFromConversationId === 'string' && seedFromConversationId) {
const source = getConversation(db, seedFromConversationId);
const source = sourceConversation;
if (source && source.projectId === req.params.id) {
for (const m of listMessages(db, seedFromConversationId)) {
// Fresh id per copied message; upsertMessage assigns the next

View file

@ -36,7 +36,7 @@ import { renderMediaGenerationContract } from './media-contract.js';
import { IMAGE_MODELS } from '../media-models.js';
import { renderPanelPrompt } from './panel.js';
import { defaultCritiqueConfig, type CritiqueConfig } from '@open-design/contracts/critique';
import type { MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
import type { ChatSessionMode, MediaExecutionPolicy, MediaSurface } from '@open-design/contracts';
const ELEVENLABS_VOICE_PROMPT_OPTION_LIMIT = 100;
const ELEVENLABS_VOICE_OPTIONS_PROMPT_PREFIX = 'ElevenLabs voice list could not be loaded';
@ -372,6 +372,10 @@ export interface ComposeInput {
// UI locale selected by the client. User-visible generated form copy
// must follow this locale even when the user's initial prompt is brief.
locale?: string | undefined;
// Per-conversation mode. Design mode keeps the artifact-first agent
// workflow; chat mode keeps the same context/tools but answers like a
// standard multi-turn assistant unless the user explicitly asks to build.
sessionMode?: ChatSessionMode | undefined;
// Run-scoped media policy. Defaults to enabled when omitted so existing
// local OD behavior keeps the same media prompt contract.
mediaExecution?: MediaExecutionPolicy | undefined;
@ -407,6 +411,7 @@ export function composeSystemPrompt({
activeStageBlocks,
streamFormat,
locale,
sessionMode,
userInstructions,
projectInstructions,
mediaExecution,
@ -439,6 +444,11 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
if (sessionMode === 'chat') {
parts.push(CHAT_MODE_OVERRIDE);
parts.push('\n\n---\n\n');
}
if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n');
@ -708,6 +718,14 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
const CHAT_MODE_OVERRIDE = `# Chat mode — standard conversation (read first — overrides every rule below)
This conversation is in Open Design Chat mode. Open Design is the open-source Claude Design alternative and a native Figma counterpart. Official links: GitHub https://github.com/nexu-io/open-design, website https://open-design.ai/, Discord https://discord.com/invite/9ptkbbqRu.
Use the same available context, files, attachments, connectors, MCP servers, project memory, and model capabilities as Design mode. The difference is behavior: answer like a fast, direct, multi-turn desktop chat assistant. Prefer concise prose, explanations, comparisons, debugging help, and follow-up questions only when needed.
Override artifact-first discovery rules below: do not emit a default discovery \`<question-form>\`, do not call TodoWrite just to plan a chat answer, and do not create or edit project files, HTML, PPT, slide decks, images, video, or audio unless the user explicitly asks you to generate/build/design/export/modify something. When the user does ask for a design artifact or file change, you may use the normal Open Design agent workflow and the same tools/capabilities available in Design mode.`;
// Defense-in-depth against Claude Code's synthetic OAuth tools.
//
// When Claude Code's built-in HTTP MCP transport gets a 401 on its first

View file

@ -389,6 +389,7 @@ import {
listTemplates,
getLatestRoutineRun,
getRoutine,
normalizeConversationSessionMode,
deleteRoutine as dbDeleteRoutine,
openDatabase,
setTabs,
@ -10117,6 +10118,7 @@ export async function startServer({
designSystemId,
streamFormat,
locale,
sessionMode,
connectedExternalMcp,
appliedPluginSnapshotId,
mediaExecution,
@ -10567,6 +10569,7 @@ export async function startServer({
critiqueBrand: critiqueShouldRun ? critiqueBrand : undefined,
critiqueSkill: critiqueShouldRun ? critiqueSkill : undefined,
locale: typeof locale === 'string' ? locale : undefined,
sessionMode: normalizeConversationSessionMode(sessionMode),
mediaExecution,
streamFormat,
connectedExternalMcp: Array.isArray(connectedExternalMcp)
@ -10675,6 +10678,7 @@ export async function startServer({
skillId,
skillIds,
designSystemId,
sessionMode,
attachments = [],
commentAttachments = [],
model,
@ -10702,6 +10706,14 @@ export async function startServer({
if (typeof skillId === 'string' && skillId) run.skillId = skillId;
if (typeof designSystemId === 'string' && designSystemId)
run.designSystemId = designSystemId;
const conversationSession =
typeof conversationId === 'string' && conversationId
? getConversation(db, conversationId)
: null;
const runSessionMode =
sessionMode === 'chat' || sessionMode === 'design'
? normalizeConversationSessionMode(sessionMode)
: normalizeConversationSessionMode(conversationSession?.sessionMode);
const def = getAgentDef(agentId);
if (!def)
return design.runs.fail(
@ -10937,6 +10949,7 @@ export async function startServer({
designSystemId,
streamFormat: def?.streamFormat ?? 'plain',
locale,
sessionMode: runSessionMode,
connectedExternalMcp,
mediaExecution: run?.mediaExecution,
// Plan §3.M2 / §3.V1 — forward the run's snapshot id so the

View file

@ -1,5 +1,8 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import os from 'node:os';
import path from 'node:path';
// node-pty is a native module; its TypeScript types resolve after
// `pnpm install` compiles the addon. The dynamic import keeps the daemon
// bootable even on a platform where the prebuilt binary is missing — a
@ -7,6 +10,63 @@ import os from 'node:os';
// instead of crashing the process at module-eval time.
import type * as NodePty from 'node-pty';
/**
* Resolve the candidate paths to node-pty's `spawn-helper` binary. On
* macOS/Linux, `pty.fork` shells out to this helper via `posix_spawn`; node-pty
* looks for the native artifacts under `build/Release` first and a
* platform-tagged `prebuilds/<platform>-<arch>` dir second (see its
* `loadNativeModule`). We return both so the executable-bit repair below covers
* whichever one a given install produced. Empty on win32 (ConPTY has no helper)
* or when node-pty can't be resolved at all.
*/
export function spawnHelperCandidatePaths(): string[] {
if (process.platform === 'win32') return [];
let pkgRoot: string;
try {
// `node-pty`'s "main" is `lib/index.js`, so the package root is two levels
// up from the resolved entry. createRequire anchors resolution at this
// module regardless of how the daemon is bundled/run.
const require = createRequire(import.meta.url);
pkgRoot = path.dirname(path.dirname(require.resolve('node-pty')));
} catch {
return [];
}
return [
path.join(pkgRoot, 'build', 'Release', 'spawn-helper'),
path.join(pkgRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
];
}
/**
* Restore the executable bit on node-pty's `spawn-helper`.
*
* pnpm unpacks node-pty's prebuilt binaries into `prebuilds/<platform>-<arch>/`
* with mode 0644 and node-pty's own `post-install.js` only chmods files under
* `build/Release`, which a prebuild-based install never creates. The result is a
* non-executable `spawn-helper`, so the very first `pty.spawn()` dies with
* "posix_spawnp failed." (surfaced to the user as "无法启动终端会话" / "Could not
* start the terminal session"). Re-adding +x before the first fork makes the
* terminal self-heal across reinstalls without depending on an install hook.
*
* Best-effort and idempotent: a missing file (addon not installed for this
* platform) or a read-only filesystem (e.g. a packaged app bundle) is swallowed,
* and the subsequent spawn surfaces the real error instead.
*/
export function ensureSpawnHelperExecutable(): void {
for (const file of spawnHelperCandidatePaths()) {
try {
const stat = fs.statSync(file);
// Owner already has the execute bit — nothing to repair.
if (stat.mode & 0o100) continue;
// OR in execute-for-all, preserving the existing read/write bits.
fs.chmodSync(file, stat.mode | 0o111);
} catch {
// Candidate not present on this install, or fs is read-only: ignore and
// let the spawn attempt report the underlying failure.
}
}
}
/**
* In-memory interactive Terminal session manager. Mirrors the chat-run
* lifecycle in `runs.ts`: each session keeps a bounded event ring-buffer so a
@ -67,6 +127,9 @@ export function createTerminalService({
const loadPty = async (): Promise<typeof NodePty> => {
if (ptyModule) return ptyModule;
// Repair the prebuilt spawn-helper's executable bit before the first fork;
// otherwise spawn() fails with "posix_spawnp failed." on macOS/Linux.
ensureSpawnHelperExecutable();
ptyModule = (await import('node-pty')) as typeof NodePty;
return ptyModule;
};

View file

@ -0,0 +1,51 @@
import fs from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import {
createTerminalService,
ensureSpawnHelperExecutable,
spawnHelperCandidatePaths,
} from '../src/terminals.js';
/**
* Regression for the "无法启动终端会话" / "Could not start the terminal session"
* report: pnpm unpacks node-pty's prebuilt `spawn-helper` without the execute
* bit, so the first `pty.spawn()` dies with "posix_spawnp failed." and the
* launcher toast fires. `terminals.create()` must self-heal that bit so a fresh
* PTY actually spawns.
*
* macOS/Linux only on win32 ConPTY has no spawn-helper and there is nothing to
* repair (`spawnHelperCandidatePaths()` is empty there).
*/
const helperPath = spawnHelperCandidatePaths().find((p) => fs.existsSync(p)) ?? null;
const describeIfHelper = helperPath ? describe : describe.skip;
describeIfHelper('terminal spawn-helper executable repair', () => {
afterEach(() => {
// Leave the shared node_modules artifact executable regardless of outcome,
// so a failure here can't cascade into every other terminal test.
if (helperPath) ensureSpawnHelperExecutable();
});
it('spawns a PTY even when the prebuilt spawn-helper lost its +x bit', async () => {
// Reproduce the post-`pnpm install` state: helper present but mode 0644.
fs.chmodSync(helperPath!, 0o644);
expect(fs.statSync(helperPath!).mode & 0o111).toBe(0);
const terminals = createTerminalService();
const session = await terminals.create({
projectId: 'p-spawn-helper',
cwd: process.cwd(),
});
try {
expect(session.status).toBe('running');
expect(typeof session.pty.pid).toBe('number');
// The repair ran as part of create() → loadPty().
expect(fs.statSync(helperPath!).mode & 0o100).not.toBe(0);
} finally {
terminals.kill(session, 'SIGTERM');
}
});
});

View file

@ -12,6 +12,7 @@ import {
projectKindToTracking,
fidelityToTracking,
} from '@open-design/contracts/analytics';
import type { ChatSessionMode } from '@open-design/contracts';
import { EntryView } from './components/EntryView';
import type { IntegrationTab } from './components/IntegrationsView';
import { MarketplaceView } from './components/MarketplaceView';
@ -829,6 +830,7 @@ function AppInner() {
pluginId?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
conversationMode?: ChatSessionMode;
autoSendFirstMessage?: boolean;
requestId?: string;
pendingFiles?: File[];
@ -852,6 +854,7 @@ function AppInner() {
designSystemId: input.designSystemId,
pendingPrompt: derivedPendingPrompt,
metadata: input.metadata,
...(input.conversationMode ? { conversationMode: input.conversationMode } : {}),
...(input.pluginId ? { pluginId: input.pluginId } : {}),
...(input.appliedPluginSnapshotId
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }

View file

@ -30,6 +30,7 @@ import { listPlugins } from "../state/projects";
import type { AppConfig, ChatAttachment, ChatCommentAttachment, ProjectFile, ProjectMetadata, SkillSummary } from "../types";
import type {
ContextItem,
ChatSessionMode,
ConnectorDetail,
InstalledPluginRecord,
PluginSourceKind,
@ -38,6 +39,7 @@ import type {
} from '@open-design/contracts';
import { buildVisualAnnotationAttachment, commentTargetDisplayName } from '../comments';
import { Icon } from "./Icon";
import { SessionModeToggle } from './SessionModeToggle';
import { PluginDetailsModal } from "./PluginDetailsModal";
import { PluginsSection, type PluginsSectionHandle } from "./PluginsSection";
import { BUILT_IN_PETS, CUSTOM_PET_ID } from "./pet/pets";
@ -96,6 +98,8 @@ interface Props {
projectId: string | null;
projectFiles: ProjectFile[];
streaming: boolean;
sessionMode?: ChatSessionMode;
onSessionModeChange?: (mode: ChatSessionMode) => void;
sendDisabled?: boolean;
initialDraft?: string;
draftStorageKey?: string;
@ -195,6 +199,8 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
projectId,
projectFiles,
streaming,
sessionMode = 'design',
onSessionModeChange,
sendDisabled = false,
initialDraft,
draftStorageKey,
@ -1864,6 +1870,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
<Icon name="attach" size={15} />
)}
</button>
<SessionModeToggle
mode={sessionMode}
onChange={onSessionModeChange}
/>
{footerAccessory}
<span className="composer-spacer" />
{showStopButton ? (

View file

@ -6,7 +6,7 @@ import type { Dict } from '../i18n/types';
import { copyToClipboard } from '../lib/copy-to-clipboard';
import { projectRawUrl } from '../providers/registry';
import type { TodoItem } from '../runtime/todos';
import type { AppliedPluginSnapshot } from '@open-design/contracts';
import type { AppliedPluginSnapshot, ChatSessionMode } from '@open-design/contracts';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
import {
DESIGN_SYSTEM_WORKSPACE_DISPLAY_DESCRIPTION,
@ -215,6 +215,8 @@ interface Props {
streaming: boolean;
error: string | null;
projectId: string | null;
sessionMode?: ChatSessionMode;
onSessionModeChange?: (mode: ChatSessionMode) => void;
// Analytics-only — forwarded to AssistantMessage so the feedback
// events know which project surface the rating applies to. Optional
// (defaults to null/'prototype') so unit tests can mount ChatPane
@ -347,6 +349,8 @@ export function ChatPane({
queuedItems = [],
error,
projectId,
sessionMode = 'design',
onSessionModeChange,
projectKindForTracking = null,
projectFiles,
hasActiveDesignSystem = false,
@ -1290,6 +1294,8 @@ export function ChatPane({
ref={composerRef}
projectId={projectId}
projectFiles={projectFiles}
sessionMode={sessionMode}
onSessionModeChange={onSessionModeChange}
skills={skills}
streaming={streaming}
sendDisabled={sendDisabled}

View file

@ -20,6 +20,7 @@ import {
} from 'react';
import {
defaultScenarioPluginIdForProjectMetadata,
type ChatSessionMode,
type ConnectorDetail,
type InstalledPluginRecord,
} from '@open-design/contracts';
@ -260,6 +261,7 @@ interface Props {
pluginId?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
conversationMode?: ChatSessionMode;
autoSendFirstMessage?: boolean;
pendingFiles?: File[];
},
@ -521,6 +523,7 @@ export function EntryShell({
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
: {}),
...(payload.pluginInputs ? { pluginInputs: payload.pluginInputs } : {}),
...(payload.conversationMode ? { conversationMode: payload.conversationMode } : {}),
...(payload.attachments && payload.attachments.length > 0
? { pendingFiles: payload.attachments }
: {}),

View file

@ -26,6 +26,7 @@ import type {
RefObject,
} from 'react';
import type {
ChatSessionMode,
ConnectorDetail,
InputFieldSpec,
InstalledPluginRecord,
@ -56,6 +57,7 @@ import {
import { PreviewSurface } from './plugins-home/cards/PreviewSurface';
import { curatedPluginPriorityForChip } from './plugins-home/curatedPriority';
import { inferPluginPreview } from './plugins-home/preview';
import { SessionModeToggle } from './SessionModeToggle';
export interface HomeHeroSubmitHandler {
(): void;
@ -65,6 +67,8 @@ interface Props {
prompt: string;
onPromptChange: (value: string) => void;
onSubmit: HomeHeroSubmitHandler;
sessionMode: ChatSessionMode;
onSessionModeChange: (mode: ChatSessionMode) => void;
activePluginTitle: string | null;
activePluginRecord?: InstalledPluginRecord | null;
activeChipId: string | null;
@ -150,6 +154,8 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
prompt,
onPromptChange,
onSubmit,
sessionMode,
onSessionModeChange,
activePluginTitle,
activePluginRecord = null,
activeSkillId = null,
@ -1078,6 +1084,11 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
>
<Icon name="attach" size={15} />
</button>
<SessionModeToggle
mode={sessionMode}
onChange={onSessionModeChange}
disabled={Boolean(submitDisabled)}
/>
{activeCreateChip ? (
<ActiveTypeChip chip={activeCreateChip} onClear={onClearActiveChip} />
) : null}

View file

@ -10,6 +10,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApplyResult,
ChatSessionMode,
ConnectorDetail,
InputFieldSpec,
McpServerConfig,
@ -231,6 +232,7 @@ export function HomeView({
const [fallbackProjectMetadata, setFallbackProjectMetadata] =
useState<ProjectMetadata | null>(null);
const [active, setActive] = useState<ActivePlugin | null>(null);
const [sessionMode, setSessionMode] = useState<ChatSessionMode>('design');
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(null);
const [selectedPluginContexts, setSelectedPluginContexts] = useState<SelectedPluginContext[]>([]);
const [selectedMcpContexts, setSelectedMcpContexts] = useState<SelectedMcpContext[]>([]);
@ -1256,9 +1258,13 @@ export function HomeView({
// Scenario plugins (chips / preset cards) and explicit skill picks are
// mutually exclusive routing sources — never send both (#2972).
const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null;
const routedPluginId =
sessionMode === 'design'
? submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID
: submittedActive?.record.id ?? null;
onSubmit({
prompt: trimmed,
pluginId: submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
pluginId: routedPluginId,
skillId: resolvedSkillId,
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
pluginTitle: submittedActive?.record.title ?? null,
@ -1271,6 +1277,7 @@ export function HomeView({
contextMcpServers,
contextConnectors,
attachments: stagedFiles,
conversationMode: sessionMode,
});
}
@ -1281,6 +1288,8 @@ export function HomeView({
prompt={prompt}
onPromptChange={handlePromptChange}
onSubmit={submit}
sessionMode={sessionMode}
onSessionModeChange={setSessionMode}
activePluginTitle={activeBadgeTitle}
activePluginRecord={active?.record ?? null}
activeSkillId={activeSkill?.id ?? null}

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApplyResult,
ChatSessionMode,
InstalledPluginRecord,
ProjectMetadata,
} from '@open-design/contracts';
@ -41,6 +42,7 @@ export interface PluginLoopSubmit {
projectKind?: 'prototype' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other' | null;
projectMetadata?: ProjectMetadata | null;
workingDir?: string | null;
conversationMode?: ChatSessionMode;
// Files staged on Home before the project exists. App uploads them
// into the created project's Design Files before the first auto-send.
attachments?: File[];

View file

@ -115,6 +115,7 @@ import type {
Artifact,
ChatAttachment,
ChatCommentAttachment,
ChatSessionMode,
ChatMessage,
ChatMessageFeedbackChange,
Conversation,
@ -178,6 +179,7 @@ import {
type ProjectChatSendMeta = ChatSendMeta & {
retryOfAssistantId?: string;
sessionMode?: ChatSessionMode;
};
interface Props {
@ -555,6 +557,11 @@ export function ProjectView({
const [activeConversationId, setActiveConversationId] = useState<string | null>(
null,
);
const activeConversation = useMemo(
() => conversations.find((conversation) => conversation.id === activeConversationId) ?? null,
[conversations, activeConversationId],
);
const activeSessionMode = activeConversation?.sessionMode ?? 'design';
const [messagesConversationId, setMessagesConversationId] = useState<string | null>(null);
const [failedMessagesConversationId, setFailedMessagesConversationId] = useState<string | null>(null);
const [conversationLoadError, setConversationLoadError] = useState<string | null>(null);

View file

@ -0,0 +1,58 @@
import type { ChatSessionMode } from '@open-design/contracts';
import { Icon } from './Icon';
interface Props {
mode: ChatSessionMode;
onChange?: (mode: ChatSessionMode) => void;
disabled?: boolean;
}
const MODE_META: Array<{
mode: ChatSessionMode;
label: string;
icon: 'comment' | 'sparkles';
title: string;
}> = [
{
mode: 'chat',
label: 'Chat',
icon: 'comment',
title:
'Chat mode: fast multi-turn answers with the same files, connectors, MCP servers, and attachments.',
},
{
mode: 'design',
label: 'Design',
icon: 'sparkles',
title:
'Design mode: agent mode for generating HTML, PPT, slides, images, video, audio, and project files.',
},
];
export function SessionModeToggle({ mode, onChange, disabled = false }: Props) {
return (
<div className="session-mode-toggle" role="tablist" aria-label="Conversation mode">
{MODE_META.map((item) => {
const active = item.mode === mode;
return (
<button
key={item.mode}
type="button"
role="tab"
aria-selected={active}
className={`session-mode-toggle__option${active ? ' is-active' : ''}`}
disabled={disabled || !onChange}
title={item.title}
aria-label={item.title}
onClick={() => {
if (!active) onChange?.(item.mode);
}}
>
<Icon name={item.icon} size={13} />
<span className="session-mode-toggle__label">{item.label}</span>
</button>
);
})}
</div>
);
}

View file

@ -8,6 +8,7 @@
import type {
AppliedPluginSnapshot,
ApplyResult,
ChatSessionMode,
CreateConversationRequest,
CreatePluginShareProjectResponse,
CreateTerminalRequest,
@ -60,6 +61,7 @@ export async function createProject(input: {
designSystemId: string | null;
pendingPrompt?: string;
metadata?: ProjectMetadata;
conversationMode?: ChatSessionMode;
// Plan §3.A1 / spec §11.5 — POST /api/projects accepts a pluginId
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
// project. Used by the PluginLoopHome flow on Home.
@ -246,10 +248,13 @@ export async function createConversation(
title?: string,
// Side Chat: seed the new conversation with another conversation's context
// by copying its messages. The daemon ignores a missing/foreign source id.
opts?: { seedFromConversationId?: string | null },
opts?: { seedFromConversationId?: string | null; sessionMode?: ChatSessionMode },
): Promise<Conversation | null> {
try {
const body: CreateConversationRequest = { title };
if (opts?.sessionMode) {
body.sessionMode = opts.sessionMode;
}
if (opts?.seedFromConversationId) {
body.seedFromConversationId = opts.seedFromConversationId;
}

View file

@ -576,6 +576,59 @@
}
.composer-row .icon-btn:hover:not(:disabled) { background: var(--bg-subtle); color: var(--text); }
.composer-spacer { flex: 1; }
.session-mode-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
height: 28px;
min-width: 0;
padding: 2px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-panel);
box-shadow: var(--shadow-xs);
}
.session-mode-toggle__option {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
height: 22px;
min-width: 0;
padding: 0 7px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 650;
line-height: 1;
white-space: nowrap;
transition:
background 160ms cubic-bezier(0.23, 1, 0.32, 1),
color 160ms cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1);
}
.session-mode-toggle__option:hover:not(:disabled) {
background: var(--bg-subtle);
color: var(--text);
}
.session-mode-toggle__option.is-active {
background: var(--bg);
color: var(--text-strong);
box-shadow: 0 1px 2px color-mix(in srgb, var(--text) 10%, transparent);
}
.session-mode-toggle__option:disabled {
cursor: default;
}
.session-mode-toggle__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.composer-import {
background: transparent;
border: 1px solid var(--border);

View file

@ -11,6 +11,7 @@ import type { RunContextSelection } from './context.js';
import type { MediaExecutionPolicy } from './media.js';
export type ChatRole = 'user' | 'assistant';
export type ChatSessionMode = 'design' | 'chat';
export type ChatCommentSelectionKind = PreviewCommentSelectionKind | 'visual';
export interface ChatRequest {
@ -21,6 +22,7 @@ export interface ChatRequest {
systemPrompt?: string;
projectId?: string | null;
conversationId?: string | null;
sessionMode?: ChatSessionMode;
assistantMessageId?: string | null;
clientRequestId?: string | null;
skillId?: string | null;

View file

@ -1,4 +1,4 @@
import type { ChatMessage, ChatRunStatus } from './chat.js';
import type { ChatMessage, ChatRunStatus, ChatSessionMode } from './chat.js';
import type {
ProjectContextConnectorRef,
ProjectContextMcpServerRef,
@ -193,6 +193,7 @@ export interface Conversation {
id: string;
projectId: string;
title: string | null;
sessionMode: ChatSessionMode;
createdAt: number;
updatedAt: number;
latestRun?: {
@ -212,6 +213,8 @@ export interface CreateProjectRequest {
pluginId?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
/** Session mode for the default conversation seeded with the project. */
conversationMode?: ChatSessionMode;
customInstructions?: string;
/** Persisted to metadata.skipDiscoveryBrief for automated project runs. */
skipDiscoveryBrief?: boolean;
@ -287,6 +290,7 @@ export interface ConversationResponse {
export interface CreateConversationRequest {
title?: string | null;
sessionMode?: ChatSessionMode;
/**
* Seed the new conversation with another conversation's context by copying
* its messages. The source must belong to the same project; a missing or
@ -299,6 +303,7 @@ export interface CreateConversationRequest {
export interface UpdateConversationRequest {
title?: string | null;
sessionMode?: ChatSessionMode;
}
export interface MessagesResponse {

View file

@ -29,6 +29,7 @@
* The composed string is what the daemon sees as `systemPrompt` and what
* the Anthropic path sends as `system`.
*/
import type { ChatSessionMode } from '../api/chat.js';
import type { ProjectMetadata, ProjectTemplate } from '../api/projects.js';
import { OFFICIAL_DESIGNER_PROMPT } from './official-system.js';
import { DISCOVERY_AND_PHILOSOPHY } from './discovery.js';
@ -191,6 +192,10 @@ export interface ComposeInput {
// When set to 'plain', suppresses tool_calls so API/BYOK-mode models
// only emit <artifact> blocks (they cannot execute tools).
streamFormat?: string | undefined;
// Per-conversation mode. Design mode keeps the artifact-first agent
// workflow; chat mode keeps the same context/tools but answers like a
// standard multi-turn assistant unless the user explicitly asks to build.
sessionMode?: ChatSessionMode | undefined;
// UI locale selected by the client. User-visible generated form copy
// must follow this locale even when the user's initial prompt is brief.
locale?: string | undefined;
@ -216,6 +221,7 @@ export function composeSystemPrompt({
audioVoiceOptions,
audioVoiceOptionsError,
streamFormat,
sessionMode,
locale,
userInstructions,
projectInstructions,
@ -240,6 +246,11 @@ export function composeSystemPrompt({
parts.push('\n\n---\n\n');
}
if (sessionMode === 'chat') {
parts.push(CHAT_MODE_OVERRIDE);
parts.push('\n\n---\n\n');
}
if (metadata?.skipDiscoveryBrief === true) {
parts.push(SKIP_DISCOVERY_BRIEF_OVERRIDE);
parts.push('\n\n---\n\n');
@ -398,6 +409,14 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
const CHAT_MODE_OVERRIDE = `# Chat mode — standard conversation (read first — overrides every rule below)
This conversation is in Open Design Chat mode. Open Design is the open-source Claude Design alternative and a native Figma counterpart. Official links: GitHub https://github.com/nexu-io/open-design, website https://open-design.ai/, Discord https://discord.com/invite/9ptkbbqRu.
Use the same available context, files, attachments, connectors, MCP servers, project memory, and model capabilities as Design mode. The difference is behavior: answer like a fast, direct, multi-turn desktop chat assistant. Prefer concise prose, explanations, comparisons, debugging help, and follow-up questions only when needed.
Override artifact-first discovery rules below: do not emit a default discovery \`<question-form>\`, do not call TodoWrite just to plan a chat answer, and do not create or edit project files, HTML, PPT, slide decks, images, video, or audio unless the user explicitly asks you to generate/build/design/export/modify something. When the user does ask for a design artifact or file change, you may use the normal Open Design agent workflow and the same tools/capabilities available in Design mode.`;
function renderMetadataBlock(
metadata: ProjectMetadata | undefined,
template: ProjectTemplate | undefined,

View file

@ -31,6 +31,19 @@ describe('DISCOVERY_AND_PHILOSOPHY (contracts copy) — TodoWrite plan item coun
const prompt = composeSystemPrompt({});
expect(prompt).not.toMatch(/5[\-]10\s+short\s+imperative/);
});
it('uses a top-level Chat mode override for conversational sessions', () => {
const prompt = composeSystemPrompt({ sessionMode: 'chat' });
expect(prompt).toContain('# Chat mode — standard conversation');
expect(prompt).toContain('https://github.com/nexu-io/open-design');
expect(prompt).toContain('https://open-design.ai/');
expect(prompt).toContain('https://discord.com/invite/9ptkbbqRu');
expect(prompt).toContain('do not emit a default discovery `<question-form>`');
expect(prompt.indexOf('# Chat mode — standard conversation')).toBeLessThan(
prompt.indexOf(DISCOVERY_AND_PHILOSOPHY),
);
});
});
describe('composeSystemPrompt', () => {