feat: open subagent sessions read-only in context panel

Adds read-only embedded chat mode without hiding permission prompts
Opens subagent sessions in the context panel instead of replacing the main chat
Fixes context panel message loading for embedded sessions
This commit is contained in:
Bohdan Triapitsyn
2026-05-15 22:16:04 +03:00
parent 040b6a9dc6
commit 9828ddb4b1
15 changed files with 117 additions and 38 deletions
+53 -21
View File
@@ -31,7 +31,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore'; import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { SyncProvider, useSessions } from '@/sync/sync-context'; import { SyncProvider } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { AboutDialog } from '@/components/ui/AboutDialog'; import { AboutDialog } from '@/components/ui/AboutDialog';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
@@ -95,12 +96,18 @@ type AppProps = {
type EmbeddedSessionChatConfig = { type EmbeddedSessionChatConfig = {
sessionId: string; sessionId: string;
directory: string | null; directory: string | null;
readOnly: boolean;
}; };
type EmbeddedVisibilityPayload = { type EmbeddedVisibilityPayload = {
visible?: unknown; visible?: unknown;
}; };
const normalizeEmbeddedDirectory = (value: string | null | undefined): string => {
if (!value) return '';
return value.replace(/\\/g, '/').replace(/\/+$/g, '');
};
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => { const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
@@ -125,6 +132,7 @@ const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
return { return {
sessionId, sessionId,
directory, directory,
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
}; };
}; };
@@ -136,31 +144,55 @@ const isMcpOAuthCallbackPath = (): boolean => {
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH; return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
}; };
const EmbeddedSessionSelectionGate: React.FC<{ const EmbeddedSessionChatContent: React.FC<{
embeddedSessionChat: EmbeddedSessionChatConfig | null; embeddedSessionChat: EmbeddedSessionChatConfig;
isVSCodeRuntime: boolean; isVSCodeRuntime: boolean;
}> = ({ embeddedSessionChat, isVSCodeRuntime }) => { embeddedBackgroundWorkEnabled: boolean;
const sessions = useSessions(); }> = ({ embeddedSessionChat, isVSCodeRuntime, embeddedBackgroundWorkEnabled }) => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const sync = useSync();
const bootstrapKeyRef = React.useRef<string | null>(null);
const expectedDirectory = normalizeEmbeddedDirectory(embeddedSessionChat.directory);
const activeDirectory = normalizeEmbeddedDirectory(currentDirectory);
React.useEffect(() => { React.useEffect(() => {
if (!embeddedSessionChat || isVSCodeRuntime) { if (isVSCodeRuntime) return;
if (expectedDirectory && activeDirectory !== expectedDirectory) return;
const bootstrapKey = `${expectedDirectory}\n${embeddedSessionChat.sessionId}`;
if (bootstrapKeyRef.current === bootstrapKey && currentSessionId === embeddedSessionChat.sessionId) {
return; return;
} }
if (currentSessionId === embeddedSessionChat.sessionId) { bootstrapKeyRef.current = bootstrapKey;
return; setCurrentSession(embeddedSessionChat.sessionId, embeddedSessionChat.directory);
} void sync.ensureSessionRenderable(embeddedSessionChat.sessionId, true);
}, [
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) { activeDirectory,
return; currentSessionId,
} embeddedSessionChat.directory,
embeddedSessionChat.sessionId,
void setCurrentSession(embeddedSessionChat.sessionId); expectedDirectory,
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]); isVSCodeRuntime,
setCurrentSession,
sync,
]);
if (expectedDirectory && activeDirectory !== expectedDirectory) {
return null; return null;
}
return (
<>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView readOnly={embeddedSessionChat.readOnly} />
<Toaster />
</>
);
}; };
function App({ apis }: AppProps) { function App({ apis }: AppProps) {
@@ -780,11 +812,11 @@ function App({ apis }: AppProps) {
<RuntimeAPIProvider apis={apis}> <RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}> <TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background"> <div className="h-full text-foreground bg-background">
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} /> <EmbeddedSessionChatContent
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} /> embeddedSessionChat={embeddedSessionChat}
<OpenCodeUpdateToast /> isVSCodeRuntime={isVSCodeRuntime}
<ChatView /> embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
<Toaster /> />
</div> </div>
</TooltipProvider> </TooltipProvider>
</RuntimeAPIProvider> </RuntimeAPIProvider>
@@ -315,11 +315,24 @@ const HYDRATING_SKELETON_ITEMS: Array<{
}, },
]; ];
type ChatContainerProps = { const ReadOnlyPromptBanner: React.FC = () => {
autoOpenDraft?: boolean; const { t } = useI18n();
return (
<div className="p-3">
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 typography-ui-label text-muted-foreground">
{t('chat.container.readOnlySubagentPromptBanner')}
</div>
</div>
);
}; };
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true }) => { type ChatContainerProps = {
autoOpenDraft?: boolean;
readOnly?: boolean;
};
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
const { t } = useI18n(); const { t } = useI18n();
// Session UI state // Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
@@ -751,7 +764,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background' : 'bg-background'
)} )}
> >
<ChatInput scrollToBottom={resumeToLatestInstant} /> {readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div> </div>
</div> </div>
); );
@@ -811,7 +824,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background' : 'bg-background'
)} )}
> >
<ChatInput scrollToBottom={resumeToLatestInstant} /> {readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div> </div>
</div> </div>
); );
@@ -844,7 +857,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
: 'bg-background' : 'bg-background'
)} )}
> >
<ChatInput scrollToBottom={resumeToLatestInstant} /> {readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div> </div>
</div> </div>
); );
@@ -892,7 +905,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
onClick={navigation.resumeToLatest} onClick={navigation.resumeToLatest}
/> />
)} )}
<ChatInput scrollToBottom={resumeToLatestInstant} /> {readOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={resumeToLatestInstant} />}
</div> </div>
<TimelineDialog <TimelineDialog
@@ -91,7 +91,8 @@ const normalizeSubtaskModel = (model: SubtaskPartLike['model']): string | null =
const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => { const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const effectiveDirectory = useEffectiveDirectory();
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const { t } = useI18n(); const { t } = useI18n();
const description = typeof part.description === 'string' ? part.description.trim() : ''; const description = typeof part.description === 'string' ? part.description.trim() : '';
@@ -151,7 +152,13 @@ const UserSubtaskPart: React.FC<{ part: SubtaskPartLike }> = ({ part }) => {
type="button" type="button"
className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" className="typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
onClick={() => { onClick={() => {
void setCurrentSession(taskSessionID); if (!effectiveDirectory) return;
openContextPanelTab(effectiveDirectory, {
mode: 'chat',
dedupeKey: `session:${taskSessionID}`,
label: description || agent || t('contextPanel.mode.chat'),
readOnly: true,
});
}} }}
> >
{t('chat.messageBody.subtask.openSession')} {t('chat.messageBody.subtask.openSession')}
@@ -1080,7 +1080,8 @@ const TaskToolSummary: React.FC<{
isActive?: boolean; isActive?: boolean;
}> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => { }> = ({ entries, isExpanded, isMobile, output, sessionId, onShowPopup, input, animateTailText = true, isActive = false }) => {
const { t } = useI18n(); const { t } = useI18n();
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const showToolFileIcons = useUIStore((state) => state.showToolFileIcons); const showToolFileIcons = useUIStore((state) => state.showToolFileIcons);
const displayEntries = entries; const displayEntries = entries;
@@ -1092,8 +1093,13 @@ const TaskToolSummary: React.FC<{
const handleOpenSession = (event: React.MouseEvent) => { const handleOpenSession = (event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
if (sessionId) { if (sessionId && currentDirectory) {
setCurrentSession(sessionId); openContextPanelTab(currentDirectory, {
mode: 'chat',
dedupeKey: `session:${sessionId}`,
label: agentType.charAt(0).toUpperCase() + agentType.slice(1),
readOnly: true,
});
} }
}; };
@@ -486,7 +486,7 @@ const desktopAnnotationToFile = async (
} }
}; };
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null): string => { const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return '';
} }
@@ -494,6 +494,11 @@ const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null
const url = new URL(window.location.pathname, window.location.origin); const url = new URL(window.location.pathname, window.location.origin);
url.searchParams.set('ocPanel', 'session-chat'); url.searchParams.set('ocPanel', 'session-chat');
url.searchParams.set('sessionId', sessionID); url.searchParams.set('sessionId', sessionID);
if (readOnly) {
url.searchParams.set('readOnly', '1');
} else {
url.searchParams.delete('readOnly');
}
if (directory && directory.trim().length > 0) { if (directory && directory.trim().length > 0) {
url.searchParams.set('directory', directory); url.searchParams.set('directory', directory);
} else { } else {
@@ -1937,7 +1942,7 @@ export const ContextPanel: React.FC = () => {
return null; return null;
} }
const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null); const src = buildEmbeddedSessionChatURL(sessionID, directoryKey || null, tab.readOnly);
if (!src) { if (!src) {
return null; return null;
} }
@@ -75,7 +75,7 @@ type Props = {
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void; removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void; addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void; openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; readOnly?: boolean }) => void;
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void; handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
mobileVariant: boolean; mobileVariant: boolean;
alwaysShowActions: boolean; alwaysShowActions: boolean;
@@ -3,12 +3,16 @@ import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary'; import { ChatErrorBoundary } from '@/components/chat/ChatErrorBoundary';
import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionUIStore } from '@/sync/session-ui-store';
export const ChatView: React.FC = () => { type ChatViewProps = {
readOnly?: boolean;
};
export const ChatView: React.FC<ChatViewProps> = ({ readOnly = false }) => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
return ( return (
<ChatErrorBoundary sessionId={currentSessionId || undefined}> <ChatErrorBoundary sessionId={currentSessionId || undefined}>
<ChatContainer /> <ChatContainer readOnly={readOnly} />
</ChatErrorBoundary> </ChatErrorBoundary>
); );
}; };
+1
View File
@@ -1433,6 +1433,7 @@ export const dict = {
'chat.container.returnToParent.titleNamed': 'Return to: {title}', 'chat.container.returnToParent.titleNamed': 'Return to: {title}',
'chat.container.returnToParent.title': 'Return to parent session', 'chat.container.returnToParent.title': 'Return to parent session',
'chat.container.returnToParent.label': 'Parent', 'chat.container.returnToParent.label': 'Parent',
'chat.container.readOnlySubagentPromptBanner': 'Subagent sessions cannot be prompted.',
'chat.unifiedControls.title': 'Controls', 'chat.unifiedControls.title': 'Controls',
'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'No recent models', 'chat.unifiedControls.model.noRecent': 'No recent models',
+1
View File
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.titleNamed": "Volver a: {title}",
"chat.container.returnToParent.title": "Volver a la sesión principal", "chat.container.returnToParent.title": "Volver a la sesión principal",
"chat.container.returnToParent.label": "Principal", "chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.",
"chat.unifiedControls.title": "Controles", "chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "No hay modelos recientes", "chat.unifiedControls.model.noRecent": "No hay modelos recientes",
+1
View File
@@ -1435,6 +1435,7 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.titleNamed': '돌아가기: {title}',
'chat.container.returnToParent.title': '상위 세션으로 돌아가기', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기',
'chat.container.returnToParent.label': '상위', 'chat.container.returnToParent.label': '상위',
'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.',
'chat.unifiedControls.title': '컨트롤', 'chat.unifiedControls.title': '컨트롤',
'chat.unifiedControls.model.title': '모델', 'chat.unifiedControls.model.title': '모델',
'chat.unifiedControls.model.noRecent': '최근 모델 없음', 'chat.unifiedControls.model.noRecent': '최근 모델 없음',
+1
View File
@@ -489,6 +489,7 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}',
'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej',
'chat.container.returnToParent.label': 'Nadrzędna', 'chat.container.returnToParent.label': 'Nadrzędna',
'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.',
'chat.unifiedControls.title': 'Kontrolki', 'chat.unifiedControls.title': 'Kontrolki',
'chat.unifiedControls.model.title': 'Model', 'chat.unifiedControls.model.title': 'Model',
'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli', 'chat.unifiedControls.model.noRecent': 'Brak ostatnich modeli',
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.titleNamed": "Voltar para: {title}",
"chat.container.returnToParent.title": "Voltar para a sessão principal", "chat.container.returnToParent.title": "Voltar para a sessão principal",
"chat.container.returnToParent.label": "Principal", "chat.container.returnToParent.label": "Principal",
"chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.",
"chat.unifiedControls.title": "Controles", "chat.unifiedControls.title": "Controles",
"chat.unifiedControls.model.title": "Modelo", "chat.unifiedControls.model.title": "Modelo",
"chat.unifiedControls.model.noRecent": "Não há modelos recentes", "chat.unifiedControls.model.noRecent": "Não há modelos recentes",
+1
View File
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
"chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.titleNamed": "Повернутися до: {title}",
"chat.container.returnToParent.title": "Повернутися до батьківської сесії", "chat.container.returnToParent.title": "Повернутися до батьківської сесії",
"chat.container.returnToParent.label": "Батьківська", "chat.container.returnToParent.label": "Батьківська",
"chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.",
"chat.unifiedControls.title": "Елементи керування", "chat.unifiedControls.title": "Елементи керування",
"chat.unifiedControls.model.title": "Модель", "chat.unifiedControls.model.title": "Модель",
"chat.unifiedControls.model.noRecent": "Немає останніх моделей", "chat.unifiedControls.model.noRecent": "Немає останніх моделей",
@@ -1399,6 +1399,7 @@ export const dict: Record<I18nKey, string> = {
'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.titleNamed': '返回到:{title}',
'chat.container.returnToParent.title': '返回父会话', 'chat.container.returnToParent.title': '返回父会话',
'chat.container.returnToParent.label': '父级', 'chat.container.returnToParent.label': '父级',
'chat.container.readOnlySubagentPromptBanner': '无法向子代理会话发送提示。',
'chat.unifiedControls.title': '控制', 'chat.unifiedControls.title': '控制',
'chat.unifiedControls.model.title': '模型', 'chat.unifiedControls.model.title': '模型',
'chat.unifiedControls.model.noRecent': '没有最近使用的模型', 'chat.unifiedControls.model.noRecent': '没有最近使用的模型',
+5
View File
@@ -24,6 +24,7 @@ type ContextPanelTab = {
targetPath: string | null; targetPath: string | null;
dedupeKey: string; dedupeKey: string;
label: string | null; label: string | null;
readOnly: boolean;
touchedAt: number; touchedAt: number;
}; };
@@ -32,6 +33,7 @@ type ContextPanelTabDescriptor = {
targetPath?: string | null; targetPath?: string | null;
dedupeKey?: string | null; dedupeKey?: string | null;
label?: string | null; label?: string | null;
readOnly?: boolean;
}; };
type ContextPanelDirectoryState = { type ContextPanelDirectoryState = {
@@ -201,6 +203,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
targetPath: normalizedTargetPath, targetPath: normalizedTargetPath,
dedupeKey, dedupeKey,
label: normalizeContextTabLabel(descriptor.label), label: normalizeContextTabLabel(descriptor.label),
readOnly: descriptor.readOnly === true,
touchedAt: Date.now(), touchedAt: Date.now(),
}; };
}; };
@@ -239,6 +242,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
targetPath?: unknown; targetPath?: unknown;
dedupeKey?: unknown; dedupeKey?: unknown;
label?: unknown; label?: unknown;
readOnly?: unknown;
touchedAt?: unknown; touchedAt?: unknown;
}; };
@@ -264,6 +268,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
targetPath, targetPath,
dedupeKey, dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null), label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
readOnly: candidate.readOnly === true,
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt) touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt ? candidate.touchedAt
: Date.now(), : Date.now(),