feat(daemon): enhance conversation session mode handling

- Added a new `mode` flag to CLI commands for project and conversation creation, allowing users to specify 'design' or 'chat' modes.
- Implemented `normalizeChatSessionModeFlag` function to validate and normalize session mode inputs.
- Updated project routes to handle session mode during conversation creation and updates.
- Enhanced web components to support session mode changes, including new props and handlers for managing session modes in conversations.
- Adjusted UI elements to reflect the current session mode, improving user experience and interaction flexibility.

This update provides a more robust framework for managing conversation modes, catering to diverse user needs and enhancing overall functionality.
This commit is contained in:
pftom 2026-05-31 17:11:03 +08:00
parent 79c039efdf
commit 44492af1fa
9 changed files with 102 additions and 16 deletions

View file

@ -152,7 +152,7 @@ const PROJECT_STRING_FLAGS = new Set([
'daemon-url', 'name', 'skill', 'design-system', 'plugin', 'metadata-json',
'pending-prompt', 'project', 'conversation', 'message', 'path', 'as',
'agent', 'model', 'snapshot-id', 'inputs', 'grant-caps', 'editor',
'title', 'against', 'seed-from',
'title', 'against', 'seed-from', 'mode',
]);
const PROJECT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'follow']);
// `od automation …` mirrors the Automations tab. Same surface, same
@ -4319,6 +4319,14 @@ async function projectDaemonUrl(flags) {
return cliDaemonUrl(flags);
}
function normalizeChatSessionModeFlag(value) {
if (value == null) return undefined;
const mode = String(value).trim().toLowerCase();
if (mode === 'design' || mode === 'chat') return mode;
console.error('--mode must be one of: design, chat');
process.exit(2);
}
function safeReadJsonFile(p) {
try {
const fs = (require ? require('node:fs') : null);
@ -4335,6 +4343,7 @@ async function runProject(args) {
console.log(`Usage:
od project create [--name "<title>"] [--skill <id>] [--design-system <id>]
[--plugin <id>] [--inputs <json>] [--metadata-json <path|->]
[--mode design|chat]
od project import <baseDir> [--name "<title>"]
od project list List projects.
od project info <id> Print one project.
@ -4406,6 +4415,8 @@ Common options:
skillId: flags.skill ?? null,
designSystemId: flags['design-system'] ?? null,
};
const conversationMode = normalizeChatSessionModeFlag(flags.mode);
if (conversationMode) body.conversationMode = conversationMode;
if (flags['pending-prompt']) body.pendingPrompt = flags['pending-prompt'];
if (flags['metadata-json']) {
const mj = safeReadJsonFile(flags['metadata-json']);
@ -5103,7 +5114,7 @@ function renderDiffLineContent(value) {
async function runConversation(args) {
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od conversation new <projectId> [--title "<title>"] [--seed-from <cid>]
od conversation new <projectId> [--title "<title>"] [--seed-from <cid>] [--mode design|chat]
Create a conversation in a project.
--seed-from copies another
conversation's messages in (Side Chat).
@ -5128,6 +5139,8 @@ Common options:
}
const body = {};
if (typeof flags.title === 'string') body.title = flags.title;
const sessionMode = normalizeChatSessionModeFlag(flags.mode);
if (sessionMode) body.sessionMode = sessionMode;
if (typeof flags['seed-from'] === 'string' && flags['seed-from']) {
body.seedFromConversationId = flags['seed-from'];
}
@ -5139,7 +5152,8 @@ Common options:
if (!resp.ok) return structuredHttpFailure(resp, 'project-not-found');
const data = await resp.json();
if (flags.json) return process.stdout.write(JSON.stringify(data, null, 2) + '\n');
console.log(`[conversation] created ${data.conversation?.id ?? '-'}`);
const conv = data.conversation;
console.log(`[conversation] created ${conv?.id ?? '-'} (mode ${conv?.sessionMode ?? sessionMode ?? 'design'})`);
return;
}
case 'list': {
@ -5185,7 +5199,7 @@ Common options:
async function runChat(args) {
if (args.length === 0 || args[0] === 'help' || args.includes('--help') || args.includes('-h')) {
console.log(`Usage:
od chat new --project <id> [--seed-from <cid>] [--title "<title>"] [--json]
od chat new --project <id> [--seed-from <cid>] [--title "<title>"] [--mode design|chat] [--json]
Create a Side Chat a new conversation
that copies in another conversation's
context (--seed-from) so the new chat
@ -5213,6 +5227,8 @@ Common options:
}
const body = {};
if (typeof flags.title === 'string') body.title = flags.title;
const sessionMode = normalizeChatSessionModeFlag(flags.mode);
if (sessionMode) body.sessionMode = sessionMode;
if (typeof flags['seed-from'] === 'string' && flags['seed-from']) {
body.seedFromConversationId = flags['seed-from'];
}
@ -5228,7 +5244,7 @@ Common options:
const seeded = body.seedFromConversationId
? ` (seeded from ${body.seedFromConversationId})`
: '';
console.log(`[chat] created ${conv?.id ?? '-'}${conv?.title ? ` "${conv.title}"` : ''}${seeded}`);
console.log(`[chat] created ${conv?.id ?? '-'}${conv?.title ? ` "${conv.title}"` : ''}${seeded} (mode ${conv?.sessionMode ?? sessionMode ?? 'design'})`);
return;
}
default:

View file

@ -323,13 +323,14 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
});
// Seed a default conversation so the UI always has somewhere to write.
const cid = randomId();
const initialSessionMode = normalizeChatSessionMode(
req.body?.conversationMode ?? req.body?.sessionMode,
);
insertConversation(db, {
id: cid,
projectId: id,
title: null,
sessionMode: normalizeChatSessionMode(
req.body?.conversationMode ?? req.body?.sessionMode,
),
sessionMode: initialSessionMode,
createdAt: now,
updatedAt: now,
});
@ -341,7 +342,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
&& req.body.appliedPluginSnapshotId.trim().length > 0;
let resolveBody =
explicitPlugin ? (req.body as Record<string, unknown>) : null;
if (!resolveBody) {
if (!resolveBody && initialSessionMode === 'design') {
const fallbackPluginId = defaultScenarioPluginIdForProjectMetadata(projectMetadata);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
resolveBody = { ...(req.body || {}), pluginId: fallbackPluginId };

View file

@ -6,6 +6,7 @@ import {
type DragEvent as ReactDragEvent,
} from 'react';
import type { TrackingProjectKind } from '@open-design/contracts/analytics';
import type { ChatSessionMode } from '@open-design/contracts';
import { useAnalytics } from '../analytics/provider';
import {
trackFileManagerClick,
@ -136,6 +137,7 @@ interface Props {
onSelectConversation?: (id: string) => void;
onDeleteConversation?: (id: string) => void;
onRenameConversation?: (id: string, title: string) => void;
onConversationSessionModeChange?: (id: string, mode: ChatSessionMode) => void;
onNewConversation?: () => void;
/** Create a context-seeded conversation and resolve its id (backs the launcher). */
onCreateSideChat?: (seedFromConversationId: string | null) => Promise<string | null>;
@ -265,6 +267,7 @@ export function FileWorkspace({
onSelectConversation,
onDeleteConversation,
onRenameConversation,
onConversationSessionModeChange,
onNewConversation,
onCreateSideChat,
}: Props) {
@ -1246,6 +1249,7 @@ export function FileWorkspace({
onSelectConversation={onSelectConversation ?? (() => {})}
onDeleteConversation={onDeleteConversation ?? (() => {})}
onRenameConversation={onRenameConversation}
onSessionModeChange={onConversationSessionModeChange}
onNewConversation={onNewConversation}
onRequestOpenFile={openFile}
/>

View file

@ -107,7 +107,7 @@ import {
type SaveMessageOptions,
waitGeneratedPluginShareTask,
} from '../state/projects';
import type { AppliedPluginSnapshot } from '@open-design/contracts';
import type { AppliedPluginSnapshot, ChatSessionMode } from '@open-design/contracts';
import type {
AgentEvent,
AgentInfo,
@ -115,7 +115,6 @@ import type {
Artifact,
ChatAttachment,
ChatCommentAttachment,
ChatSessionMode,
ChatMessage,
ChatMessageFeedbackChange,
Conversation,
@ -1434,7 +1433,9 @@ export function ProjectView({
return project.id;
}, [project.id]);
const composedSystemPrompt = useCallback(async (): Promise<string> => {
const composedSystemPrompt = useCallback(async (
sessionModeOverride: ChatSessionMode = activeSessionMode,
): Promise<string> => {
let skillBody: string | undefined;
let skillName: string | undefined;
let skillMode: SkillSummary['mode'] | undefined;
@ -1537,6 +1538,7 @@ export function ProjectView({
audioVoiceOptions,
audioVoiceOptionsError: audioVoiceOptionsLookupError,
streamFormat: config.mode === 'api' ? 'plain' : undefined,
sessionMode: sessionModeOverride,
locale,
userInstructions: config.customInstructions,
projectInstructions: project.customInstructions,
@ -1551,6 +1553,7 @@ export function ProjectView({
designSystems,
config.mode,
config.customInstructions,
activeSessionMode,
locale,
]);
@ -2267,6 +2270,7 @@ export function ProjectView({
) => {
if (!activeConversationId) return;
if (messagesConversationIdRef.current !== activeConversationId) return;
const runSessionMode = meta?.sessionMode ?? activeSessionMode;
const retryTarget = meta?.retryOfAssistantId
? resolveRetryTarget(messages, meta.retryOfAssistantId)
: null;
@ -2285,7 +2289,7 @@ export function ProjectView({
prompt,
attachments,
commentAttachments,
...(meta === undefined ? {} : { meta }),
meta: { ...(meta ?? {}), sessionMode: runSessionMode },
createdAt: Date.now(),
});
if (commentAttachments.length > 0) {
@ -2770,6 +2774,7 @@ export function ProjectView({
designSystemId: project.designSystemId ?? null,
attachments: runAttachments.map((a) => a.path),
commentAttachments: runCommentAttachments,
sessionMode: runSessionMode,
research: meta?.research,
mediaExecution: mediaExecutionPolicyForProjectMetadata(project.metadata),
model: choice?.model ?? null,
@ -2863,7 +2868,7 @@ export function ProjectView({
// on the next event.
}
}
const systemPrompt = await composedSystemPrompt();
const systemPrompt = await composedSystemPrompt(runSessionMode);
const apiHistory = await historyWithApiAttachmentContext(
historyWithCommentAttachmentContext(nextHistory, userMsg.id),
userMsg.id,
@ -2911,6 +2916,7 @@ export function ProjectView({
[
attachedComments,
activeConversationId,
activeSessionMode,
currentConversationBusy,
enqueueChatSend,
messages,
@ -3638,6 +3644,33 @@ export function ProjectView({
[project.id],
);
const handleConversationSessionModeChange = useCallback(
async (id: string, sessionMode: ChatSessionMode) => {
setConversations((curr) =>
curr.map((conversation) =>
conversation.id === id ? { ...conversation, sessionMode } : conversation,
),
);
const updated = await patchConversation(project.id, id, { sessionMode });
if (updated) {
setConversations((curr) =>
curr.map((conversation) =>
conversation.id === id ? { ...conversation, ...updated } : conversation,
),
);
}
},
[project.id],
);
const handleActiveConversationSessionModeChange = useCallback(
(sessionMode: ChatSessionMode) => {
if (!activeConversationId) return;
void handleConversationSessionModeChange(activeConversationId, sessionMode);
},
[activeConversationId, handleConversationSessionModeChange],
);
// Side Chat launcher: create a NEW conversation seeded with the current
// chat's context (the daemon copies the source conversation's messages) and
// resolve its id. The new conversation is a normal conversation, so it shows
@ -4480,6 +4513,8 @@ export function ProjectView({
queuedItems={currentConversationQueuedItems}
error={conversationLoadError ?? error ?? audioVoiceOptionsError}
projectId={project.id}
sessionMode={activeSessionMode}
onSessionModeChange={handleActiveConversationSessionModeChange}
projectKindForTracking={projectKindToTracking(project.metadata?.kind)}
projectFiles={projectFiles}
hasActiveDesignSystem={!!project.designSystemId}
@ -4615,6 +4650,7 @@ export function ProjectView({
onSelectConversation={handleSelectConversation}
onDeleteConversation={handleDeleteConversation}
onRenameConversation={handleRenameConversation}
onConversationSessionModeChange={handleConversationSessionModeChange}
onNewConversation={handleNewConversation}
onCreateSideChat={handleCreateSideChat}
/>

View file

@ -7,6 +7,7 @@ import type {
Conversation,
ProjectFile,
} from '../../types';
import type { ChatSessionMode } from '@open-design/contracts';
import { useConversationChat } from './useConversationChat';
import styles from './SideChatTab.module.css';
@ -28,6 +29,7 @@ interface Props {
onSelectConversation: (id: string) => void;
onDeleteConversation: (id: string) => void;
onRenameConversation?: (id: string, title: string) => void;
onSessionModeChange?: (id: string, mode: ChatSessionMode) => void;
onNewConversation?: () => void;
/** Forward produced-file / tool-card open requests to the workspace. */
onRequestOpenFile?: (name: string) => void;
@ -49,14 +51,19 @@ export function SideChatTab({
onSelectConversation,
onDeleteConversation,
onRenameConversation,
onSessionModeChange,
onNewConversation,
onRequestOpenFile,
}: Props) {
const t = useT();
const sessionMode =
conversations.find((conversation) => conversation.id === conversationId)?.sessionMode
?? 'design';
const chat = useConversationChat(projectId, conversationId, {
config,
agentsById,
locale,
sessionMode,
});
return (
@ -73,6 +80,8 @@ export function SideChatTab({
streaming={chat.streaming}
error={chat.error}
projectId={projectId}
sessionMode={sessionMode}
onSessionModeChange={(mode) => onSessionModeChange?.(conversationId, mode)}
projectFiles={projectFiles}
projectFileNames={projectFileNames}
onEnsureProject={async () => projectId}

View file

@ -18,6 +18,7 @@ import type {
ChatCommentAttachment,
ChatMessage,
} from '../../types';
import type { ChatSessionMode } from '@open-design/contracts';
// ---------------------------------------------------------------------------
// useConversationChat — drives a secondary ChatPane bound to a single
@ -51,6 +52,7 @@ export interface ConversationChatContext {
agentsById: Map<string, AgentInfo>;
/** UI locale forwarded to the daemon so prompts compose in-language. */
locale: string;
sessionMode: ChatSessionMode;
}
export interface UseConversationChatResult {
@ -137,7 +139,12 @@ export function useConversationChat(
commentAttachments: ChatCommentAttachment[],
retryOfAssistantId?: string,
) => {
const { config: cfg, agentsById: agents, locale: loc } = ctxRef.current;
const {
config: cfg,
agentsById: agents,
locale: loc,
sessionMode,
} = ctxRef.current;
if (cfg.mode !== 'daemon') {
setError('Side Chat needs a local agent. Pick one in the top bar.');
return;
@ -277,6 +284,7 @@ export function useConversationChat(
model: choice?.model ?? null,
reasoning: choice?.reasoning ?? null,
locale: loc,
sessionMode,
onRunCreated: (runId) => {
updateAssistant(assistantId, (prev) => ({
...prev,

View file

@ -17,6 +17,7 @@ import type {
ChatRunStatus,
ChatRunStatusResponse,
ChatRequest,
ChatSessionMode,
ChatSseEvent,
ChatSseStartPayload,
DaemonAgentPayload,
@ -204,6 +205,7 @@ export interface DaemonStreamOptions {
// workspace.
projectId?: string | null;
conversationId?: string | null;
sessionMode?: ChatSessionMode;
assistantMessageId?: string | null;
clientRequestId?: string | null;
skillId?: string | null;
@ -300,6 +302,7 @@ export async function streamViaDaemon({
handlers,
projectId,
conversationId,
sessionMode,
assistantMessageId,
clientRequestId,
skillId,
@ -333,6 +336,7 @@ export async function streamViaDaemon({
currentPrompt: latestUserPromptFromHistory(history),
projectId: projectId ?? null,
conversationId: conversationId ?? null,
sessionMode,
assistantMessageId: assistantMessageId ?? null,
clientRequestId: clientRequestId ?? null,
skillId: skillId ?? null,

View file

@ -629,6 +629,14 @@
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 700px) {
.session-mode-toggle__option {
padding: 0 6px;
}
.session-mode-toggle__label {
display: none;
}
}
.composer-import {
background: transparent;
border: 1px solid var(--border);

View file

@ -193,7 +193,7 @@ export interface Conversation {
id: string;
projectId: string;
title: string | null;
sessionMode: ChatSessionMode;
sessionMode?: ChatSessionMode;
createdAt: number;
updatedAt: number;
latestRun?: {