Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-side-conversations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@roomote/web': patch
---

Add side chats alongside active Sessions so people can discuss and act on ongoing work without interrupting the main conversation.
1 change: 1 addition & 0 deletions apps/api/src/handlers/sessions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ async function searchSessions(c: SessionContext): Promise<Response> {
const conditions: Array<SQL | undefined> = [
eq(sessions.visibility, 'visible'),
isNull(sessions.archivedAt),
isNull(sessions.parentSessionId),
customAutomationHistoryAccess(c.get('mcpAuth'), 'session'),
];
if (sessionStatus) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ export function FastSessionTranscript({
headerActions,
timelineExtras,
autoStartVoice = false,
showHeader = true,
updatePageTitle = true,
allowVoice = true,
showInitialThinking = true,
}: {
sessionId: string;
initialMessages: FastSessionMessage[];
Expand All @@ -383,6 +387,10 @@ export function FastSessionTranscript({
* so the first reply is spoken rather than read.
*/
autoStartVoice?: boolean;
showHeader?: boolean;
updatePageTitle?: boolean;
allowVoice?: boolean;
showInitialThinking?: boolean;
}) {
const trpcClient = useTRPCClient();
const openTaskPanel = useOpenSessionTaskPanel();
Expand Down Expand Up @@ -415,22 +423,28 @@ export function FastSessionTranscript({
initialOptimisticMessage
? [...initialMessages, initialOptimisticMessage]
: initialMessages,
(messages) =>
pendingResponseReducer(
{
pendingAfter: null,
latestVisibleResponse: null,
optimisticRollback: null,
},
{ type: 'hydrate', messages },
),
(messages) => {
const initialState: PendingResponseState = {
pendingAfter: null,
latestVisibleResponse: null,
optimisticRollback: null,
};
return messages.length === 0 && !showInitialThinking
? initialState
: pendingResponseReducer(initialState, {
type: 'hydrate',
messages,
});
},
);
const [replyError, setReplyError] = useState<string | null>(null);
const [title, setTitle] = useState<string | null>(initialTitle);
const [conversationResponding, setConversationResponding] = useState<
boolean | null
>(null);
usePageTitle(truncatePageTitle(title ?? fallbackTitle));
usePageTitle(
updatePageTitle ? truncatePageTitle(title ?? fallbackTitle) : null,
);
const streamServiceRef = useRef<AcpProtocolService | null>(null);
const getStreamService = useCallback(
() => (streamServiceRef.current ??= new AcpProtocolService()),
Expand Down Expand Up @@ -1407,33 +1421,37 @@ export function FastSessionTranscript({
value={{ displayMode, hidePrReviewActions: true }}
>
<SlackMentionProvider scope={slackMentionScope}>
<WorkspaceHeader
className="py-3.25"
contentClassName={`${SESSION_HEADER_CONTENT_CLASS_NAME} !flex-row !flex-nowrap`}
actions={headerActions}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h1
className="ph-no-capture min-w-0 truncate cursor-default text-sm font-medium"
title={title ?? fallbackTitle}
>
{title ?? fallbackTitle}
</h1>
{(effectiveSessionModel || headerExtras) && (
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
{effectiveSessionModel ? (
<ModelBadge
model={effectiveSessionModel}
displayName={getTaskModelDisplayName(effectiveSessionModel)}
showIcon={false}
iconClassName="text-muted-foreground"
/>
) : null}
{headerExtras}
</div>
)}
</div>
</WorkspaceHeader>
{showHeader ? (
<WorkspaceHeader
className="py-3.25"
contentClassName={`${SESSION_HEADER_CONTENT_CLASS_NAME} !flex-row !flex-nowrap`}
actions={headerActions}
>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<h1
className="ph-no-capture min-w-0 truncate cursor-default text-sm font-medium"
title={title ?? fallbackTitle}
>
{title ?? fallbackTitle}
</h1>
{(effectiveSessionModel || headerExtras) && (
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted-foreground">
{effectiveSessionModel ? (
<ModelBadge
model={effectiveSessionModel}
displayName={getTaskModelDisplayName(
effectiveSessionModel,
)}
showIcon={false}
iconClassName="text-muted-foreground"
/>
) : null}
{headerExtras}
</div>
)}
</div>
</WorkspaceHeader>
) : null}
<Conversation className="min-h-0 flex-1" initial="instant">
<ConversationContent className="ph-no-capture mx-auto w-full max-w-4xl p-4 pt-0">
{hasOlderMessages ? (
Expand Down Expand Up @@ -1520,7 +1538,7 @@ export function FastSessionTranscript({
defaultModelId={defaultModelId}
defaultReasoningEffort={defaultReasoningEffort}
voice={
voiceEnabled
voiceEnabled && allowVoice
? {
enabled: true,
active:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner';

import { FramedSurface } from '@/components/layout';
import { Loader2Icon } from '@/components/system';
import { useTRPC } from '@/trpc/client';
import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader';
import { FastSessionTranscript } from './FastSessionTranscript';

export function SessionSideChatPanel({
parentSessionId,
onClose,
}: {
parentSessionId: string;
onClose: () => void;
}) {
const trpc = useTRPC();
const startedRef = useRef(false);
const sideChat = useMutation(
trpc.sessions.sideChat.mutationOptions({
onError: (error) => toast.error(error.message),
}),
);
const [detail, setDetail] = useState<typeof sideChat.data>();

useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
void sideChat
.mutateAsync({ sessionId: parentSessionId })
.then(setDetail)
.catch(() => undefined);
}, [parentSessionId, sideChat]);

return (
<FramedSurface
frameClassName="p-0"
surfaceClassName="relative flex flex-col overflow-hidden"
>
<SandboxSidePanelHeader
title="Side chat"
onClose={onClose}
closeLabel="Close side chat"
/>
{detail === undefined && !sideChat.isError ? (
<div
className="flex min-h-0 flex-1 items-center justify-center"
aria-label="Opening side chat"
>
<Loader2Icon className="size-5 animate-spin text-muted-foreground" />
</div>
) : detail ? (
<FastSessionTranscript
sessionId={detail.fastConversationId}
initialMessages={detail.messages}
hasOlderMessages={detail.hasOlderMessages}
canReply
initialTitle={detail.title}
fallbackTitle="Side chat"
sessionModel={detail.model}
sessionReasoningEffort={detail.reasoningEffort}
showHeader={false}
updatePageTitle={false}
allowVoice={false}
showInitialThinking={false}
/>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
Side chat is unavailable for this session.
</div>
)}
</FramedSurface>
);
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import {
Loader2Icon,
LocalDateTime,
Mail,
MessagesSquare,
Popover,
PopoverContent,
PopoverTrigger,
Expand All @@ -81,6 +82,7 @@ import {
useSandboxLayout,
} from '../../use-sandbox-layout';
import { NestedTaskSidePanel } from './NestedTaskSidePanel';
import { SessionSideChatPanel } from './SessionSideChatPanel';
import {
OpenSessionArtifactViewerContext,
OpenSessionTaskPanelContext,
Expand Down Expand Up @@ -891,7 +893,12 @@ export function SessionWorkspace({
};

const utilityPanelContent =
utilityPanel?.kind === 'tasks' ? (
utilityPanel?.kind === 'side-chat' ? (
<SessionSideChatPanel
parentSessionId={session.id}
onClose={closeUtilityPanel}
/>
) : utilityPanel?.kind === 'tasks' ? (
<SessionTasksPanel
tasks={taskCards}
onOpenTask={openTaskPanel}
Expand Down Expand Up @@ -1008,6 +1015,15 @@ export function SessionWorkspace({
sideActions={
<>
<SandboxSideActions isPanelOpen={panelOpen} onShowMain={showMain}>
<SideNavItem
side="right"
label="Side chat"
tooltip="Side chat"
description="Discuss this work without interrupting it"
active={utilityPanel?.kind === 'side-chat'}
icon={MessagesSquare}
onClick={() => togglePanel('side-chat')}
/>
<SideNavItem
side="right"
label="Tasks"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { SessionArtifactViewerSelection } from './session-task-panel-contex
export type UtilityWorkspacePanelKind =
| 'info'
| 'tasks'
| 'side-chat'
| 'artifacts'
| 'previews';

Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/server/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,7 @@ function listConditions(
input.ownedOnly ? sessionOwnerScope(auth) : undefined,
eq(sessions.visibility, 'visible'),
isNull(sessions.archivedAt),
isNull(sessions.parentSessionId),
input.ids ? inArray(sessions.id, input.ids) : undefined,
input.status ? eq(sessions.cachedStatus, input.status) : undefined,
input.user ? sessionCreatorCondition(input.user) : undefined,
Expand Down Expand Up @@ -491,6 +492,7 @@ const baseSelection = {
sourceSurface: sessions.sourceSurface,
sourceTrigger: sessions.sourceTrigger,
fastConversationId: sessions.fastConversationId,
parentSessionId: sessions.parentSessionId,
visibility: sessions.visibility,
activityAt: sessions.activityAt,
cachedStatus: sessions.cachedStatus,
Expand Down Expand Up @@ -781,6 +783,7 @@ export async function getSessionSources(auth: SessionAuth) {
sessionListScope(auth),
eq(sessions.visibility, 'visible'),
isNull(sessions.archivedAt),
isNull(sessions.parentSessionId),
),
)
.orderBy(asc(sessions.sourceSurface));
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/trpc/commands/sessions/index.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading