diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index 1fd3d025b..9d1351dc3 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -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 ""] [--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: diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index 4379558b2..2a6072ee3 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -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 }; diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index 26a4d54aa..d76739adc 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -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} /> diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 9bc8f5bdd..de06ac88c 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -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} /> diff --git a/apps/web/src/components/workspace/SideChatTab.tsx b/apps/web/src/components/workspace/SideChatTab.tsx index be5286e4c..2c173b8d2 100644 --- a/apps/web/src/components/workspace/SideChatTab.tsx +++ b/apps/web/src/components/workspace/SideChatTab.tsx @@ -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} diff --git a/apps/web/src/components/workspace/useConversationChat.ts b/apps/web/src/components/workspace/useConversationChat.ts index d7b0a86ca..c461a795d 100644 --- a/apps/web/src/components/workspace/useConversationChat.ts +++ b/apps/web/src/components/workspace/useConversationChat.ts @@ -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, diff --git a/apps/web/src/providers/daemon.ts b/apps/web/src/providers/daemon.ts index 596de7f3d..0c4780740 100644 --- a/apps/web/src/providers/daemon.ts +++ b/apps/web/src/providers/daemon.ts @@ -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, diff --git a/apps/web/src/styles/chat.css b/apps/web/src/styles/chat.css index 39eca1bf6..225bc049b 100644 --- a/apps/web/src/styles/chat.css +++ b/apps/web/src/styles/chat.css @@ -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); diff --git a/packages/contracts/src/api/projects.ts b/packages/contracts/src/api/projects.ts index 71f410788..0167cbe7d 100644 --- a/packages/contracts/src/api/projects.ts +++ b/packages/contracts/src/api/projects.ts @@ -193,7 +193,7 @@ export interface Conversation { id: string; projectId: string; title: string | null; - sessionMode: ChatSessionMode; + sessionMode?: ChatSessionMode; createdAt: number; updatedAt: number; latestRun?: {