diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 598a722e2..fcdee832e 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ createDirectMessage: vi.fn(), postMessage: vi.fn(), addReaction: vi.fn(), - hasFastDefault: vi.fn(), processFast: vi.fn(), })); @@ -41,10 +40,6 @@ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUserId, })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../shared/channel-launch-gate.js') @@ -120,6 +115,24 @@ function messagePayload(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so launch-path tests +// use an attachment-only human message (no text for Fast mode to answer). +function attachmentOnlyPayload(overrides: Record = {}) { + return messagePayload({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + function gatewayEvent(payload: Record): DiscordGatewayEvent { return { eventId: String(payload.id), @@ -204,13 +217,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { mocks.createDirectMessage.mockResolvedValue({ id: 'dm-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-message-1' }); mocks.addReaction.mockResolvedValue(undefined); - mocks.hasFastDefault.mockResolvedValue(false); mocks.processFast.mockResolvedValue(undefined); }); - it('routes a linked user default to Fast mode before channel auto-start launch', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - + it('routes a linked-human text message to Fast mode before channel auto-start launch', async () => { await expect(runHandler({})).resolves.toBe(true); await flushBackgroundWork(); @@ -266,6 +276,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { runHandler({ payload: messagePayload({ content: '', + author: { id: 'alert-bot', username: 'alerts', bot: true }, message_snapshots: [ { message: { @@ -315,8 +326,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('launches a linked-human message with instructions as the prompt prefix', async () => { - await expect(runHandler({})).resolves.toBe(true); + it('launches a linked-human attachment message with instructions as the prompt prefix', async () => { + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -347,7 +360,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('forwards message_reference into startNewDiscordTask for reply launches', async () => { await expect( runHandler({ - payload: messagePayload({ + payload: attachmentOnlyPayload({ type: 19, message_reference: { message_id: 'parent-message-1', @@ -472,7 +485,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'skip', reason: 'not an incident' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.evaluateGate).toHaveBeenCalledWith( @@ -507,7 +522,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'error', reason: 'provider unavailable' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).not.toHaveBeenCalled(); @@ -521,7 +538,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('replies when task startup throws', async () => { mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.postMessage).toHaveBeenCalledWith({ @@ -590,7 +609,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('never lets a reaction failure abort the launch', async () => { mocks.addReaction.mockRejectedValue(new Error('rate limited')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).toHaveBeenCalledTimes(1); @@ -603,7 +624,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('releases the routing lock when the launch fails', async () => { mocks.startNewTask.mockRejectedValue(new Error('boom')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.redis.del).toHaveBeenCalledWith( diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index df5e2127b..c39793c95 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -278,6 +278,65 @@ describe('processDiscordFastAgentMessage', () => { }, ); + it('anchors the thread and replies on an explicit anchor message (reaction summons)', async () => { + const provider = { + createThreadFromMessage: vi.fn().mockResolvedValue({ + channelId: 'reacted-1', + parentChannelId: 'channel-1', + name: 'Investigate this', + kind: 'thread', + messageId: 'reacted-1', + }), + editMessage: vi.fn().mockResolvedValue(undefined), + }; + mocks.answerQuestion.mockResolvedValueOnce('A quick answer'); + + await processDiscordFastAgentMessage({ + event: { eventId: 'synthetic-1' } as never, + question: 'Investigate this', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }, + metadata: { + communicationChannelId: 'channel-1', + communicationMessageId: 'reacted-1', + communicationAnchorMessageId: 'reacted-1', + communicationGuildId: 'guild-1', + } as never, + conversationId: 'reacted-1', + anchorMessageId: 'reacted-1', + }); + + // The synthesized message id ('source-1' from getDiscordMessageCreate) is + // not a real Discord message; the reacted-on message anchors everything. + expect(provider.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'reacted-1', + name: 'Investigate this', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ currentMessageId: 'reacted-1' }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: expect.objectContaining({ + channelId: 'reacted-1', + isThread: true, + }), + replyToMessageId: 'reacted-1', + }), + ); + }); + it('continues an existing guild thread without creating another thread', async () => { const provider = { createThreadFromMessage: vi.fn(), diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 0b93a629e..d71a1d310 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ suggestionReaction: vi.fn(), getTaskUrl: vi.fn(), getChannel: vi.fn(), + getMessage: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(), createDirectMessage: vi.fn(), @@ -60,7 +61,6 @@ const mocks = vi.hoisted(() => ({ startGoal: vi.fn(), acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), - hasFastDefault: vi.fn(), hasFastSession: vi.fn(), findFastReplySession: vi.fn(), isFastProviderMessage: vi.fn(), @@ -190,10 +190,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ .mockResolvedValue({ id: 'fast-session-1' }), })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - import { discord, discordGatewayEventProcessingTimeout } from '../index.js'; import { discordApiEventLeaseRenewal } from '../event-gate.js'; @@ -202,6 +198,7 @@ app.route('/api/internal/discord', discord); const provider = { getChannel: mocks.getChannel, + getMessage: mocks.getMessage, addReaction: mocks.addReaction, removeReaction: mocks.removeReaction, createDirectMessage: mocks.createDirectMessage, @@ -236,6 +233,24 @@ function message(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so task-orchestration +// tests use attachment-only messages (no text for Fast mode to answer). +function attachmentMessage(overrides: Record = {}) { + return message({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + async function postEvent(body: unknown, secret = 'gateway-secret') { return app.request('http://localhost/api/internal/discord/events/process', { method: 'POST', @@ -291,6 +306,7 @@ describe('Discord Gateway event handler', () => { mocks.findCompletedRun.mockResolvedValue(null); mocks.findAutomationReportRun.mockResolvedValue(null); mocks.findSourceRun.mockResolvedValue(null); + mocks.getMessage.mockResolvedValue(null); mocks.removeReaction.mockResolvedValue(undefined); mocks.processAttachments.mockResolvedValue({ images: [], @@ -306,7 +322,6 @@ describe('Discord Gateway event handler', () => { vi.fn().mockResolvedValue(undefined), ); mocks.answerFast.mockResolvedValue('A quick answer'); - mocks.hasFastDefault.mockResolvedValue(false); mocks.hasFastSession.mockResolvedValue(false); mocks.findFastReplySession.mockResolvedValue(null); mocks.isFastProviderMessage.mockResolvedValue(false); @@ -382,10 +397,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'use API instead', channel: { id: 'thread-1', type: 11, @@ -404,7 +418,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.handleRoutingReply).toHaveBeenCalledWith( expect.objectContaining({ pendingRouteId: 'pending-route-1', - queuedMessage: expect.objectContaining({ text: 'use API instead' }), + queuedMessage: expect.objectContaining({ text: 'Image: context.png' }), }), ); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -420,7 +434,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('turns a configured reaction into a thread task entry', async () => { + it('routes a configured reaction into the fast agent in a thread anchored on the reacted-on message', async () => { mocks.callViaEmojiConfig.mockResolvedValue({ emoji: 'white_check_mark', prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', @@ -431,6 +445,14 @@ describe('Discord Gateway event handler', () => { type: 0, guildId: 'guild-1', }); + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-1', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); const response = await postEvent({ eventId: 'channel-1:message-1:discord-user-1:white_check_mark', @@ -449,28 +471,85 @@ describe('Discord Gateway event handler', () => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); expect(mocks.channelAutoStart).not.toHaveBeenCalled(); - expect(mocks.addReaction).toHaveBeenCalledWith({ + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-1', - name: 'đź‘€', }); - expect(mocks.startNewTask).toHaveBeenCalledWith( + // The fast thread anchors on the real reacted-on message, not the + // synthesized event id. + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-1', + name: expect.stringContaining('Act on this'), + }); + expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ - requesterDiscordUserId: 'discord-user-1', - launchOwnerUserId: 'roomote-user-1', - queuedMessage: expect.objectContaining({ - text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', - }), - metadata: expect.objectContaining({ - communicationMessageId: 'message-1', - communicationAnchorMessageId: 'message-1', + question: + 'Act on this\n\nAdditional instructions:\nPrioritize safety.\n\nMessage to act on:\nDeploys are failing on main', + userId: 'roomote-user-1', + currentMessageId: 'message-1', + conversation: expect.objectContaining({ + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'message-1', }), + }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'message-1', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-1', + text: expect.stringContaining('A quick answer'), }), ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.queueMessage).not.toHaveBeenCalled(); + }); + + it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => { + mocks.callViaEmojiConfig.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this', + }); + mocks.getChannel.mockResolvedValue({ + id: 'channel-1', + name: 'general', + type: 0, + guildId: 'guild-1', + }); + mocks.getMessage.mockRejectedValue(new Error('rate limited')); + + const response = await postEvent({ + eventId: 'channel-1:message-1:discord-user-1:white_check_mark', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ question: 'Act on this' }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('starts an exactly tracked suggestion before configured emoji routing', async () => { @@ -726,8 +805,8 @@ describe('Discord Gateway event handler', () => { }, ); - it('launches a linked DM request through the Discord task orchestrator', async () => { - const response = await postEvent(envelope(message())); + it('launches a linked DM attachment request through the Discord task orchestrator', async () => { + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.completeEvent).toHaveBeenCalledWith({ @@ -747,7 +826,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, queuedMessage: expect.objectContaining({ provider: 'discord', - text: 'Fix the flaky tests', + text: 'Image: context.png', userId: 'roomote-user-1', }), metadata: { @@ -762,8 +841,6 @@ describe('Discord Gateway event handler', () => { }); it('routes an ordinary linked DM message through Fast mode when the user default is enabled', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - const response = await postEvent(envelope(message())); expect(response.status).toBe(200); @@ -798,7 +875,6 @@ describe('Discord Gateway event handler', () => { }); it('starts a new guild-channel Fast conversation in an anchored thread', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.getChannel.mockResolvedValue({ id: 'channel-1', name: 'general', @@ -851,7 +927,6 @@ describe('Discord Gateway event handler', () => { }); it('passes the model-authored Fast kickoff through the Discord enqueue gate', async () => { - mocks.hasFastDefault.mockResolvedValue(true); const postKickoff = vi.fn().mockResolvedValue(undefined); mocks.startNewTask.mockImplementation( async (input: { @@ -910,7 +985,6 @@ describe('Discord Gateway event handler', () => { }); it('serializes complete Fast turns before the next Discord message enters the agent', async () => { - mocks.hasFastDefault.mockResolvedValue(true); let grantSecondLock!: (release: () => Promise) => void; const secondLock = new Promise<() => Promise>((resolve) => { grantSecondLock = resolve; @@ -982,7 +1056,6 @@ describe('Discord Gateway event handler', () => { }); it('gives defaulted Discord Fast mode the active task for thread continuation', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.findActiveRun.mockResolvedValue({ id: 23, taskId: 'task-23', @@ -1006,11 +1079,11 @@ describe('Discord Gateway event handler', () => { }); const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> can you check if this issue already exists?', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'message-parent', @@ -1047,11 +1120,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: 'Could you expand on the migration note?', message_reference: { message_id: 'announcer-root', channel_id: 'channel-1', @@ -1099,11 +1171,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1117,7 +1189,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.queueMessage).toHaveBeenCalledWith( 'discord', 11, - expect.objectContaining({ text: 'follow up on the first report' }), + expect.objectContaining({ text: 'Image: context.png' }), ); expect(mocks.findActiveRun).not.toHaveBeenCalled(); }); @@ -1148,11 +1220,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1173,7 +1245,7 @@ describe('Discord Gateway event handler', () => { it('still launches when the initial eyes reaction fails', async () => { mocks.addReaction.mockRejectedValueOnce(new Error('rate limited')); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -1230,7 +1302,7 @@ describe('Discord Gateway event handler', () => { ); }); - it('queues an ordinary message in an active Discord task thread with full thread context', async () => { + it('queues an attachment-only message in an active Discord task thread with full thread context', async () => { mocks.getChannel.mockResolvedValue({ id: 'thread-1', guildId: 'guild-1', @@ -1246,10 +1318,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Also fix the type error', }), ), ); @@ -1260,7 +1331,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', }), }), ); @@ -1269,7 +1340,7 @@ describe('Discord Gateway event handler', () => { taskId: 'task-23', provider: 'discord', message: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -1278,7 +1349,7 @@ describe('Discord Gateway event handler', () => { 'discord', 23, expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), turnPolicy: { reactionsAllowed: true }, }), @@ -1309,10 +1380,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'what about that earlier note?', message_reference: { message_id: 'earlier-1', channel_id: 'thread-1', @@ -1328,7 +1398,7 @@ describe('Discord Gateway event handler', () => { replyToMessageId: 'earlier-1', replyToChannelId: 'thread-1', queuedMessage: expect.objectContaining({ - text: 'what about that earlier note?', + text: 'Image: context.png', }), }), ); @@ -1361,10 +1431,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'yes fix those', }), ), ); @@ -1384,7 +1453,7 @@ describe('Discord Gateway event handler', () => { mocks.findSourceRun.mockResolvedValue({ id: 23, taskId: 'task-23' }); mocks.getTaskUrl.mockReturnValue('https://roomote.example/task/task-23'); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.queueMessage).not.toHaveBeenCalled(); @@ -2334,10 +2403,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'discussion-thread', guild_id: 'guild-1', - content: '<@bot-1> investigate the flaky build', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ), @@ -2376,10 +2445,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2390,7 +2458,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', }), }), ); @@ -2413,7 +2481,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, }, queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -2440,10 +2508,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2507,10 +2574,10 @@ describe('Discord Gateway event handler', () => { }, ); const originalEvent = envelope( - message({ + attachmentMessage({ channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> fix this', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ); @@ -2560,7 +2627,7 @@ describe('Discord Gateway event handler', () => { requesterDiscordUserId: 'discord-user-1', launchOwnerUserId: 'roomote-user-1', queuedMessage: expect.objectContaining({ - text: 'fix this', + text: 'Image: context.png', ts: 'message-1', userId: 'roomote-user-1', }), @@ -2577,7 +2644,7 @@ describe('Discord Gateway event handler', () => { }); it('restores the pending request and link code when continuation fails', async () => { - const originalEvent = envelope(message()); + const originalEvent = envelope(attachmentMessage()); mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); @@ -2722,27 +2789,40 @@ describe('Discord Gateway event handler', () => { }, }; + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-target', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); + const response = await postEvent( envelope(interaction, 'INTERACTION_CREATE'), ); expect(response.status).toBe(200); - expect(mocks.startNewTask).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - communicationMessageId: 'message-target', - communicationAnchorMessageId: 'message-target', - }), - replyToMessageId: 'message-target', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-target', - }), - ); - expect(mocks.addReaction).toHaveBeenCalledWith({ + // The replayed reaction summon enters the fast agent anchored on the + // reacted-on message, matching direct reaction entry. + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-target', - name: 'đź‘€', }); + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'channel-1', + messageId: 'message-target', + }), + ); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ + question: + 'Act on this\n\nMessage to act on:\nDeploys are failing on main', + currentMessageId: 'message-target', + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('requires /link in a DM without consuming the one-shot code', async () => { diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2fe6de4a6..6523501ed 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -24,7 +24,6 @@ import { } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { checkAutoStartChannelCache } from '../shared/auto-start-cache.js'; import { CHANNEL_AUTO_START_FAILURE_MESSAGE, @@ -279,10 +278,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { getDiscordMessageContent(message), botUserId, ); - if ( - defaultFastQuestion && - (await hasCommunicationsFastModeDefault(mappedUserId)) - ) { + if (defaultFastQuestion) { void processDiscordFastAgentMessage({ event, question: defaultFastQuestion, diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 023ac8e0a..863a5eb0f 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -85,14 +85,23 @@ export async function processDiscordFastAgentMessage(input: { metadata: ReturnType; conversationId: string; createAnchoredThread?: boolean; + /** + * The real Discord message replies and anchored threads attach to. Defaults + * to the inbound message's own id; reaction summons pass the reacted-on + * message because their synthesized message id is not a real Discord + * message. + */ + anchorMessageId?: string; interaction?: DiscordInteractionReplyContext; activeTasks?: { taskId: string }[]; }): Promise { const message = getDiscordMessageCreate(input.event); + const anchorMessageId = input.anchorMessageId ?? message?.id; let channel = input.channel; let metadata = input.metadata; if ( message && + anchorMessageId && input.createAnchoredThread !== false && !channel.isDirectMessage && !channel.isThread && @@ -100,7 +109,7 @@ export async function processDiscordFastAgentMessage(input: { ) { const thread = await input.provider.createThreadFromMessage({ channelId: channel.channelId, - messageId: message.id, + messageId: anchorMessageId, name: buildCommunicationTaskThreadName(input.question), }); channel = { @@ -179,7 +188,7 @@ export async function processDiscordFastAgentMessage(input: { applicationId: input.applicationId, channel, ...(input.interaction ? { interaction: input.interaction } : {}), - ...(message ? { replyToMessageId: message.id } : {}), + ...(anchorMessageId ? { replyToMessageId: anchorMessageId } : {}), text: textWithFooter, }); await recordFastAgentConversationMessageBestEffort({ @@ -218,7 +227,7 @@ export async function processDiscordFastAgentMessage(input: { userId: input.senderUserId, apiBaseUrl, conversation, - currentMessageId: message?.id ?? input.interaction?.interaction.id, + currentMessageId: anchorMessageId ?? input.interaction?.interaction.id, signal: releaseFastAgentLock.signal, senderDisplayName: input.interaction?.interaction.member?.nick ?? diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index e8a7b8cd8..2657563bb 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -46,7 +46,6 @@ import { } from '@roomote/sdk/server'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { @@ -745,12 +744,11 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + // Fast mode is unconditional for ordinary linked-human messages, including + // reaction summons: a configured emoji synthesizes a bot mention that enters + // the fast agent, matching Slack's call-roomote-via-emoji flow. const defaultFastMessage = - message != null && - command == null && - (await hasCommunicationsFastModeDefault(senderUserId)) - ? message - : null; + message != null && command == null ? message : null; if (command?.name === 'goal') { if (!command.objective) { @@ -811,6 +809,11 @@ async function processDiscordGatewayEvent( conversationId: repliedFastSession?.conversation.conversationId ?? channel.channelId, ...(repliedFastSession ? { createAnchoredThread: false } : {}), + // A reaction summon's synthesized message id is not a real Discord + // message; anchor replies on the reacted-on message instead. + ...(reactionTarget + ? { anchorMessageId: reactionTarget.messageId } + : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastContinued: true }; @@ -823,19 +826,42 @@ async function processDiscordGatewayEvent( ) : ''; if (defaultFastMessage && defaultFastQuestion) { + let fastQuestion = defaultFastQuestion; + if (reactionTarget) { + // Match Slack's emoji summon: inline the reacted-on message so the fast + // agent sees what it was asked to act on even without thread history. + try { + const targetMessage = await resolved.provider.getMessage({ + channelId: reactionTarget.channelId, + messageId: reactionTarget.messageId, + }); + if (targetMessage?.text) { + fastQuestion = `${defaultFastQuestion}\n\nMessage to act on:\n${targetMessage.text}`; + } + } catch (error) { + apiLogger.warn( + `[discord] Could not resolve emoji summon target ${reactionTarget.channelId}:${reactionTarget.messageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } await processDiscordFastAgentMessage({ event, - question: defaultFastQuestion, + question: fastQuestion, sender, senderUserId, provider: resolved.provider, applicationId: resolved.applicationId, channel, metadata, + // A reaction summon anchors its fast conversation (and any created + // thread) on the reacted-on message, mirroring Slack threading under + // the reacted-on message; the synthesized message id is not a real + // Discord message. conversationId: getDiscordFastConversationId( channel, - defaultFastMessage.id, + reactionTarget?.messageId ?? defaultFastMessage.id, ), + ...(reactionTarget ? { anchorMessageId: reactionTarget.messageId } : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastDefaulted: true }; diff --git a/apps/api/src/handlers/fast-agent-entry.test.ts b/apps/api/src/handlers/fast-agent-entry.test.ts deleted file mode 100644 index a8d9c61f4..000000000 --- a/apps/api/src/handlers/fast-agent-entry.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -const mocks = vi.hoisted(() => ({ - findUser: vi.fn(), -})); - -vi.mock('@roomote/db/server', () => ({ - db: { query: { users: { findFirst: mocks.findUser } } }, - eq: vi.fn(), - users: { id: 'users.id' }, -})); - -import { hasCommunicationsFastModeDefault } from './fast-agent-entry'; - -describe('hasCommunicationsFastModeDefault', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('returns the stored preference', async () => { - mocks.findUser.mockResolvedValue({ - metadata: { communications_fast_mode_default: true }, - }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - true, - ); - }); - - it('returns false when the stored preference is not enabled', async () => { - mocks.findUser.mockResolvedValue({ metadata: {} }); - - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - false, - ); - }); -}); diff --git a/apps/api/src/handlers/fast-agent-entry.ts b/apps/api/src/handlers/fast-agent-entry.ts index a87bb5f88..bb7ad4585 100644 --- a/apps/api/src/handlers/fast-agent-entry.ts +++ b/apps/api/src/handlers/fast-agent-entry.ts @@ -1,5 +1,3 @@ -import { db, eq, users } from '@roomote/db/server'; - type FastAgentEntryMode = 'explicit' | 'default'; export function resolveFastAgentEntryMode(params: { @@ -12,21 +10,3 @@ export function resolveFastAgentEntryMode(params: { return params.userDefaultEnabled ? 'default' : null; } - -export async function hasCommunicationsFastModeDefault( - userId: string, -): Promise { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { metadata: true }, - }); - const metadata = user?.metadata; - - return ( - typeof metadata === 'object' && - metadata !== null && - !Array.isArray(metadata) && - (metadata as Record).communications_fast_mode_default === - true - ); -} diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 438ae3530..4f7cacf02 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -150,7 +150,6 @@ describe('channel auto-start unlinked author', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); const { handleMessageOrAppMentionEvent } = diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index f459d5d25..8b3548ec4 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -1248,11 +1248,9 @@ async function maybeHandleChannelAutoStart(params: { explicitInvocation: isBareFastCommandInvocation( channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, ), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation( - channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, - ), + userDefaultEnabled: !isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ), }) : null; @@ -1733,9 +1731,7 @@ async function handleSlackEntryEvent(params: { const authoredEventText = event.authoredText ?? event.text; const fastAgentEntryMode = resolveFastAgentEntryMode({ explicitInvocation: isFastCommandInvocation(authoredEventText), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(authoredEventText), + userDefaultEnabled: !isRemovedEvalCommandInvocation(authoredEventText), }); if (fastAgentEntryMode) { diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index f339fbb5b..b66f5fb43 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,7 +24,6 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', - metadata: 'users.metadata', }, })); @@ -52,7 +51,6 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -68,7 +66,6 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, - communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); @@ -85,7 +82,6 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), - userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 5537804bc..4aa738842 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,9 +8,7 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: - | (SlackUserMapping & { communicationsFastModeDefault: boolean }) - | null; + activeMapping: SlackUserMapping | null; hasInactiveMapping: boolean; }; @@ -28,7 +26,6 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, - userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -62,12 +59,6 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - communicationsFastModeDefault: - typeof row.userMetadata === 'object' && - row.userMetadata !== null && - !Array.isArray(row.userMetadata) && - (row.userMetadata as Record) - .communications_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts new file mode 100644 index 000000000..040a492fb --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -0,0 +1,211 @@ +import { + db, + eq, + fastAgentConversations, + inArray, + sessionBackfillState, + sessionFactory, + sessionTasks, + sessions, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { sessionsReconcileJob } from '../sessions-reconcile'; + +const BACKFILL_KEY = 'unified-sessions-v1'; + +describe('sessionsReconcileJob', () => { + it('backfills Fast conversations and visible tasks idempotently', async () => { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + }); + + it('adopts orphan Fast conversations during steady-state reconciliation', async () => { + // Complete (or advance) the one-time backfill first so the next run takes + // the steady-state reconciliation path. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + }); + + it('resumes a backfill parked in the legacy fast_tasks phase', async () => { + await db + .insert(sessionBackfillState) + .values({ key: BACKFILL_KEY, phase: 'fast_tasks' }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: 'fast_tasks', + cursorCreatedAt: null, + cursorId: null, + completedAt: null, + }, + }); + const user = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + expect(state?.completedAt).not.toBeNull(); + }); + + it('continues past a poisoned row during steady-state reconciliation', async () => { + // Ensure the backfill is complete so the steady-state path runs. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + // A surface value the sessions check constraint rejects makes + // ensureSessionForFastConversation throw for this row only. + const [poisoned] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'bogus' as never, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const [healthy] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await expect(sessionsReconcileJob()).resolves.toBeUndefined(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, healthy!.id)), + ).resolves.toHaveLength(1); + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, poisoned!.id)), + ).resolves.toHaveLength(0); + + // A failed adoption must NOT advance the reconcile watermark, so the + // failed row stays inside the next run's scan window instead of being + // stranded past the cutoff once the failure clears. + const watermarkBefore = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, poisoned!.id)); + await sessionsReconcileJob(); + const watermarkAfter = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + expect(watermarkAfter?.cursorCreatedAt?.getTime() ?? 0).toBeGreaterThan( + watermarkBefore?.cursorCreatedAt?.getTime() ?? 0, + ); + }); + + it('drains an over-batch orphan backlog across runs without stranding rows', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + // 101 orphans: one full batch plus one. A full batch must NOT advance + // the watermark, so the next run still sees (and adopts) the remainder. + const user = await userFactory.create(); + const rows = await db + .insert(fastAgentConversations) + .values( + Array.from({ length: 101 }, () => ({ + userId: user.id, + surface: 'web' as const, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + })), + ) + .returning({ id: fastAgentConversations.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const ids = rows.map((row) => row.id); + const adopted = await db + .select({ id: sessions.fastConversationId }) + .from(sessions) + .where(inArray(sessions.fastConversationId, ids)); + expect(adopted).toHaveLength(101); + }); + + it('heals sessions wedged active on an expired responding lease', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const wedged = await sessionFactory.create({ + cachedStatus: 'active', + respondingUntil: new Date(Date.now() - 60_000), + // Old activity keeps it clear of the recent-activity refresh window. + activityAt: 100, + }); + + await sessionsReconcileJob(); + + const [healed] = await db + .select({ cachedStatus: sessions.cachedStatus }) + .from(sessions) + .where(eq(sessions.id, wedged.id)); + expect(healed?.cachedStatus).toBe('ready'); + + await db.delete(sessions).where(eq(sessions.id, wedged.id)); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts index 6be114ec8..a2b2061f0 100644 --- a/apps/bullmq/src/scheduled-jobs/index.ts +++ b/apps/bullmq/src/scheduled-jobs/index.ts @@ -9,3 +9,4 @@ export { standbyRetentionJob } from './standby-retention'; export { prReviewNotificationDispatchJob } from './pr-review-notification-dispatch'; export { brainOutboxDrainJob, brainCollectorsJob } from './brain-outbox-drain'; export { brainMaintenanceJob } from './brain-maintenance'; +export { sessionsReconcileJob } from './sessions-reconcile'; diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts new file mode 100644 index 000000000..52a2ec63a --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -0,0 +1,393 @@ +import { + and, + db, + desc, + ensureSessionForFastConversation, + ensureSessionForTask, + eq, + fastAgentConversations, + gt, + inArray, + isNull, + lt, + or, + sessionBackfillState, + sessions, + sessionTasks, + sql, + taskRuns, + tasks, + touchSessionActivity, +} from '@roomote/db/server'; +const LOG_PREFIX = '[sessions]'; +const BACKFILL_KEY = 'unified-sessions-v1'; +/** + * Steady-state reconcile watermark, stored as a second state row: its + * cursorCreatedAt marks the scan-start time of the last orphan pass that + * completed with ZERO failures. Advancing only on clean passes means a + * transient outage keeps failed rows inside the scan window until they + * actually converge, instead of stranding them past the cutoff forever. + */ +const RECONCILE_KEY = 'unified-sessions-reconcile-v1'; +const RECONCILE_CURSOR_ID = 'watermark'; +const BATCH_SIZE = 100; +/** Slack subtracted from the last-run watermark when bounding orphan scans. */ +const ORPHAN_SCAN_SLACK_MS = 60 * 60 * 1000; + +type Cursor = { createdAt: Date; id: string } | null; + +function afterCursor( + createdAt: TCreatedAt, + id: TId, + cursor: Cursor, +) { + return cursor + ? or( + gt(createdAt as never, cursor.createdAt), + and( + eq(createdAt as never, cursor.createdAt), + gt(id as never, cursor.id), + ), + ) + : undefined; +} + +async function updateState(input: { + phase: 'fast_conversations' | 'tasks' | 'participants'; + cursor?: Cursor; + completed?: boolean; +}) { + await db + .insert(sessionBackfillState) + .values({ + key: BACKFILL_KEY, + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function backfillFastConversations(cursor: Cursor): Promise { + const rows = await db + .select({ + id: fastAgentConversations.id, + createdAt: fastAgentConversations.createdAt, + }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + afterCursor( + fastAgentConversations.createdAt, + fastAgentConversations.id, + cursor, + ), + ), + ) + .orderBy(fastAgentConversations.createdAt, fastAgentConversations.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, row.id), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} backfill failed for fast conversation ${row.id}`, + error, + ); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'fast_conversations' : 'tasks', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill fast conversations`, { + processed: rows.length, + }); + return rows.length < BATCH_SIZE; +} + +async function backfillTasks(cursor: Cursor): Promise { + const rows = await db + .select({ id: tasks.id, createdAt: tasks.createdAt }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + afterCursor(tasks.createdAt, tasks.id, cursor), + ), + ) + .orderBy(tasks.createdAt, tasks.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + const latestFastRun = await db.query.taskRuns.findFirst({ + where: and( + eq(taskRuns.taskId, row.id), + sql`${taskRuns.fastAgentSessionId} IS NOT NULL`, + ), + columns: { fastAgentSessionId: true }, + orderBy: desc(taskRuns.id), + }); + await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: row.id, + fastConversationId: latestFastRun?.fastAgentSessionId ?? null, + origin: 'backfill', + }), + ); + } catch (error) { + console.error(`${LOG_PREFIX} backfill failed for task ${row.id}`, error); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'tasks' : 'participants', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill tasks`, { processed: rows.length }); + return rows.length < BATCH_SIZE; +} + +async function backfillParticipants(): Promise { + await db.execute(sql` + INSERT INTO session_participants (session_id, user_id, role) + SELECT DISTINCT s.id, fam.metadata->>'userId', 'member' + FROM sessions s + JOIN fast_agent_messages fam ON fam.conversation_id = s.fast_conversation_id + JOIN users u ON u.id = fam.metadata->>'userId' AND u.deleted_at IS NULL + WHERE fam.metadata->>'userId' IS NOT NULL + ON CONFLICT (session_id, user_id) DO NOTHING + `); + await updateState({ phase: 'participants', completed: true }); + console.info(`${LOG_PREFIX} backfill participants complete`); +} + +async function reconcileRecentSessions(watermark: Date | null): Promise { + // Bound the steady-state orphan scans to rows created since the last + // fully-successful pass (with slack) so they stop scanning entire tables + // every run. A null watermark (first run, or no clean pass yet) scans + // unbounded. + const cutoff = watermark + ? new Date(watermark.getTime() - ORPHAN_SCAN_SLACK_MS) + : null; + const scanStartedAt = new Date(); + let orphanFailures = 0; + + // Fast conversations without a session row (e.g. created before this + // release finished its backfill) are adopted here so the unified list + // converges without another full backfill. + const orphanConversations = await db + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + cutoff ? gt(fastAgentConversations.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(fastAgentConversations.updatedAt)) + .limit(BATCH_SIZE); + + for (const conversation of orphanConversations) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation.id), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for fast conversation ${conversation.id}`, + error, + ); + } + } + + const orphanTasks = await db + .select({ id: tasks.id }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + cutoff ? gt(tasks.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(tasks.activityAt)) + .limit(BATCH_SIZE); + + for (const task of orphanTasks) { + try { + await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for task ${task.id}`, + error, + ); + } + } + + const recent = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where(eq(sessions.visibility, 'visible')) + .orderBy(desc(sessions.activityAt)) + .limit(BATCH_SIZE); + for (const session of recent) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} refresh failed for session ${session.id}`, + error, + ); + } + } + + // Sessions stuck 'active'/'needs_input' on an expired (or missing) lease + // may be older than the top-100-by-activity window; heal them explicitly + // so wedged sessions converge regardless of recency. + const expiredLeases = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where( + and( + eq(sessions.visibility, 'visible'), + inArray(sessions.cachedStatus, ['active', 'needs_input']), + or( + isNull(sessions.respondingUntil), + lt(sessions.respondingUntil, new Date()), + ), + ), + ) + .limit(BATCH_SIZE); + for (const session of expiredLeases) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} lease heal failed for session ${session.id}`, + error, + ); + } + } + + // Advance the watermark only when this pass definitely drained the + // backlog: zero adoption failures AND neither scan returned a full batch + // (a full batch means older rows may remain beyond the LIMIT). Otherwise + // the next run rescans the same window until it converges. Failures in + // the touch/heal loops don't affect orphan scanning. + const sawFullBatch = + orphanConversations.length === BATCH_SIZE || + orphanTasks.length === BATCH_SIZE; + if (orphanFailures === 0 && !sawFullBatch) { + await db + .insert(sessionBackfillState) + .values({ + key: RECONCILE_KEY, + phase: 'participants', + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + completedAt: null, + lastRunAt: scanStartedAt, + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + lastRunAt: scanStartedAt, + updatedAt: new Date(), + }, + }); + } else { + console.warn( + `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed, fullBatch=${sawFullBatch}`, + ); + } + + console.info(`${LOG_PREFIX} reconciliation`, { + orphanFastConversations: orphanConversations.length, + orphanVisibleTasks: orphanTasks.length, + refreshedSessions: recent.length, + healedExpiredLeases: expiredLeases.length, + }); +} + +export async function sessionsReconcileJob(): Promise { + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + if (state?.completedAt) { + const reconcileState = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, RECONCILE_KEY), + }); + await reconcileRecentSessions(reconcileState?.cursorCreatedAt ?? null); + return; + } + + const phase = state?.phase ?? 'fast_conversations'; + const cursor = + state?.cursorCreatedAt && state.cursorId + ? { createdAt: state.cursorCreatedAt, id: state.cursorId } + : null; + + if (phase === 'fast_conversations') { + const complete = await backfillFastConversations(cursor); + if (!complete) return; + } + // 'fast_tasks' is the pre-rename name of the tasks phase; deployments that + // ran an earlier build of this branch may still be parked there. + if ( + phase === 'fast_conversations' || + phase === 'fast_tasks' || + phase === 'tasks' + ) { + const complete = await backfillTasks( + phase === 'fast_tasks' || phase === 'tasks' ? cursor : null, + ); + if (!complete) return; + } + await backfillParticipants(); +} diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts index 360d37127..44b34dfa5 100644 --- a/apps/bullmq/src/scheduler.ts +++ b/apps/bullmq/src/scheduler.ts @@ -35,6 +35,7 @@ import { brainOutboxDrainJob, brainCollectorsJob, brainMaintenanceJob, + sessionsReconcileJob, } from './scheduled-jobs'; const QUEUE_NAME = 'scheduled-jobs'; @@ -225,6 +226,10 @@ async function createJobs(queue: Queue): Promise { { pattern: '0 7 * * *' }, ); + await queue.upsertJobScheduler(ScheduledJobName.SessionsReconcile, { + every: 60 * 1000, + }); + const schedulers = await queue.getJobSchedulers(); console.log('[createJobs] getJobSchedulers ->', schedulers); } @@ -266,6 +271,8 @@ const runJobs = async (job: ScheduledJob): Promise => { return brainCollectorsJob(); case ScheduledJobName.BrainMaintenance: return brainMaintenanceJob(); + case ScheduledJobName.SessionsReconcile: + return sessionsReconcileJob(); case ScheduledJobName.CustomAutomations: await customAutomationsJob(); return; diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts index 6393c98a6..9e3730035 100644 --- a/apps/bullmq/src/types.ts +++ b/apps/bullmq/src/types.ts @@ -18,6 +18,7 @@ export enum ScheduledJobName { BrainOutboxDrain = 'BrainOutboxDrain', BrainCollectors = 'BrainCollectors', BrainMaintenance = 'BrainMaintenance', + SessionsReconcile = 'SessionsReconcile', } /** diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 9fbfd2b5e..ee44179eb 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,32 +1,41 @@ --- -title: Fast sessions -icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast session transcript. +title: Sessions +icon: messages-square +description: Follow a conversation and every execution it delegates from one continuous Roomote workspace. --- -Fast is Roomote's conversational orchestrator: it answers directly when it can -and delegates execution work into tasks when needed. A Fast session persists -across Slack, Discord, Microsoft Teams, Telegram, an automation, or the web -dashboard. +Sessions are the primary way to follow work in Roomote. A Session keeps the +conversation, delegated executions, review activity, artifacts, pull requests, +cost, and unread state together, whether it started in chat, source control, an +automation, the API, or the web dashboard. Fast is the conversational +orchestrator inside a Session: it answers directly when it can and delegates +execution work when needed across Slack, Discord, Microsoft Teams, Telegram, +automations, and the web dashboard. -## Start a Fast session from the dashboard +## Start a Session from the dashboard -On the home page, open the workspace selector next to the prompt box and choose -**Fast**. Your prompt starts a Fast session instead of a sandbox task, and -Roomote takes you straight to the session view, where the response streams in -as it is produced. +On the home page, leave the workspace selector on **Auto** to start a +conversation. Roomote answers directly when it can and delegates execution +when the request needs a repository workspace. Selecting an environment or +repository starts the execution directly, but Roomote still creates the +owning Session and opens it with that execution selected. -Use Fast when you want an answer, a decision, or a delegation rather than a -full sandbox run. Fast can still launch tasks on your behalf; delegated tasks -appear in the transcript with links to their task pages. +You do not need to choose a separate conversation mode. The Session grows from +conversation to execution to review without changing identity. ## The session view -A session's transcript shows prompts, replies, and the tool activity behind -them, rendered with the same transcript view as tasks, with a generated title -that updates as the session evolves. The view updates in real time while -a turn is running, so you can watch tool calls complete and replies land -without refreshing. +A Session timeline shows prompts, replies, and delegated execution activity. +Execution cards show their status, workspace, pull requests, artifacts, latest +error, and cost. Select a card to open the lightweight details panel, or choose +**Open full workspace** for terminal, logs, diff, and preview tools. + +The Sessions page supports list and board views, filters, search, pins, recent +Sessions, and unread indicators. **Ready** is not a terminal state: you can +reply or start another execution in the same Session later. + +The transcript renders prompts, replies, and tool activity in real time with a +generated title that updates as the Session evolves. Fast sessions can also render presentational widgets such as status cards, tables, and plans directly in the transcript. Widget HTML is sanitized and @@ -35,15 +44,21 @@ fallback. ## Reply to a session -Every session has a reply box at the bottom of the transcript; follow-ups -continue the same session with full context. For sessions that live -on another surface, such as a Slack thread, Roomote's answer is posted back -into the originating thread with a quoted copy of your web message, so the -session stays in one place for everyone following it there. Fast replies -across Slack, Discord, Microsoft Teams, and Telegram carry a "Reply or use the -web app" footer linking to the session view. Slack and Discord can also resume -Fast directly from chat. Microsoft Teams replies to Fast session and automation -messages also continue the same session after Roomote verifies the tenant, -installation, conversation, and linked user. Telegram currently uses the -session view for Fast follow-ups because its inbound webhook route does not yet -carry Fast session identity. +Conversational Sessions have a reply box at the bottom of the transcript; +follow-ups continue the same conversation with full context. For conversations +that live on another surface, Roomote posts the answer back into the originating +thread with a quoted copy of your web message, so the conversation stays in one +place for everyone following it there. + +Fast replies across Slack, Discord, Microsoft Teams, and Telegram link back to +the Session view. Slack and Discord can also resume Fast directly from chat. +Microsoft Teams replies continue the same Session after Roomote verifies the +tenant, installation, conversation, and linked user. Telegram currently uses +the Session view for Fast follow-ups because its inbound webhook route does not +yet carry Fast Session identity. + +## Execution access + +Session participants can see timeline summaries. Full execution details keep +the existing task permissions, so joining a shared channel does not grant +access to logs, terminals, diffs, previews, or private artifacts. diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 02f976dee..8af762abb 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,11 +64,6 @@ Personal Settings also include app preferences such as: - **Mind Reader Mode** to expand LLM thoughts by default in task conversations; you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view -- **Fast response mode** to select Fast by default for new homepage prompts and - use Fast responses by default for messages sent from your linked Slack and - Discord accounts. An explicit homepage workspace choice takes precedence. - The chat preference does not apply to GitHub, Teams, or Telegram. You can - still use `!fast` explicitly in Slack whether the preference is on or off. Most teammates only need profile, linked accounts, and theme settings. diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 3c8b773ec..a3857c3af 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -120,9 +120,9 @@ under **Settings > Automations**, the same way you would pick a Slack channel. current one - use `/goal objective:` to keep working toward an objective across multiple turns in an active task thread or DM; this does not create a new task -- enable **Fast response mode** under **Settings > Personal** to send ordinary - Discord DMs, mentions, and eligible thread replies from your linked account - through the fast orchestrator +- ordinary Discord DMs, mentions, and eligible thread replies from your linked + account are always answered in Fast mode, which can delegate repository work + into tasks - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 113c832c0..7e5aa0671 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -181,9 +181,9 @@ Mention the app and use `!fast ` to ask the fast orchestrator a question or delegate work into a task. For example: `@Roomote !fast summarize this thread` or `@Roomote !fast fix the failing CI job`. -Enable **Fast response mode** under **Settings > Personal** to send ordinary -messages from your linked Slack and Discord accounts through the fast -orchestrator without an explicit command. +**Fast response mode** is always on: ordinary messages from your linked Slack +and Discord accounts go through the fast orchestrator, which can delegate work +into tasks. `!fast` remains available for an explicit Fast request. Fast can read a bounded history from the current Slack channel, use MCP servers and user-scoped integrations that you are allowed to access, and delegate diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index ce1107bea..6ae7279d5 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -4,9 +4,10 @@ icon: clipboard-check description: Inspect the transcript, logs, diffs, previews, and follow-up path before you trust the result. --- -A task is a single unit of Roomote work. It may start from chat, source -control, Linear, or the web dashboard, but the task view gives your team one -shared place to inspect what happened and decide what should happen next. +A task is one independently controllable execution inside a Session. It may +start from chat, source control, Linear, the API, or the web dashboard. The +task workspace remains the place to inspect operational details such as logs, +terminal output, diffs, previews, retries, and artifacts. Use the task view as the handoff point between Roomote and your normal review process. A task is complete only when the evidence is clear enough for a @@ -24,19 +25,17 @@ Before you dive into details, check the basics: - whether the end state matches the kind of outcome you wanted: answer, plan, patch, branch, or PR -## Task board +## Sessions and the task board -Use the board view on the Tasks page to scan shared work by lifecycle. Roomote -places tasks in **Active**, **Needs input**, **Blocked / failed**, or **Done** +Use the board view on the Sessions page to scan shared work by lifecycle. +Roomote places Sessions in **Active**, **Needs input**, **Blocked**, or **Ready** from their current task, goal, and run state, so your team does not need to maintain a separate status field. -Each card shows who started the task, participant avatars, recent activity, and -available workspace or pull-request context. The Done column keeps the six most -recent completed tasks so finished work does not overwhelm active work. Board -and list choices remain in the URL so views are shareable. Roomote also restores -the most recently selected layout from browser storage when you return; if -browser storage is unavailable, the Tasks page falls back to list view. +Each Session card shows its owner and participants, recent activity, delegated +execution count, workspace or pull-request context, aggregate cost, and unread +state. Use the **Tasks** scope when you only want Sessions containing execution +work. Board and list choices remain in the URL so views are shareable. ## Recover from a failed start @@ -50,6 +49,8 @@ reattach any files the new task needs. The task view gives you the working context for a run: +- a header breadcrumb linking back to the owning Session (when you opened the + workspace from a filtered Sessions view, browser Back returns to that view) - conversation history and Roomote updates - inline widgets for structured tables, status cards, plans, and other presentational results an agent chooses to show diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx index 47013ad37..f3451106b 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx @@ -56,6 +56,8 @@ const analyticsFilterKeys = [ 'taskType', 'provider', 'model', + 'ownerKind', + 'hasExecution', ] as const; type SelectedAnalyticsSegment = { @@ -65,7 +67,11 @@ type SelectedAnalyticsSegment = { seriesLabel: string; }; -const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = ['tasks', 'pullRequests']; +const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = [ + 'tasks', + 'sessions', + 'pullRequests', +]; function parseAnalyticsObject( value: string | null, @@ -75,7 +81,7 @@ function parseAnalyticsObject( return value as AnalyticsObject; } - return allowedObjects[0] ?? analyticsObjects[0]; + return allowedObjects[0] ?? analyticsObjects[0] ?? 'tasks'; } function getFiltersFromSearchParams( diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx index 7f0acd1fd..c0513d34c 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx @@ -42,12 +42,14 @@ type AnalyticsDetailsDialogProps = { }; const DIALOG_WIDTH_BY_OBJECT: Record = { + sessions: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', tasks: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', pullRequests: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', costs: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', }; const TABLE_MIN_WIDTH_BY_OBJECT: Record = { + sessions: 'min-w-[900px] md:min-w-[1040px]', tasks: 'min-w-[980px] md:min-w-[1100px]', pullRequests: 'min-w-[1140px] md:min-w-[1220px]', costs: 'min-w-[1140px] md:min-w-[1220px]', diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts index 0e440a02d..35a5b9c57 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts @@ -10,6 +10,7 @@ import { GitPullRequest, RadioTower, VectorSquare, + Rows4, } from '@/components/system'; export const ANALYTICS_DIMENSION_ICONS: Record< @@ -25,4 +26,6 @@ export const ANALYTICS_DIMENSION_ICONS: Record< taskType: Bot, provider: Cpu, model: Brain, + ownerKind: Bot, + hasExecution: Rows4, }; diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx index c2dcda2d3..a7b2e2c7f 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx @@ -39,6 +39,8 @@ const ANALYTICS_DIMENSION_PLURAL_LABELS: Record = { taskType: 'Task Types', provider: 'Providers', model: 'Models', + ownerKind: 'Owner kinds', + hasExecution: 'Execution states', }; type AnalyticsFilterBarProps = { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx index 2311c1ac7..f5caa6d23 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx @@ -16,6 +16,8 @@ type AnalyticsShellItemId = AnalyticsObject; export function getAnalyticsHref(itemId: AnalyticsShellItemId) { switch (itemId) { + case 'sessions': + return '/analytics?object=sessions'; case 'tasks': return '/analytics'; case 'pullRequests': @@ -26,6 +28,7 @@ export function getAnalyticsHref(itemId: AnalyticsShellItemId) { } const ANALYTICS_SHELL_ITEMS = [ + { id: 'sessions', label: 'Sessions', icon: ChartColumnIncreasing }, { id: 'tasks', label: 'Tasks', icon: ChartColumnIncreasing }, { id: 'costs', label: 'Costs', icon: CircleDollarSign }, ] as const satisfies Array<{ @@ -35,6 +38,7 @@ const ANALYTICS_SHELL_ITEMS = [ }>; const ANALYTICS_DESCRIPTIONS: Record = { + sessions: 'Track Session activity by owner, status, and source.', pullRequests: 'Track pull request activity by user, status, repository, and author.', tasks: 'Track task activity by user, environment, source, and task type.', diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index a49ad66e2..5c2b000e2 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -7,7 +7,6 @@ import { } from '@testing-library/react'; import { ALL_REPOSITORIES, FAST_EXECUTION } from '@roomote/types'; -import type { RoutingDecision } from '@roomote/cloud-agents/server'; import type { PromptInputMessage } from '@/components/ai-elements'; import { AUTO_WORKSPACE_VALUE } from '@/components/tasks/constants'; @@ -19,8 +18,6 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; -let currentCommunicationsFastModeDefault = false; -let currentPersonalPreferencesLoading = false; const { mockPush, @@ -31,8 +28,6 @@ const { mockUseCreateStandardTaskRun, mockCreateStandardTaskRun, mockUseLaunchTaskModels, - mockUseRouteHomeTask, - mockRouteHomeTask, mockPreparePromptAttachments, mockStartFastSession, } = vi.hoisted(() => ({ @@ -44,8 +39,6 @@ const { mockUseCreateStandardTaskRun: vi.fn(), mockCreateStandardTaskRun: vi.fn(), mockUseLaunchTaskModels: vi.fn(), - mockUseRouteHomeTask: vi.fn(), - mockRouteHomeTask: vi.fn(), mockPreparePromptAttachments: vi.fn(), mockStartFastSession: vi.fn(), })); @@ -95,23 +88,8 @@ vi.mock('@/hooks/environments', () => ({ }), })); -vi.mock('@/hooks/usePersonalPreferences', () => ({ - usePersonalPreferences: () => ({ - preferences: { - colorTheme: 'system', - mindReaderMode: false, - narrationMode: false, - communicationsFastModeDefault: currentCommunicationsFastModeDefault, - }, - isLoading: currentPersonalPreferencesLoading, - isUpdating: false, - setPreferences: vi.fn(), - }), -})); - vi.mock('@/hooks/task-runs', () => ({ useCreateStandardTaskRun: mockUseCreateStandardTaskRun, - useRouteHomeTask: mockUseRouteHomeTask, useStartFastSession: () => ({ isPending: false, mutateAsync: mockStartFastSession, @@ -133,17 +111,6 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: mockUseLaunchTaskModels, })); -vi.mock('@/components/system', async () => { - const actual = await vi.importActual( - '@/components/system', - ); - - return { - ...actual, - Loader2: (props: React.ComponentProps<'svg'>) => , - }; -}); - vi.mock('@/lib', () => ({ processImageFiles: mockProcessImageFiles, })); @@ -179,13 +146,11 @@ vi.mock('@/components/tasks', async () => { ...actual, SelectWorkspace: ({ allowAuto, - allowFast, autoSelectDefaultWorkspace, onInvalidWorkspaceReset, allowBranchSelection, }: { allowAuto?: boolean; - allowFast?: boolean; autoSelectDefaultWorkspace?: boolean; onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; @@ -234,18 +199,16 @@ vi.mock('@/components/tasks', async () => { > Use auto workspace - {allowFast && ( - - )} + + + + + updateParams((params) => { + if (id && id !== 'all') { + params.set('user', id); + } else { + params.delete('user'); + } + }) + } + onRepositoryChange={(value) => + updateParams((params) => { + if (value) params.set('repository', value); + else params.delete('repository'); + }) + } + onPullRequestChange={(value) => + updateParams((params) => { + if (value) params.set('pullRequest', value); + else params.delete('pullRequest'); + }) + } + onModelChange={(value) => + updateParams((params) => { + if (value) params.set('model', value); + else params.delete('model'); + }) + } + onTimePeriodChange={(period) => + updateParams((params) => { + if (period === 'all') { + params.delete('period'); + } else { + params.set('period', String(period)); + } + }) + } + showRepository + showPullRequest + showModel + showTaskType={false} + /> + ); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index 8a794905c..b92747bf2 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -1,78 +1,143 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { + getSessionStatusLabel, + SESSION_STATUSES, + type SessionStatus, +} from '@roomote/types'; + import { parseTimePeriodParam } from '@/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessions } from '@/lib/server/fast-sessions'; +import { getSessions, type SessionScope } from '@/lib/server/sessions'; import { Empty, EmptyDescription, EmptyHeader } from '@/components/system'; -import { FastSessionCard } from './FastSessionCard'; import { SessionsFilters } from './SessionsFilters'; +import { SessionCard } from './SessionCard'; export default async function SessionsPage({ searchParams, }: { - searchParams?: Promise<{ before?: string; user?: string; period?: string }>; + searchParams?: Promise<{ + before?: string; + user?: string; + period?: string; + scope?: string; + status?: string; + view?: string; + q?: string; + repository?: string; + pullRequest?: string; + source?: string; + model?: string; + }>; }) { - const [authorizedUser, { before, user, period } = {}] = await Promise.all([ + const [authorizedUser, params = {}] = await Promise.all([ authorize(), searchParams, ]); if (!authorizedUser.success) { notFound(); } + const { before, user, period, q } = params; + const scope = ['all', 'tasks', 'reviews', 'automations'].includes( + params.scope ?? '', + ) + ? (params.scope as SessionScope) + : 'all'; + const status = (SESSION_STATUSES as readonly string[]).includes( + params.status ?? '', + ) + ? (params.status as SessionStatus) + : undefined; + const view = params.view === 'board' ? 'board' : 'list'; const timePeriod = parseTimePeriodParam(period ?? null, 'all'); - const { sessions, nextCursor } = await getFastSessions(authorizedUser, { + const result = await getSessions(authorizedUser, { before, - filterUserId: user ?? null, - timePeriod, + user, + period: timePeriod, + scope, + status, + q, + repository: params.repository, + pullRequest: params.pullRequest, + source: params.source, + model: params.model, }); - const olderParams = new URLSearchParams(); - if (nextCursor) olderParams.set('before', nextCursor); - if (user) olderParams.set('user', user); - if (timePeriod !== 'all') olderParams.set('period', String(timePeriod)); + Object.entries(params).forEach(([key, value]) => { + if (value && key !== 'before') olderParams.set(key, value); + }); + if (result.nextCursor) olderParams.set('before', result.nextCursor); + const columns = SESSION_STATUSES; return (
-
- -
+
- -
-
- {sessions.length === 0 ? ( - - - No sessions yet. - - - ) : ( -
- {sessions.map((session) => ( - - ))} - {nextCursor ? ( -
- - Show older sessions - +
+ {result.sessions.length === 0 ? ( + + + No sessions found. + + + ) : view === 'board' ? ( +
+ {columns.map((column) => ( +
+

+ {getSessionStatusLabel(column)} +

+
+ {result.sessions + .filter((session) => + column === 'ready' + ? !session.cachedStatus || + session.cachedStatus === column + : session.cachedStatus === column, + ) + .map((session) => ( + + ))}
- ) : null} -
- )} -
-
+ + ))} +
+ ) : ( +
+ {result.sessions.map((session) => ( + + ))} +
+ )} + {result.nextCursor ? ( +
+ + Show older sessions + +
+ ) : null} +
); } diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index b7e41924c..645172511 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -5,8 +5,6 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; -import { ALL_REPOSITORIES } from '@roomote/types'; - import { type Filter, type TimePeriodFilter, @@ -14,7 +12,11 @@ import { parseTimePeriodParam, } from '@/types'; -import { DEFAULT_VISIBLE_TASK_WORKFLOWS, getTaskCategoryById } from '@/lib'; +import { + DEFAULT_VISIBLE_TASK_WORKFLOWS, + formatRepositoryName, + getTaskCategoryById, +} from '@/lib'; import { cn } from '@/lib/utils'; import { useAuthorizedUser } from '@/hooks/useUser'; @@ -320,7 +322,7 @@ export const Tasks = () => { const pullRequestLabel = pullRequest === HAS_PULL_REQUEST_FILTER_VALUE ? 'Has PR' - : pullRequest.replace(ALL_REPOSITORIES, 'All Repositories'); + : formatRepositoryName(pullRequest); result.push({ type: 'pullRequest', diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 32b5c5f48..cbc48735e 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -6,6 +6,8 @@ import { toast } from 'sonner'; import { Tasks } from './Tasks'; +// Sessions is the primary workspace; this page is intentionally unlinked from +// the primary nav but stays fully functional for direct URLs and deep links. export default function Page() { const searchParams = useSearchParams(); const error = searchParams.get('error'); diff --git a/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx new file mode 100644 index 000000000..967f94001 --- /dev/null +++ b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +import { SandboxSidePanelHeader } from './SandboxSidePanelHeader'; + +export function SandboxInfoPanel({ + title, + onClose, + closeLabel, + header, + children, +}: { + title: string; + onClose: () => void; + closeLabel?: string; + header?: ReactNode; + children: ReactNode; +}) { + return ( + <> + {header ?? ( + + )} +
+
{children}
+
+ + ); +} + +export function SandboxInfoRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +export function SandboxInfoTable({ children }: { children: ReactNode }) { + return ( + + {children} +
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 5d8930f6d..f2e1e1624 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -9,9 +9,16 @@ import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; import { FastSessionTranscript } from './FastSessionTranscript'; -const { replyMutate, preparePromptAttachments } = vi.hoisted(() => ({ - replyMutate: vi.fn(), - preparePromptAttachments: vi.fn(), +const { replyMutate, preparePromptAttachments, openTaskPanel, narrationState } = + vi.hoisted(() => ({ + replyMutate: vi.fn(), + preparePromptAttachments: vi.fn(), + openTaskPanel: vi.fn(), + narrationState: { enabled: false }, + })); + +vi.mock('@/hooks/useNarrationMode', () => ({ + useNarrationMode: () => ({ enabled: narrationState.enabled }), })); vi.mock('@/trpc/client', () => ({ @@ -38,6 +45,24 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./session-task-panel-context', () => ({ + useOpenSessionTaskPanel: () => openTaskPanel, +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + onOpen, + }: { + taskId: string; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + class FakeEventSource { static instances: FakeEventSource[] = []; listeners = new Map void>>(); @@ -71,6 +96,8 @@ beforeEach(() => { preparePromptAttachments.mockImplementation(({ text }: { text: string }) => Promise.resolve({ text }), ); + narrationState.enabled = false; + openTaskPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); @@ -207,7 +234,9 @@ describe('FastSessionTranscript', () => { />, ); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Starting')).toBeInTheDocument(); + expect(screen.getByText('Coding Task')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); expect(FakeEventSource.instances).toHaveLength(1); expect(FakeEventSource.instances[0]!.url).toBe( '/api/sessions/session-1/stream', @@ -219,7 +248,8 @@ describe('FastSessionTranscript', () => { }); }); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Started')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { @@ -279,6 +309,69 @@ describe('FastSessionTranscript', () => { ); }); + it('keeps a launched child task visible in narration mode and opens its panel', () => { + narrationState.enabled = true; + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Delegated task/ })); + + expect(openTaskPanel).toHaveBeenCalledWith('child-1'); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { />, ); - const activityToggle = screen.getByRole('button', { - name: /Worked for/, - }); - expect(screen.queryByText('launch_task')).not.toBeInTheDocument(); - - fireEvent.click(activityToggle); - - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect( + screen.getByRole('button', { name: /Started Coding Task Completed/ }), + ).toBeInTheDocument(); expect(screen.getByText('I started the checkout fix.')).toBeInTheDocument(); }); @@ -467,11 +555,11 @@ describe('FastSessionTranscript', () => { , ); - expect(screen.getByText('Session')).toBeInTheDocument(); + expect(screen.getByText('New session')).toBeInTheDocument(); act(() => { FakeEventSource.instances[0]!.emit('session', { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..401d66bf8 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -1,6 +1,13 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; import { ACP_ENVELOPE_EVENT_TYPES, getImageUrisFromContentBlocks, @@ -24,6 +31,8 @@ import { type SessionPromptSubmission, } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; +import { useNarrationMode } from '@/hooks/useNarrationMode'; import { AcpTranscriptBlockList, @@ -56,11 +65,13 @@ export function FastSessionTranscript({ hasOlderMessages, canReply, initialTitle = null, - fallbackTitle = 'Session', + fallbackTitle = 'New session', sessionModel = null, sessionReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + headerExtras, + timelineExtras, }: { sessionId: string; initialMessages: FastSessionMessage[]; @@ -72,8 +83,13 @@ export function FastSessionTranscript({ sessionReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + headerExtras?: ReactNode; + timelineExtras?: ReactNode; }) { const trpcClient = useTRPCClient(); + const openTaskPanel = useOpenSessionTaskPanel(); + const { enabled: narrationModeEnabled } = useNarrationMode(); + const displayMode = narrationModeEnabled ? 'narration' : 'default'; const [serverMessages, setServerMessages] = useState< Map >( @@ -169,11 +185,12 @@ export function FastSessionTranscript({ const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({ messages: uiMessages, artifacts: [], - displayMode: 'default', + displayMode, initialPrompt: null, shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -254,11 +271,15 @@ export function FastSessionTranscript({ ); return ( - - + +

{title ?? fallbackTitle}

+ {headerExtras}
@@ -267,10 +288,12 @@ export function FastSessionTranscript({ Older messages in this session are not shown.

) : null} + {timelineExtras}
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx new file mode 100644 index 000000000..6e663264b --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.client.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { RunStatus } from '@roomote/types'; + +const useTaskSessionMock = vi.fn(); + +vi.mock('../../task/[taskId]/hooks/use-task-session', () => ({ + useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/use-task-message-envelopes', () => ({ + useTaskMessageEnvelopes: () => ({ + data: [], + isPending: false, + isSuccess: true, + isError: false, + }), +})); + +vi.mock('../../task/[taskId]/hooks/ArtifactLinkProvider', () => ({ + ArtifactLinkProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('../../task/[taskId]/hooks/HistoricalSandboxProvider', () => ({ + HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/hooks/SandboxProvider', () => ({ + SandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/Messages', () => ({ + Messages: () =>
Child transcript
, +})); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ + title, + actions, + }: { + title: string; + actions: ReactNode; + }) => ( +
+ {title} + {actions} +
+ ), +})); + +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; + +describe('NestedTaskSidePanel', () => { + beforeEach(() => { + useTaskSessionMock.mockReturnValue({ + taskId: 'child-1', + task: { title: 'Fix checkout' }, + taskRun: { + id: 42, + harness: 'opencode-server', + status: RunStatus.Running, + taskPhase: 'running', + sandboxServerUrl: 'http://sandbox.test', + }, + artifacts: [], + prompt: null, + token: 'token', + refreshConnection: vi.fn(), + sessionState: 'interactive', + isSessionLoading: false, + }); + }); + + it('renders the focused live transcript and full-task navigation without task chrome', () => { + render(); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Go to task/ })).toHaveAttribute( + 'href', + '/task/child-1', + ); + expect(screen.queryByText('Task actions')).not.toBeInTheDocument(); + expect(useTaskSessionMock).toHaveBeenCalledWith('child-1', { + refetchInterval: 2_000, + }); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx new file mode 100644 index 000000000..5298aac4c --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,128 @@ +'use client'; + +import Link from 'next/link'; + +import { DEFAULT_CODING_HARNESS, type TaskPhase } from '@roomote/types'; + +import { + Button, + ErrorState, + ExternalLink, + Skeleton, +} from '@/components/system'; +import { FramedSurface } from '@/components/layout'; + +import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; +import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; +import { SandboxProvider } from '../../task/[taskId]/hooks/SandboxProvider'; +import { useTaskMessageEnvelopes } from '../../task/[taskId]/hooks/use-task-message-envelopes'; +import { + useTaskSession, + type TaskSession, +} from '../../task/[taskId]/hooks/use-task-session'; +import { Messages } from '../../task/[taskId]/Messages'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; + +function NestedTaskTranscript({ session }: { session: TaskSession }) { + const history = useTaskMessageEnvelopes(session.taskId); + + if (session.isSessionLoading) { + return ( +
+ + + +
+ ); + } + + if ( + session.sessionState === 'error' || + session.sessionState === 'not-found' + ) { + return ; + } + + if (!session.taskRun) { + return ; + } + + const transcript = ( + + + + ); + + if ( + session.sessionState === 'historical' || + session.sessionState === 'resuming' || + session.sessionState === 'boot-failed' + ) { + return ( + + {transcript} + + ); + } + + return ( + + {transcript} + + ); +} + +export function NestedTaskSidePanel({ + taskId, + onClose, +}: { + taskId: string; + onClose: () => void; +}) { + const session = useTaskSession(taskId, { refetchInterval: 2_000 }); + const title = session.task?.title?.trim() || 'Task'; + + return ( + + + + Go to task + + + + } + /> +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx new file mode 100644 index 000000000..58717a7c4 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; +import { useRecentSessions } from '@/hooks/useRecentSessions'; +import { useTelemetry } from '@/hooks/useTelemetry'; + +export function SessionReadTracker({ sessionId }: { sessionId: string }) { + const { recordVisit } = useRecentSessions(); + const { capture } = useTelemetry(); + + useMarkSessionRead(sessionId); + + useEffect(() => { + recordVisit(sessionId); + capture('session_opened', { surface: 'web', outcome: 'opened' }); + }, [capture, recordVisit, sessionId]); + + return null; +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx new file mode 100644 index 000000000..edd3fdede --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionTaskCards.tsx @@ -0,0 +1,176 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useMutation } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { formatInferenceCost, formatRepositoryName } from '@/lib'; +import { + Badge, + Button, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; + +export type SessionTaskSummary = { + taskId: string; + title: string; + workflow: string; + state: string; + repositoryName: string | null; + latestOutput: string | null; + inferenceCostMicroUsd: number; + canAccessDetails?: boolean; + latestRun: { + id: number; + status: string; + taskPhase: string | null; + error: string | null; + result: unknown; + } | null; + artifacts: Array<{ + id: string; + path: string; + artifactType: string; + }>; + pullRequests: Array<{ + id: string; + url: string; + number: number | null; + title: string | null; + repository: string | null; + status: string | null; + }>; +}; + +export function SessionTaskCards({ + sessionId, + tasks, +}: { + sessionId: string; + tasks: SessionTaskSummary[]; +}) { + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const cancel = useMutation(trpc.taskRuns.cancel.mutationOptions()); + const retry = useMutation(trpc.taskRuns.retryFailedStart.mutationOptions()); + + if (tasks.length === 0) return null; + + const selectTask = (taskId: string) => { + const params = new URLSearchParams(searchParams); + params.set('task', taskId); + router.replace(`/sessions/${sessionId}?${params.toString()}`); + }; + + return ( +
+

+ Executions +

+
+ {tasks.map((task) => ( + + +
+ + {task.title} + + + {task.state} + +
+
+ +

+ {task.repositoryName + ? formatRepositoryName(task.repositoryName) + : task.workflow} +

+ {task.latestRun?.error ? ( +

+ {task.latestRun.error} +

+ ) : null} + {task.latestOutput ? ( +

{task.latestOutput}

+ ) : null} + {task.inferenceCostMicroUsd > 0 ? ( +

+ ${formatInferenceCost(task.inferenceCostMicroUsd)} inference +

+ ) : null} + {task.canAccessDetails === false ? ( +

Execution details require task access.

+ ) : null} +
+ + {task.canAccessDetails === false ? null : task.state === + 'active' ? ( + + ) : task.state === 'failed' ? ( + + ) : null} + {task.canAccessDetails === false ? null : ( + <> + + + + )} + +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 99029b02a..df1b51ca5 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -1,23 +1,95 @@ import { useState, type ReactNode } from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { SandboxLayoutContext } from '../../use-sandbox-layout'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { useOpenSessionTaskPanel } from './session-task-panel-context'; -const { useMediaQueryMock } = vi.hoisted(() => ({ - useMediaQueryMock: vi.fn(), -})); +const { useMediaQueryMock, sessionQueryState, fastTaskQueryState } = vi.hoisted( + () => ({ + useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, + fastTaskQueryState: { data: null as unknown }, + }), +); vi.mock('usehooks-ts', () => ({ useMediaQuery: useMediaQueryMock, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { models: [{ id: 'model-1', displayName: 'Model One' }] }, }), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sessions: { + byId: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['sessions', 'byId', input.sessionId], + queryFn: async () => sessionQueryState.data, + ...options, + }), + }, + }, + fastSessions: { + tasks: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['fastSessions', 'tasks', input.sessionId], + queryFn: async () => fastTaskQueryState.data, + ...options, + }), + }, + }, + }), +})); + +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ taskId }: { taskId: string }) => ( +
Nested panel {taskId}
+ ), +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + prompt, + onOpen, + }: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + const session: SessionInfo = { id: 'session-1', ownerName: 'Test User', @@ -25,8 +97,11 @@ const session: SessionInfo = { ownerImageUrl: null, surface: 'slack', model: 'model-1', + reasoningEffort: null, inferenceCostMicroUsd: 1_000_000, createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'needs_input', + tasks: [], }; function SandboxLayoutProvider({ children }: { children: ReactNode }) { @@ -45,7 +120,21 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { ); } -function renderWorkspace({ isMobile }: { isMobile: boolean }) { +function renderWorkspace({ + isMobile, + children =
Session transcript
, + sessionOverride, + queriedTasks, + queriedFastTasks, +}: { + isMobile: boolean; + children?: ReactNode; + sessionOverride?: Partial; + queriedTasks?: SessionInfo['tasks']; + queriedFastTasks?: Array< + Pick + >; +}) { useMediaQueryMock.mockReturnValue(!isMobile); let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = null; @@ -65,12 +154,22 @@ function renderWorkspace({ isMobile }: { isMobile: boolean }) { value: vi.fn().mockReturnValue(mediaQuery), }); + const initialSession = { ...session, ...sessionOverride }; + sessionQueryState.data = { + ...initialSession, + tasks: queriedTasks ?? initialSession.tasks, + }; + fastTaskQueryState.data = queriedFastTasks ?? initialSession.taskCards ?? []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const result = render( - - -
Session transcript
-
-
, + + + {children} + + , ); return { @@ -84,6 +183,16 @@ function renderWorkspace({ isMobile }: { isMobile: boolean }) { }; } +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + + return ( + + ); +} + describe('SessionWorkspace', () => { it('matches the task sidebar replacement behavior and controls on mobile', () => { renderWorkspace({ isMobile: true }); @@ -99,25 +208,9 @@ describe('SessionWorkspace', () => { expect(screen.queryByText('Session transcript')).not.toBeInTheDocument(); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); - const table = screen.getByRole('table'); - const panel = table.parentElement!.parentElement!; - - expect(panel).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); - expect(panel.parentElement).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); + expect(screen.getByText('needs input')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Close session info' }), ).toBeNull(); @@ -137,7 +230,7 @@ describe('SessionWorkspace', () => { fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); }); @@ -153,6 +246,86 @@ describe('SessionWorkspace', () => { ).toBeInTheDocument(); }); + it('disables the Tasks panel button until the session has a task', () => { + renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Tasks' })).toBeDisabled(); + }); + + it('lists session tasks with delegated task cards', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { + tasks: [ + { + taskId: 'task-1', + title: 'Update homepage background', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }, + ], + }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'View coding task: Update homepage background', + }), + ); + + expect(screen.getByText('Nested panel task-1')).toBeInTheDocument(); + }); + + it('enables and populates the Tasks panel from refreshed session tasks', async () => { + const delegatedTask = { + taskId: 'task-2', + title: 'Refreshed coding task', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }; + renderWorkspace({ + isMobile: false, + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [delegatedTask], + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect( + screen.getByRole('button', { + name: 'View coding task: Refreshed coding task', + }), + ).toBeInTheDocument(); + }); + + it('opens delegated tasks in the existing session side-panel slot', () => { + renderWorkspace({ isMobile: false, children: }); + + fireEvent.click(screen.getByRole('button', { name: 'Open child' })); + + expect(screen.getByText('Nested panel child-1')).toBeInTheDocument(); + }); + it('collapses the right rail when the viewport changes from desktop to mobile', () => { const { resizeToMobile } = renderWorkspace({ isMobile: false }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index ab63de71b..3b96ed536 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,22 +1,59 @@ 'use client'; -import { useState, type ReactNode } from 'react'; -import { formatDistanceToNow } from 'date-fns'; +import Link from 'next/link'; +import { + useCallback, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { getReasoningEffortLabel, type ReasoningEffort } from '@roomote/types'; -import { formatInferenceCost, getUserDisplayName } from '@/lib'; +import { + formatInferenceCost, + formatRepositoryName, + getUserDisplayName, +} from '@/lib'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; +import { + getSessionSurfaceBrandIcon, + getSessionSurfaceLabel, +} from '@/components/sessions/session-surfaces'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; -import { WorkspaceSurface } from '@/components/layout'; +import { useTRPC } from '@/trpc/client'; +import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { ArrowLeftFromLine, Avatar, BasicTooltip, + BrandIcon, + Brain, Button, + Calendar, DollarSign, + Globe, Info, + Slack, + X, + Rows4, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from '@/components/system'; +import type { SessionTaskSummary } from './SessionTaskCards'; import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../SandboxInfoPanel'; import { ResponsiveWorkspacePanels, SandboxSideActions, @@ -25,6 +62,9 @@ import { useResponsiveSandboxSidebar, useSandboxLayout, } from '../../use-sandbox-layout'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { OpenSessionTaskPanelContext } from './session-task-panel-context'; +import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; export type SessionInfo = { id: string; @@ -34,25 +74,151 @@ export type SessionInfo = { surface: string; /** Effective model for the session's turns (stored override or default). */ model: string | null; + reasoningEffort: ReasoningEffort | null; inferenceCostMicroUsd: number; createdAt: Date; + status: string | null; + tasks: SessionTaskSummary[]; + taskSource?: 'unified' | 'fast'; + taskCards?: Array>; }; -const SURFACE_LABELS: Record = { - slack: 'Slack', - discord: 'Discord', - teams: 'Microsoft Teams', - telegram: 'Telegram', - automation: 'Automation', - web: 'Web', -}; +function SessionTaskPanel({ + sessionId, + task, + tasks, + onSelect, + onClose, +}: { + sessionId: string; + task: SessionTaskSummary; + tasks: SessionTaskSummary[]; + onSelect: (taskId: string) => void; + onClose: () => void; +}) { + return ( + <> +
+

Execution details

+ + + +
+
+ {tasks.length > 1 ? ( + + ) : null} +
+

{task.title}

+

{task.state}

+ {task.repositoryName ? ( +

+ {formatRepositoryName(task.repositoryName)} +

+ ) : null} +
+ {task.canAccessDetails === false ? ( +

+ Execution details require task access. +

+ ) : null} + {task.latestRun?.error ? ( +
+ {task.latestRun.error} +
+ ) : null} + {task.pullRequests.length ? ( +
+

Pull requests

+ {task.pullRequests.map((pullRequest) => ( + + {pullRequest.repository}#{pullRequest.number} + + ))} +
+ ) : null} + {task.artifacts.length ? ( +
+

Artifacts

+ {task.artifacts.map((artifact) => ( + + {artifact.path} + + ))} +
+ ) : null} + {task.canAccessDetails === false ? null : ( + + )} +
+ + ); +} -function InfoRow({ label, children }: { label: string; children: ReactNode }) { +function SessionTasksPanel({ + tasks, + onOpenTask, + onClose, +}: { + tasks: Array>; + onOpenTask: (taskId: string) => void; + onClose: () => void; +}) { return ( - - {label} - {children} - + + +
+ {tasks.map((task) => ( + + ))} +
+
); } @@ -73,54 +239,96 @@ function SessionInfoPanel({ ? (modelData?.models.find(({ id }) => id === session.model)?.displayName ?? session.model) : null; + const modelAndReasoningLabel = [ + modelLabel ?? 'Default model', + session.reasoningEffort + ? getReasoningEffortLabel(session.reasoningEffort) + : null, + ] + .filter(Boolean) + .join(' • '); const inferenceCostLabel = formatInferenceCost(session.inferenceCostMicroUsd); + const surfaceLabel = getSessionSurfaceLabel(session.surface); + const surfaceBrandIcon = getSessionSurfaceBrandIcon(session.surface); return ( -
- + -
- - - - - - {ownerDisplayName} - - - {modelLabel ?? 'Default model'} - - - - {inferenceCostLabel} + > + + + + + {ownerDisplayName} + + + + + + {modelAndReasoningLabel} + + + + + + {inferenceCostLabel} + + + + + + + {session.createdAt.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + })} - - - - - {formatDistanceToNow(session.createdAt, { addSuffix: true })} - - - - - {SURFACE_LABELS[session.surface] ?? session.surface} - - -
-
-
+ + + + + {session.surface === 'slack' ? ( + + ) : surfaceBrandIcon ? ( + + ) : ( + + )} + {surfaceLabel} + + + {session.status ? ( + + + + ) : null} + + + ); } +type WorkspacePanel = + | { kind: 'info' } + | { kind: 'tasks' } + | { kind: 'nested'; taskId: string }; + export function SessionWorkspace({ session, children, @@ -128,55 +336,153 @@ export function SessionWorkspace({ session: SessionInfo; children: ReactNode; }) { - const [isInfoOpen, setIsInfoOpen] = useState(false); + // Exactly one side panel can be active: the discriminated union makes an + // impossible combination unrepresentable. The URL's ?task= selection is the + // fourth panel and always wins over `panel` when both are set. + const [panel, setPanel] = useState(null); + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isFastTaskSource = session.taskSource === 'fast'; + const { data: currentSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: session.id }, + { + enabled: !isFastTaskSource, + // Settled sessions poll slowly; only visibly-running work needs the + // fast cadence. TanStack pauses both while the tab is unfocused. + refetchInterval: (query) => + query.state.data?.status === 'active' || + query.state.data?.status === 'needs_input' + ? 2_000 + : 30_000, + }, + ), + ); + const { data: currentFastTasks } = useQuery( + trpc.fastSessions.tasks.queryOptions( + { sessionId: session.id }, + { + enabled: isFastTaskSource, + refetchInterval: 2_000, + }, + ), + ); + const sessionTasks = currentSession?.tasks ?? session.tasks; + const taskCards = isFastTaskSource + ? (currentFastTasks ?? session.taskCards ?? session.tasks) + : sessionTasks; + const selectedTaskId = searchParams.get('task'); + const selectedTask = sessionTasks.find( + (task) => task.taskId === selectedTaskId, + ); + const panelOpen = panel !== null || Boolean(selectedTask); + + const selectTask = useCallback( + (taskId: string | null) => { + const params = new URLSearchParams(searchParams); + if (taskId) params.set('task', taskId); + else params.delete('task'); + const query = params.toString(); + router.replace(`/sessions/${session.id}${query ? `?${query}` : ''}`); + }, + [router, searchParams, session.id], + ); + + // Default a single-task session to its task panel once, on mount only — an + // explicit close or panel choice must never be fought by a re-select. + const didAutoSelect = useRef(false); + useEffect(() => { + if (didAutoSelect.current) return; + didAutoSelect.current = true; + if (!selectedTaskId && session.tasks.length === 1) { + selectTask(session.tasks[0]!.taskId); + } + }, [selectTask, selectedTaskId, session.tasks]); + + const openTaskPanel = useCallback( + (taskId: string) => { + setPanel({ kind: 'nested', taskId }); + selectTask(null); + }, + [selectTask], + ); + const closePanel = () => { + setPanel(null); + selectTask(null); + }; + const togglePanel = (kind: 'info' | 'tasks') => { + setPanel((previous) => (previous?.kind === kind ? null : { kind })); + selectTask(null); + }; + const panelContent = selectedTask ? ( + + ) : panel?.kind === 'nested' ? ( + + ) : panel?.kind === 'tasks' ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); useResponsiveSandboxSidebar(session.id); return ( - - setIsInfoOpen(false)} - > - setIsInfoOpen((previous) => !previous)} - /> - - {!isSidebarVisible && !isInfoOpen ? ( - - - - ) : null} - - } - > - setIsInfoOpen(false)} - /> + + + + togglePanel('info')} + /> + togglePanel('tasks')} + /> + + {!isSidebarVisible && !panelOpen ? ( + + + + ) : null} + } - /> - + > + + + ); } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 2d429ed07..0aae6fa7f 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -1,21 +1,42 @@ import type { ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -const { authorizeMock, getFastSessionByIdMock, transcriptMock } = vi.hoisted( - () => ({ - authorizeMock: vi.fn(), - getFastSessionByIdMock: vi.fn(), - transcriptMock: vi.fn( - ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( -
{footer}
- ), +const { + authorizeMock, + getFastSessionByIdMock, + getFastSessionTasksMock, + getSessionByIdCommandMock, + transcriptMock, + sessionWorkspaceMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + getFastSessionByIdMock: vi.fn(), + getFastSessionTasksMock: vi.fn(), + getSessionByIdCommandMock: vi.fn(), + transcriptMock: vi.fn( + ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( +
{footer}
), - }), -); + ), + sessionWorkspaceMock: vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+ )), +})); vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + notFound: () => { + throw new Error('NEXT_NOT_FOUND'); + }, +})); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, + getFastSessionTasks: getFastSessionTasksMock, +})); +vi.mock('@/trpc/commands/sessions', () => ({ + getSessionByIdCommand: getSessionByIdCommandMock, })); vi.mock('../../use-sandbox-layout', () => ({ useResponsiveSandboxSidebar: vi.fn(), @@ -36,10 +57,25 @@ vi.mock('@/components/layout', () => ({ vi.mock('./FastSessionTranscript', () => ({ FastSessionTranscript: transcriptMock, })); +vi.mock('./SessionWorkspace', () => ({ + SessionWorkspace: sessionWorkspaceMock, +})); +vi.mock('./SessionReadTracker', () => ({ + SessionReadTracker: () => null, +})); +vi.mock('./SessionTaskCards', () => ({ + SessionTaskCards: () =>
, +})); import SessionDetailPage from './page'; -describe('Fast session detail page', () => { +describe('Session detail page', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionByIdCommandMock.mockResolvedValue(null); + getFastSessionTasksMock.mockResolvedValue([]); + }); + it('uses the shared task workspace and renders supported session data', async () => { authorizeMock.mockResolvedValue({ success: true, @@ -47,7 +83,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-1', + id: '6a1f8f1e-0000-4000-8000-000000000001', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -99,7 +135,9 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-1' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', + }), }), ); @@ -109,7 +147,7 @@ describe('Fast session detail page', () => { expect(html).not.toContain('OpenCode workspace details unavailable'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-1', + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', canReply: true, fallbackTitle: 'Question', initialMessages: expect.arrayContaining([ @@ -127,7 +165,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-2', + id: '6a1f8f1e-0000-4000-8000-000000000003', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -147,17 +185,187 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-2' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', + }), }), ); expect(html).not.toContain('b3b0a53e-6dab-4bb8-b3a5-111111111111'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-2', + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', canReply: true, initialTitle: 'Rotate the API keys', - fallbackTitle: 'Session', + fallbackTitle: 'New session', + }), + undefined, + ); + }); + + it('resolves the unified session first and renders its Fast transcript', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + title: 'Session title', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'slack', + fastConversationId: '6a1f8f1e-0000-4000-8000-000000000005', + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'active', + tasks: [ + { + taskId: 'task-1', + title: 'Delegated task', + }, + ], + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000002', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000002', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).not.toHaveBeenCalled(); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + status: 'active', + tasks: [expect.objectContaining({ taskId: 'task-1' })], + }), + }), + undefined, + ); + expect(transcriptMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + canReply: true, + initialTitle: 'Session title', + fallbackTitle: 'Session title', + }), + undefined, + ); + }); + + it('renders a task-only workspace for unified sessions without a Fast conversation', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000004', + title: 'Task-only session', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'web', + fastConversationId: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'completed', + tasks: [ + { + taskId: 'task-2', + title: 'Delegated task', + }, + ], + }); + + const html = renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000004', + }), + }), + ); + + expect(getFastSessionByIdMock).not.toHaveBeenCalled(); + expect(transcriptMock).not.toHaveBeenCalled(); + expect(html).toContain('Task-only session'); + }); + + it('falls back to the Fast conversation lookup when no session row exists', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + userId: 'user-1', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + getFastSessionTasksMock.mockResolvedValue([ + { taskId: 'task-1', title: 'Delegated task' }, + ]); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + taskSource: 'fast', + taskCards: [expect.objectContaining({ taskId: 'task-1' })], + }), }), undefined, ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..3f4cfb550 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -1,4 +1,5 @@ import { notFound } from 'next/navigation'; +import { z } from 'zod'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; import { @@ -8,10 +9,18 @@ import { } from '@roomote/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessionById } from '@/lib/server/fast-sessions'; +import { + getFastSessionById, + getFastSessionTasks, +} from '@/lib/server/fast-sessions'; +import { getSessionByIdCommand } from '@/trpc/commands/sessions'; +import { WorkspaceHeader } from '@/components/layout'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; import { FastSessionTranscript } from './FastSessionTranscript'; import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { SessionTaskCards } from './SessionTaskCards'; +import { SessionReadTracker } from './SessionReadTracker'; export default async function SessionDetailPage({ params, @@ -25,12 +34,24 @@ export default async function SessionDetailPage({ if (!authorizedUser.success) { notFound(); } - - const session = await getFastSessionById(authorizedUser, sessionId); - if (!session) { + // Both lookup columns are uuid; a garbage route param would otherwise throw + // 22P02 in Postgres instead of 404ing. + if (!z.string().uuid().safeParse(sessionId).success) { notFound(); } + // Old links may carry a fast-conversation id whose session row hasn't been + // backfilled yet; getSessionByIdCommand falls back by fastConversationId, + // and the fast lookup below covers a conversation with no session row. + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); + const session = unifiedSession?.fastConversationId + ? await getFastSessionById( + authorizedUser, + unifiedSession.fastConversationId, + ) + : unifiedSession + ? null + : await getFastSessionById(authorizedUser, sessionId); // The chip's "default" must reflect what Fast actually runs with: the // deployment's orchestration model, not the task launch default. const modelEnv: Record = @@ -44,6 +65,72 @@ export default async function SessionDetailPage({ ? (rawDefaultEffort as ReasoningEffort) : null; + if (unifiedSession) { + const sessionInfo: SessionInfo = { + id: unifiedSession.id, + ownerName: unifiedSession.ownerName, + ownerEmail: unifiedSession.ownerEmail, + ownerImageUrl: unifiedSession.ownerImageUrl, + surface: unifiedSession.sourceSurface, + model: session?.model ?? defaultModelId, + reasoningEffort: session?.reasoningEffort ?? defaultReasoningEffort, + inferenceCostMicroUsd: unifiedSession.inferenceCostMicroUsd, + createdAt: unifiedSession.createdAt, + status: unifiedSession.status, + tasks: unifiedSession.tasks, + }; + const taskCards = ( + + ); + + return ( + + +
+ {session ? ( + + } + timelineExtras={taskCards} + /> + ) : ( + <> + +

+ {unifiedSession.title} +

+ +
+
+
{taskCards}
+
+ + )} +
+
+ ); + } + if (!session) { + notFound(); + } + const sessionInfo: SessionInfo = { id: session.id, ownerName: session.ownerName, @@ -51,15 +138,20 @@ export default async function SessionDetailPage({ ownerImageUrl: session.ownerImageUrl, surface: session.surface, model: session.model ?? defaultModelId, + reasoningEffort: session.reasoningEffort ?? defaultReasoningEffort, inferenceCostMicroUsd: session.inferenceCostMicroUsd, createdAt: session.createdAt, + status: null, + tasks: [], + taskSource: 'fast', + taskCards: (await getFastSessionTasks(authorizedUser, session.id)) ?? [], }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', ); const fallbackTitle = getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || - 'Session'; + 'New session'; return ( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts new file mode 100644 index 000000000..8fd19b8cf --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export const OpenSessionTaskPanelContext = createContext< + ((taskId: string) => void) | null +>(null); + +export function useOpenSessionTaskPanel() { + return useContext(OpenSessionTaskPanelContext); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx index 8dd0401c4..76c2ee383 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx @@ -1,12 +1,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const { useSandboxLayoutMock, useTRPCMock, updateTitleMutationMock } = - vi.hoisted(() => ({ - useSandboxLayoutMock: vi.fn(), - useTRPCMock: vi.fn(), - updateTitleMutationMock: vi.fn(async () => undefined), - })); +const { + useSandboxLayoutMock, + useTRPCMock, + updateTitleMutationMock, + parentSessionQueryMock, +} = vi.hoisted(() => ({ + useSandboxLayoutMock: vi.fn(), + useTRPCMock: vi.fn(), + updateTitleMutationMock: vi.fn(async () => undefined), + parentSessionQueryMock: vi.fn(), +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: useSandboxLayoutMock, @@ -16,6 +21,10 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('./TaskSessionReadTracker', () => ({ + TaskSessionReadTracker: () => null, +})); + vi.mock('@/components/sandbox', () => ({ WorkspaceBadge: ({ environmentId, @@ -78,6 +87,10 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + parentSessionQueryMock.mockResolvedValue({ + sessionId: 'session-1', + title: 'Parent Session', + }); useSandboxLayoutMock.mockReturnValue({ isSidebarVisible: true, @@ -93,6 +106,18 @@ describe('Header', () => { ], }, }, + sessions: { + forTask: { + queryOptions: ( + _input: { taskId: string }, + options?: { enabled?: boolean }, + ) => ({ + queryKey: ['sessions.forTask'], + queryFn: parentSessionQueryMock, + enabled: options?.enabled, + }), + }, + }, tasks: { updateTitle: { mutationOptions: () => ({ @@ -142,6 +167,37 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); + it('always queries the parent session and renders its links', async () => { + renderHeader(); + + expect( + await screen.findByRole('link', { name: 'Parent Session' }), + ).toHaveAttribute('href', '/sessions/session-1?task=task-123'); + expect(screen.getByRole('link', { name: /Go to session/ })).toHaveAttribute( + 'href', + '/sessions/session-1?task=task-123', + ); + expect(parentSessionQueryMock).toHaveBeenCalled(); + }); + + it('links to the Fast session when the task has no unified session', async () => { + parentSessionQueryMock.mockResolvedValue(null); + + renderHeader({ + taskRun: { + payload: { + environmentId: 'env-1', + fastAgentSessionId: '00000000-0000-4000-8000-000000000001', + }, + harness: 'opencode-server', + } as never, + }); + + expect( + await screen.findByRole('link', { name: /Go to session/ }), + ).toHaveAttribute('href', '/sessions/00000000-0000-4000-8000-000000000001'); + }); + it('refreshes task lists after renaming a task', async () => { const { queryClient } = renderHeader(); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 282df1030..d9d40eedb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -1,17 +1,26 @@ 'use client'; import { useEffect, useState, type KeyboardEvent } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeftFromLine, Button, + ExternalLink, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from '@/components/system'; import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; @@ -20,6 +29,7 @@ import { useTRPC } from '@/trpc/client'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; +import { TaskSessionReadTracker } from './TaskSessionReadTracker'; interface HeaderProps { session: TaskSession; @@ -28,15 +38,24 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); + const { data: parentSession } = useQuery( + trpc.sessions.forTask.queryOptions({ taskId }), + ); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; + const sessionHref = parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null; const badges = [ (environmentId || repo) && ( @@ -150,21 +169,65 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { }; const title = task?.title || 'Untitled task'; + const returnTo = searchParams?.get('returnTo'); + const safeReturnTo = + returnTo?.startsWith('/sessions') && !returnTo.startsWith('//') + ? returnTo + : '/sessions'; return ( <> - -

- {title} -

+ {parentSession ? ( + + ) : null} + + {parentSession ? ( + + + + + Sessions + + + + + + + {parentSession.title} + + + + + + + {title} + + + + + ) : ( +

+ {title} +

+ )} {badges.length > 0 && (
{badges.map((badge, index) => ( @@ -174,6 +237,14 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { ))}
)} + {sessionHref ? ( + + ) : null} {!isSidebarVisible && ( + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx index 2edeaacfa..f30360a05 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx @@ -142,16 +142,19 @@ function buildGroup(): GroupedToolCallRenderBlock { describe('AcpGroupedToolMessage anchors', () => { it('keeps per-item anchors mounted even when tool content is collapsed', () => { + // The compact grouped layout renders anchors as standalone hidden divs + // (no collapsed ToolContent container anymore); scroll targets must stay + // in the DOM while per-item detail stays unmounted. render(); - const collapsedContent = screen.getByTestId('collapsed-tool-content'); - expect(document.getElementById('msg-101')).toBeTruthy(); expect(document.getElementById('msg-102')).toBeTruthy(); - expect(collapsedContent.querySelector('#msg-101')).toBeNull(); - expect(collapsedContent.querySelector('#msg-102')).toBeNull(); + expect(document.getElementById('msg-101')).toHaveAttribute( + 'aria-hidden', + 'true', + ); - // Subheadings live inside ToolContent and should not be mounted in collapsed mode. + // Subheadings only mount with expanded tool detail. expect(screen.queryByText('file_b.txt')).toBeNull(); expect(screen.queryByText('file_c.txt')).toBeNull(); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx index 6985d2161..98869452d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx @@ -120,15 +120,12 @@ describe('AcpGroupedToolMessage', () => { codeBlockSpy.mockClear(); }); - it('renders grouped header and per-file sections', () => { + it('keeps grouped read rows compact when no item has expandable details', () => { render(); expect(screen.getByText('Exploring 2 files')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt')).toBeInTheDocument(); - expect(screen.getByText('file_b.txt')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt').className).toContain('truncate'); - expect(screen.getByText('file_b.txt').className).toContain('truncate'); - + expect(screen.queryByText('file_a.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('file_b.txt')).not.toBeInTheDocument(); expect(codeBlockSpy).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx index 78779d639..a2e9d2bd7 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx @@ -281,7 +281,7 @@ describe('AcpToolDetails', () => { }); it.each(['search', 'query'])( - 'adds the sanitized Hippocampus %s query to the existing result YAML', + 'renders the sanitized Memory %s input before the result YAML', (toolName) => { const result = { matches: [{ title: 'Existing result', score: 0.98 }], @@ -308,20 +308,14 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'matches:', - ' - title: Existing result', - ' score: 0.98', - 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', + ['matches:', ' - title: Existing result', ' score: 0.98'].join( + '\n', + ), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }, ); @@ -349,19 +343,12 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'delivered: true', - 'taskId: task-1', - 'message: Review RooCodeInc/Roomote and use password=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'message: Review RooCodeInc/Roomote and use password=[redacted]', + ['delivered: true', 'taskId: task-1'].join('\n'), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }); @@ -389,6 +376,61 @@ describe('AcpToolDetails', () => { expect(toolInputSpy).not.toHaveBeenCalled(); }); + it('keeps colliding input and result fields separate', () => { + render( + ), + text: JSON.stringify({ query: 'result value', matches: 2 }), + }} + />, + ); + + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: requested value', + ['query: result value', 'matches: 2'].join('\n'), + ]); + }); + + it('keeps input visible when a truncated result is no longer valid JSON', () => { + render( + ), + text: '{"matches":[\n... output truncated ...\n]}', + }} + />, + ); + + expect(codeBlockSpy.mock.calls[0]?.[0].code).toBe('query: large result'); + expect(codeBlockSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + language: 'yaml', + code: expect.stringContaining('output truncated'), + }), + ); + }); + it('hides expanded details for Roomote Slack lifecycle tools', () => { const { container } = render( { expect(toolDetailsSpy).not.toHaveBeenCalled(); }); - it('renders the gbrain MCP server as Hippocampus', () => { + it('renders the gbrain MCP server as Memory', () => { render( { expect.objectContaining({ action: 'Used', object: 'Query', - suffix: 'Hippocampus', + suffix: 'Memory', + }), + ); + }); + + it('uses the known MCP integration’s brand icon', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: mcpIntegrationIconFor('sentry'), + suffix: 'Sentry', }), ); }); @@ -387,7 +410,7 @@ describe('AcpToolMessage', () => { expect(toolHeaderSpy).toHaveBeenCalledWith( expect.objectContaining({ - icon: Eye, + icon: FileIcon, collapsible: false, }), ); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts index 29b7fd169..fe9a68221 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts @@ -820,7 +820,7 @@ describe('buildAcpRenderBlocks', () => { kind: 'tool_group', action: 'Used', objectSummary: '2 get issue calls', - displayKind: 'tool', + displayKind: 'generic', }); }); @@ -2014,4 +2014,78 @@ describe('buildAcpRenderBlocks', () => { }, }); }); + + it('keeps multiple delegated tasks as standalone cards when requested', () => { + const delegatedTask = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'launch_task', + kind: 'tool', + mcp: false, + payload: { + toolName: 'launch_task', + output: JSON.stringify({ success: true, taskId: id }), + }, + }); + + const entries = buildAcpRenderBlocks( + [delegatedTask('child-1', 1), delegatedTask('child-2', 2)], + { keepDelegatedTasksVisible: true }, + ); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent widget previews standalone', () => { + const widget = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'show_widget', + kind: 'mcp', + toolName: 'show_widget', + text: JSON.stringify({ + success: true, + shown: true, + html: `

${id}

`, + height: 240, + }), + }); + + const entries = buildAcpRenderBlocks([ + widget('widget-1', 1), + widget('widget-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent visual-proof uploads standalone', () => { + const proof = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'manage_artifacts', + kind: 'mcp', + toolName: 'manage_artifacts', + text: JSON.stringify({ + success: true, + artifactId: id, + artifactType: 'visual-proof', + viewUrl: `https://example.com/task/task-1/artifacts/${id}.png`, + rawUrl: `https://example.com/${id}.png`, + }), + }); + + const entries = buildAcpRenderBlocks([ + proof('proof-1', 1), + proof('proof-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts new file mode 100644 index 000000000..7cc739ba9 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -0,0 +1,198 @@ +import type { AcpToolResultPayload } from '@roomote/types'; + +import { resolveToolPresentation } from '../tool-presentation'; +import { resolveToolPresentationPolicy } from '../tool-presentation-policy'; +import type { AcpToolResultUiMessage } from '../types'; + +function toolData( + overrides: Partial = {}, +): AcpToolResultPayload { + return { + toolCallId: 'call-1', + kind: 'tool', + title: 'custom_tool', + isExecute: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: null, + exitCode: null, + output: '{}', + status: 'completed', + ...overrides, + }; +} + +function toolMessage( + overrides: Partial = {}, +): AcpToolResultUiMessage { + const data = toolData(overrides); + return { + id: 'message-1', + ts: 1, + role: 'tool', + partial: false, + sessionId: 'session-1', + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: data.output, + data, + }; +} + +describe('tool presentation resolver', () => { + it.each([ + [{ kind: 'execute', isExecute: true }, 'execute', 'terminal'], + [{ kind: 'read' }, 'read', 'file'], + [{ toolName: 'spill_grep' }, 'search', 'search'], + [{ toolName: 'list_skills' }, 'list', 'folder'], + [{ toolName: 'launch_task' }, 'task', 'task'], + [{ toolName: 'save_memory' }, 'memory', 'memory'], + [{ toolName: 'show_widget' }, 'widget', 'widget'], + ] as const)('classifies %o as %s', (overrides, category, iconKey) => { + expect(resolveToolPresentation(toolData(overrides))).toMatchObject({ + category, + iconKey, + }); + }); + + it.each([ + ['manage_custom_automations', 'task'], + ['get_about_me', 'roomote'], + ['describe_video', 'video'], + ['manage_goal', 'target'], + ['manage_tasks', 'list-checks'], + ['manage_source_control', 'pull-request'], + ['manage_environments', 'environment'], + ['save_task_memory', 'memory'], + ['request_environment_variables', 'terminal'], + ['report_platform_issue', 'alert'], + ['submit_automation_work_items', 'task'], + ['list_chat_channels', 'messages'], + ['get_chat_channel_messages', 'messages'], + ['get_chat_message_context', 'messages'], + ] as const)('uses the %s icon for %s', (toolName, iconKey) => { + expect(resolveToolPresentation(toolData({ toolName }))).toMatchObject({ + iconKey, + }); + }); + + it('uses Memory as the provider label without changing canonical identity', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'gbrain', + mcpToolName: 'query', + serverName: 'gbrain', + toolName: 'query', + }), + ), + ).toMatchObject({ + category: 'memory', + providerLabel: 'Memory', + identity: { serverName: 'gbrain', toolName: 'query' }, + }); + }); + + it('uses a known MCP integration’s catalog label and icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'search_issues', + serverName: 'sentry', + toolName: 'search_issues', + }), + ), + ).toMatchObject({ + integrationIcon: 'sentry', + providerLabel: 'Sentry', + }); + }); + + it('keeps explicit tool icons ahead of an MCP integration icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'manage_goal', + serverName: 'sentry', + toolName: 'manage_goal', + }), + ), + ).toMatchObject({ iconKey: 'target', integrationIcon: undefined }); + }); + + it('uses meaningful receipt language for consequential task actions', () => { + expect( + resolveToolPresentation(toolData({ toolName: 'launch_task' })), + ).toMatchObject({ verb: 'Started', object: 'Coding Task' }); + expect( + resolveToolPresentation( + toolData({ toolName: 'launch_task', status: 'failed' }), + ), + ).toMatchObject({ verb: 'Failed to Start', object: 'Coding Task' }); + }); + + it('sanitizes native fallback titles without using them for identity', () => { + expect( + resolveToolPresentation( + toolData({ + title: 'Read /sandbox/repos/RooCodeInc/Roomote/apps/web/package.json', + toolName: null, + }), + ), + ).toMatchObject({ + displayName: 'Read RooCodeInc/Roomote/apps/web/package.json', + object: 'Read RooCodeInc/Roomote/apps/web/package.json', + identity: { toolName: null }, + groupKey: 'kind:tool', + }); + }); +}); + +describe('tool presentation policy', () => { + it('keeps consequential receipts outside collapsed activity', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'save_memory', kind: 'memory' }), + ).activityMode, + ).toBe('keep-visible'); + }); + + it('keeps delegated task cards visible in narration mode only on card-enabled surfaces', () => { + const message = toolMessage({ + toolName: 'launch_task', + kind: 'task', + output: JSON.stringify({ success: true, taskId: 'task-1' }), + }); + + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: true, + displayMode: 'narration', + }), + ).toMatchObject({ + renderAs: 'delegated-task-card', + rowVisibility: 'visible', + activityMode: 'keep-visible', + }); + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: false, + }).renderAs, + ).toBe('row'); + }); + + it('keeps ordinary exploration hidden in narration mode', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'read_file', kind: 'read' }), + { displayMode: 'narration' }, + ).rowVisibility, + ).toBe('hidden'); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index c899e451e..d3f69f225 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -8,8 +8,7 @@ import type { AcpUiMessage, } from './types'; import type { AcpRenderBlock } from './render-blocks'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; -import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -21,10 +20,6 @@ const COLLAPSIBLE_ACP_MESSAGE_KIND_SET = new Set( COLLAPSIBLE_ACP_MESSAGE_KINDS, ); -const MANAGE_ARTIFACTS_TOOL_NAME = 'manage_artifacts'; -const SHOW_WIDGET_TOOL_NAME = 'show_widget'; -const ROOMOTE_MCP_SERVER_NAME = 'roomote'; - export interface AcpActivityGroupRenderBlock { kind: 'activity_group'; id: string; @@ -42,6 +37,7 @@ interface BuildAcpActivityRenderBlocksOptions { displayMode?: 'default' | 'narration'; hasLeadingTextBoundary?: boolean; collapseLeadingActivity?: boolean; + keepDelegatedTasksVisible?: boolean; } function isToolMessage( @@ -92,48 +88,6 @@ function isActivityBoundaryBlock(block: AcpRenderBlock): boolean { return isTextBoundaryBlock(block) || isProgressBoundaryBlock(block); } -function getToolName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.toolName ?? msg.data.mcpToolName; - const normalized = rawName?.trim().toLowerCase(); - - return normalized && normalized.length > 0 ? normalized : null; -} - -function getServerName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.serverName ?? msg.data.mcpServerName; - const normalized = rawName?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function isArtifactToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - artifacts: readonly TaskArtifact[] | null | undefined, -): boolean { - const toolName = getToolName(msg); - const serverName = getServerName(msg); - - if (toolName === MANAGE_ARTIFACTS_TOOL_NAME) { - return true; - } - - if ( - toolName === SHOW_WIDGET_TOOL_NAME && - serverName === ROOMOTE_MCP_SERVER_NAME - ) { - return true; - } - - if (resolveShowWidgetForToolMessage(msg) !== null) { - return true; - } - - return resolveVisualProofMediaForToolMessage(msg, artifacts).length > 0; -} - function isLivePartialBlock(block: AcpRenderBlock): boolean { if (block.kind === 'tool_group') { return block.items.some( @@ -152,6 +106,7 @@ function isLivePartialBlock(block: AcpRenderBlock): boolean { export function isActivityCollapsibleBlock( block: AcpRenderBlock, artifacts?: readonly TaskArtifact[] | null, + keepDelegatedTasksVisible = false, ): boolean { // Keep in-flight reasoning/tool rows outside default-closed groups so current // activity stays visible without a manual expand. @@ -160,8 +115,12 @@ export function isActivityCollapsibleBlock( } if (block.kind === 'tool_group') { - return !block.items.some((item) => - isArtifactToolMessage(item.msg, artifacts), + return !block.items.some( + (item) => + resolveToolPresentationPolicy(item.msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'keep-visible', ); } @@ -175,8 +134,13 @@ export function isActivityCollapsibleBlock( return false; } - if (isToolMessage(msg) && isArtifactToolMessage(msg, artifacts)) { - return false; + if (isToolMessage(msg)) { + return ( + resolveToolPresentationPolicy(msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'collapsible' + ); } return true; @@ -215,7 +179,11 @@ export function buildAcpActivityRenderBlocks( if ( !hasLeftTextBoundary || - !isActivityCollapsibleBlock(current, options.artifacts) + !isActivityCollapsibleBlock( + current, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { groupedBlocks.push(current); hasLeftTextBoundary = false; @@ -228,7 +196,11 @@ export function buildAcpActivityRenderBlocks( while ( activityEnd < blocks.length && - isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts) + isActivityCollapsibleBlock( + blocks[activityEnd]!, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { activityEnd += 1; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts new file mode 100644 index 000000000..488e97bc8 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -0,0 +1,50 @@ +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface DelegatedTaskDetails { + taskId: string; + prompt: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function getDelegatedTaskDetails( + msg: ToolMessage, +): DelegatedTaskDetails | null { + const toolName = (msg.data.toolName ?? msg.data.mcpToolName) + ?.trim() + .toLowerCase(); + + if (msg.kind !== 'tool_result' || toolName !== 'launch_task') { + return null; + } + + try { + const parsed = asRecord(JSON.parse(msg.data.output)); + const result = asRecord(parsed?.result) ?? asRecord(parsed?.data) ?? parsed; + const taskId = result?.taskId; + + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const rawInput = asRecord( + (msg.data as unknown as Record).rawInput, + ); + const args = asRecord(rawInput?.arguments); + const prompt = args?.prompt; + + return { + taskId, + prompt: + typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts index 3cedf9ef0..9c8289e11 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts @@ -4,47 +4,24 @@ import { normalizeTranscriptUserText, } from '@roomote/types'; -import { - isInternalDebugToolCallMessage, - shouldHideAcpMessage, -} from '../../message-visibility'; +import { shouldHideAcpMessage } from '../../message-visibility'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage, AcpUiMessage, } from './types'; +import { isSubagentToolMessage, isSubagentToolPayload } from './subagent-tool'; import { - isSubagentSpawnRowMessage, - isSubagentToolMessage, - isSubagentToolPayload, -} from './subagent-tool'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; - -export type ExplorationStepKind = 'list' | 'read' | 'search'; - -export type GroupedToolDisplayKind = - | ExplorationStepKind - | 'execute' - | 'edit' - | 'tool'; - -const EXPLORATION_TOOL_NAMES: Record> = { - search: new Set(['search', 'search_file', 'search_files']), - list: new Set(['glob', 'list', 'list_dir', 'list_directory', 'list_files']), - read: new Set(['read', 'read_file']), -}; + resolveToolPresentation, + summarizeToolGroup, + type ToolPresentationCategory, +} from './tool-presentation'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; -const STEP_KIND_ORDER: ExplorationStepKind[] = ['search', 'list', 'read']; +type ExplorationStepKind = 'list' | 'read' | 'search'; -const STEP_KIND_LABELS: Record< - ExplorationStepKind, - { singular: string; plural: string } -> = { - search: { singular: 'search', plural: 'searches' }, - list: { singular: 'listing', plural: 'listings' }, - read: { singular: 'file', plural: 'files' }, -}; +export type GroupedToolDisplayKind = ToolPresentationCategory; const STEP_KIND_DATA_KEYS: Record = { search: [ @@ -113,6 +90,7 @@ interface BuildAcpRenderBlocksOptions { initialPrompt?: Pick | null; shouldHideFirstMessage?: boolean; showInternalMessages?: boolean; + keepDelegatedTasksVisible?: boolean; suppressedMessageIds?: ReadonlySet; } @@ -287,45 +265,6 @@ function extractLabelFromToolData( return extractStringByKeys(argumentsRecord as Record, keys); } -function isExecuteToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): boolean { - const data = msg.data as unknown as Record; - return ( - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isExecute === true - ); -} - -function resolveExplorationStepKind( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): ExplorationStepKind | null { - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - - for (const stepKind of STEP_KIND_ORDER) { - if (toolName && EXPLORATION_TOOL_NAMES[stepKind].has(toolName)) { - return stepKind; - } - } - - if (msg.data.kind === 'search') { - return 'search'; - } - - if (msg.data.kind === 'list') { - return 'list'; - } - - if (msg.data.kind === 'read') { - return 'read'; - } - - return null; -} - /** * Stable identity for consecutive same-type collapsing. Different tools never * share a key, even when both are MCP exploration-style helpers. @@ -338,49 +277,14 @@ function resolveToolGroupKey( return null; } - if (isExecuteToolMessage(msg)) { - return 'execute'; - } - - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - const serverName = (msg.data.serverName ?? msg.data.mcpServerName ?? '') - .trim() - .toLowerCase(); - - if (toolName) { - return serverName ? `mcp:${serverName}:${toolName}` : `tool:${toolName}`; - } - - const kind = (msg.data.kind ?? '').trim().toLowerCase(); - - if (kind && kind !== 'mcp') { - return `kind:${kind}`; - } - - return null; + return resolveToolPresentation(msg.data, msg.partial).groupKey; } function resolveGroupedToolDisplayKind( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - groupKey: string, + _groupKey: string, ): GroupedToolDisplayKind { - if (groupKey === 'execute' || isExecuteToolMessage(msg)) { - return 'execute'; - } - - const explorationStep = resolveExplorationStepKind(msg); - - if (explorationStep) { - return explorationStep; - } - - if (msg.data.kind === 'edit') { - return 'edit'; - } - - return 'tool'; + return resolveToolPresentation(msg.data, msg.partial).category; } function isSettledToolMessage( @@ -393,10 +297,6 @@ function isSettledToolMessage( return msg.data.status === 'completed' || msg.data.status === 'failed'; } -function formatGenericToolLabel(value: string): string { - return value.split(/[_-]+/).filter(Boolean).join(' ').toLowerCase(); -} - const TITLE_PREFIX_RE = /^(?:search|read|list|find|run|using|used|ran|running)\s+(.+)$/i; @@ -435,53 +335,14 @@ function extractObjectLabel( function summarizeSameTypeGroup( items: GroupedToolCallItem[], displayKind: GroupedToolDisplayKind, - groupKey: string, + _groupKey: string, ): { action: string; objectSummary: string } { - const count = items.length; - - if (displayKind === 'execute') { - return { - action: 'Ran', - objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, - }; - } - - if ( - displayKind === 'search' || - displayKind === 'list' || - displayKind === 'read' - ) { - const labels = STEP_KIND_LABELS[displayKind]; - return { - action: 'Exploring', - objectSummary: `${count} ${count === 1 ? labels.singular : labels.plural}`, - }; - } - - if (displayKind === 'edit') { - return { - action: 'Edited', - objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, - }; - } - - const toolNameMatch = /^(?:mcp:[^:]+:|tool:)(.+)$/.exec(groupKey); - const toolLabel = toolNameMatch?.[1] - ? formatGenericToolLabel(toolNameMatch[1]) - : null; - - if (toolLabel) { - return { - action: 'Used', - objectSummary: - count === 1 ? `1 ${toolLabel}` : `${count} ${toolLabel} calls`, - }; - } - - return { - action: 'Used', - objectSummary: `${count} ${count === 1 ? 'tool' : 'tools'}`, - }; + const presentation = resolveToolPresentation(items[0]!.msg.data); + return summarizeToolGroup( + displayKind, + items.length, + presentation.displayName, + ); } function buildGroupedToolItem( @@ -680,12 +541,6 @@ function resolveMessageRenderState( options: BuildAcpRenderBlocksOptions, hideCurrentFirstUserPrompt: boolean, ): MessageRenderState { - const shouldShowInternalMessageInNarration = - options.showInternalMessages === true && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)); - const shouldShowWidgetInNarration = - isToolMessage(msg) && resolveShowWidgetForToolMessage(msg) !== null; - if (options.suppressedMessageIds?.has(msg.id)) { return { visibility: 'hidden', @@ -700,32 +555,18 @@ function resolveMessageRenderState( }; } - if ( - options.showInternalMessages === false && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)) && - // Spawn rows render inline even without debug UI. Keyed on the stable - // payload shape, never on live-only activity data: activity does not - // survive a transcript rebuild, and a row that vanishes on refresh reads - // as a lost subagent. - !isSubagentSpawnRowMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; - } - - if ( - options.displayMode === 'narration' && - isToolMessage(msg) && - !shouldShowInternalMessageInNarration && - !shouldShowWidgetInNarration && - !isSubagentToolMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; + if (isToolMessage(msg)) { + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + if (policy.rowVisibility !== 'visible') { + return { + visibility: 'hidden', + behavior: policy.hiddenBehavior, + }; + } } if (isEmptyCompletedTextMessage(msg)) { @@ -757,9 +598,16 @@ function resolveMessageRenderState( }; } + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + return { visibility: 'render', - groupKey: resolveToolGroupKey(msg), + groupKey: + policy.groupingMode === 'standalone' ? null : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts index da53510ec..8fffccbd8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts @@ -1,5 +1,5 @@ -import { isInternalDebugToolCallMessage } from '../../message-visibility'; import { isSubagentToolPayload } from './subagent-tool'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; @@ -69,21 +69,9 @@ export function hidesExpandedToolResult( msg: AcpToolUiMessage, options?: ToolDetailVisibilityOptions, ): boolean { - const data = msg.data as unknown as Record; - - if (isSubagentToolPayload(msg.data)) { - if (options?.showSubagentPayload === true) { - return false; - } - - return ( - getSubagentPrompt(msg) === null && getSubagentLastMessage(msg) === null - ); - } - return ( - isInternalDebugToolCallMessage(msg) || - msg.data.kind === 'read' || - data.isRead === true + resolveToolPresentationPolicy(msg, { + showInternalMessages: options?.showSubagentPayload === true, + }).detailMode !== 'expandable' ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts new file mode 100644 index 000000000..d61e59431 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -0,0 +1,67 @@ +import { createElement, forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + +import { + type LucideIcon, + Brain, + BrandIcon, + Bot, + FileIcon, + FolderIcon, + GalleryVerticalEnd, + GitPullRequest, + HardDriveUpload, + ListChecks, + MessageSquareText, + MessagesSquare, + RoomoteR, + Search, + SquarePen, + Target, + Terminal, + TriangleAlert, + VectorSquare, + Video, + Wrench, + Zap, +} from '@/components/system'; + +import type { ToolIconKey } from './tool-presentation'; + +export function toolIconForKey(key: ToolIconKey): LucideIcon { + if (key === 'terminal') return Terminal; + if (key === 'file') return FileIcon; + if (key === 'folder') return FolderIcon; + if (key === 'search') return Search; + if (key === 'edit') return SquarePen; + if (key === 'bot') return Bot; + if (key === 'task') return Zap; + if (key === 'message') return MessageSquareText; + if (key === 'memory') return Brain; + if (key === 'artifact') return HardDriveUpload; + if (key === 'widget') return GalleryVerticalEnd; + if (key === 'roomote') return RoomoteR; + if (key === 'video') return Video; + if (key === 'target') return Target; + if (key === 'list-checks') return ListChecks; + if (key === 'pull-request') return GitPullRequest; + if (key === 'environment') return VectorSquare; + if (key === 'alert') return TriangleAlert; + if (key === 'messages') return MessagesSquare; + return Wrench; +} + +const mcpIntegrationIconCache = new Map(); + +export function mcpIntegrationIconFor(icon: string): LucideIcon { + const existing = mcpIntegrationIconCache.get(icon); + if (existing) return existing; + + const McpIntegrationIcon = forwardRef( + ({ className }, _ref) => + createElement(BrandIcon, { icon, name: '', className }), + ); + McpIntegrationIcon.displayName = `McpIntegrationIcon(${icon})`; + mcpIntegrationIconCache.set(icon, McpIntegrationIcon); + return McpIntegrationIcon; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts new file mode 100644 index 000000000..699a853f1 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts @@ -0,0 +1,140 @@ +import type { TaskArtifact } from '@/types'; + +import { + isInternalDebugToolCallMessage, + shouldHideAcpMessage, +} from '../../message-visibility'; +import { getDelegatedTaskDetails } from './delegated-task'; +import { + isSubagentSpawnRowMessage, + isSubagentToolMessage, +} from './subagent-tool'; +import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { resolveToolPresentation } from './tool-presentation'; +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; +import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface ToolPresentationPolicyOptions { + artifacts?: readonly TaskArtifact[] | null; + delegatedTaskCardsEnabled?: boolean; + displayMode?: 'default' | 'narration'; + showInternalMessages?: boolean; +} + +interface ResolvedToolPolicy { + rowVisibility: 'visible' | 'hidden' | 'debug-only'; + hiddenBehavior: 'boundary' | 'transparent'; + detailMode: 'none' | 'expandable' | 'preview'; + activityMode: 'collapsible' | 'keep-visible'; + renderAs: 'row' | 'delegated-task-card'; + groupingMode: 'groupable' | 'standalone'; +} + +const CONSEQUENTIAL_RECEIPTS = new Set([ + 'launch_task', + 'cancel_task', + 'retry_task_start', + 'send_task_message', + 'save_memory', +]); + +export function resolveToolPresentationPolicy( + msg: ToolMessage, + options: ToolPresentationPolicyOptions = {}, +): ResolvedToolPolicy { + const presentation = resolveToolPresentation(msg.data, msg.partial); + const delegatedTask = getDelegatedTaskDetails(msg); + const renderAs = + options.delegatedTaskCardsEnabled && delegatedTask + ? 'delegated-task-card' + : 'row'; + const isInternal = + isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg); + const showWidget = resolveShowWidgetForToolMessage(msg) !== null; + const visualProof = + resolveVisualProofMediaForToolMessage(msg, options.artifacts).length > 0; + const isArtifact = presentation.category === 'artifact'; + const hasPreview = showWidget || visualProof; + const isRunning = msg.partial || msg.data.status === 'in_progress'; + const consequentialReceipt = + presentation.identity.toolName !== null && + CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName); + + let rowVisibility: ResolvedToolPolicy['rowVisibility'] = 'visible'; + if (shouldHideAcpMessage(msg)) { + rowVisibility = 'hidden'; + } else if ( + options.showInternalMessages === false && + isInternal && + !isSubagentSpawnRowMessage(msg) + ) { + rowVisibility = 'debug-only'; + } else if ( + options.displayMode === 'narration' && + !hasPreview && + !isSubagentToolMessage(msg) && + renderAs !== 'delegated-task-card' && + !consequentialReceipt && + !(options.showInternalMessages && isInternal) + ) { + rowVisibility = 'hidden'; + } + + const detailMode: ResolvedToolPolicy['detailMode'] = + isSubagentToolMessage(msg) && hasSubagentSummary(msg) + ? 'expandable' + : hasPreview + ? 'preview' + : isInternalDebugToolCallMessage(msg) || + presentation.category === 'read' || + (isSubagentToolMessage(msg) && + !options.showInternalMessages && + !hasSubagentSummary(msg)) + ? 'none' + : 'expandable'; + + return { + rowVisibility, + hiddenBehavior: 'boundary', + detailMode, + activityMode: + isRunning || + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'keep-visible' + : 'collapsible', + renderAs, + groupingMode: + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'standalone' + : 'groupable', + }; +} + +function hasSubagentSummary(msg: ToolMessage): boolean { + const data = msg.data as unknown as Record; + const prompt = data.prompt; + const rawInput = + data.rawInput && + typeof data.rawInput === 'object' && + !Array.isArray(data.rawInput) + ? (data.rawInput as Record) + : null; + const rawPrompt = rawInput?.prompt; + const output = msg.kind === 'tool_result' ? msg.data.output : null; + const activity = data.subagentActivity; + + return Boolean( + (typeof prompt === 'string' && prompt.trim()) || + (typeof rawPrompt === 'string' && rawPrompt.trim()) || + (typeof output === 'string' && output.trim()) || + (activity && typeof activity === 'object'), + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts new file mode 100644 index 000000000..021532e92 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -0,0 +1,350 @@ +import { + getMcpIntegration, + type AcpToolCallPayload, + type AcpToolResultPayload, +} from '@roomote/types'; + +// Direct import: the @/lib barrel drags icon-bearing modules into any test +// that mocks @/components/system. +import { sanitizeSandboxPathString } from '@/lib/sandbox-paths'; + +export type ToolPresentationCategory = + | 'execute' + | 'read' + | 'search' + | 'list' + | 'edit' + | 'subagent' + | 'task' + | 'communication' + | 'memory' + | 'artifact' + | 'widget' + | 'generic'; + +export type ToolIconKey = + | 'terminal' + | 'file' + | 'folder' + | 'search' + | 'edit' + | 'bot' + | 'task' + | 'message' + | 'memory' + | 'artifact' + | 'widget' + | 'roomote' + | 'video' + | 'target' + | 'list-checks' + | 'pull-request' + | 'environment' + | 'alert' + | 'messages' + | 'tool'; + +type ToolPresentationPhase = 'running' | 'completed' | 'failed'; + +type ToolData = AcpToolCallPayload | AcpToolResultPayload; + +interface ResolvedToolPresentation { + identity: { + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + }; + category: ToolPresentationCategory; + displayName: string; + iconKey: ToolIconKey; + integrationIcon?: string; + phase: ToolPresentationPhase; + verb: string; + object?: string; + providerLabel?: string; + groupKey: string | null; +} + +const SEARCH_TOOL_NAMES = new Set([ + 'search', + 'search_file', + 'search_files', + 'spill_grep', +]); +const LIST_TOOL_NAMES = new Set([ + 'glob', + 'list', + 'list_dir', + 'list_directory', + 'list_files', + 'list_skills', +]); +const READ_TOOL_NAMES = new Set([ + 'read', + 'read_file', + 'spill_read', + 'load_skill', +]); +const TASK_TOOL_NAMES = new Set([ + 'launch_task', + 'retry_task_start', + 'cancel_task', + 'send_task_message', +]); +const COMMUNICATION_TOOL_NAMES = new Set([ + 'send_chat_reply', + 'send_chat_reaction', + 'send_chat_reaction_emoji', + 'add_reaction_to_slack_message', + 'post_to_channel', + 'ignore_event', +]); +const TOOL_ICON_OVERRIDES: Readonly>> = { + manage_custom_automations: 'task', + get_about_me: 'roomote', + describe_video: 'video', + manage_goal: 'target', + manage_tasks: 'list-checks', + manage_source_control: 'pull-request', + manage_environments: 'environment', + save_task_memory: 'memory', + request_environment_variables: 'terminal', + report_platform_issue: 'alert', + submit_automation_work_items: 'task', + list_chat_channels: 'messages', + get_chat_channel_messages: 'messages', + get_chat_message_context: 'messages', +}; + +function normalized(value: string | null | undefined): string | null { + const result = value?.trim().toLowerCase(); + return result ? result : null; +} + +function formatToolIdentifier(value: string): string { + if (value.toLowerCase() === 'gbrain') return 'Memory'; + + return value + .replace(/[.]/g, ' ') + .replace(/[-_]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +export function resolveToolPresentation( + data: ToolData, + partial = false, +): ResolvedToolPresentation { + const serverName = normalized(data.serverName ?? data.mcpServerName); + const toolName = normalized(data.toolName ?? data.mcpToolName); + const kind = normalized(data.kind); + const providerKind = data.isMcp ? 'mcp' : 'native'; + const phase: ToolPresentationPhase = + data.status === 'failed' + ? 'failed' + : data.status === 'in_progress' || partial + ? 'running' + : 'completed'; + const category = resolveToolCategory({ + kind, + toolName, + serverName, + isExecute: data.isExecute, + isRead: 'isRead' in data && data.isRead === true, + isSubagentSpawn: data.isSubagentSpawn === true, + }); + const explicitIconKey = toolName ? TOOL_ICON_OVERRIDES[toolName] : undefined; + const integration = + providerKind === 'mcp' && serverName + ? getMcpIntegration(serverName) + : undefined; + const displayName = toolName + ? formatToolIdentifier(toolName) + : sanitizeSandboxPathString(data.title ?? 'Tool'); + const providerLabel = + integration?.name ?? + (serverName ? formatToolIdentifier(serverName) : undefined); + const receipt = resolveReceiptLanguage(toolName, phase); + const verb = receipt?.verb ?? (phase === 'running' ? 'Using' : 'Used'); + const object = receipt?.object ?? displayName; + + return { + identity: { providerKind, serverName, toolName }, + category, + displayName, + iconKey: explicitIconKey ?? categoryIconKey(category), + integrationIcon: explicitIconKey ? undefined : integration?.icon, + phase, + verb, + object, + providerLabel, + groupKey: resolveToolGroupKey({ + category, + providerKind, + serverName, + toolName, + kind, + }), + }; +} + +function resolveToolCategory(input: { + kind: string | null; + toolName: string | null; + serverName: string | null; + isExecute: boolean; + isRead: boolean; + isSubagentSpawn: boolean; +}): ToolPresentationCategory { + if (input.kind === 'subagent' || input.isSubagentSpawn) return 'subagent'; + if ( + input.kind === 'execute' || + input.kind === 'execute_command' || + input.isExecute + ) + return 'execute'; + if ( + input.kind === 'read' || + input.isRead || + (input.toolName && READ_TOOL_NAMES.has(input.toolName)) + ) + return 'read'; + if ( + input.kind === 'search' || + (input.toolName && SEARCH_TOOL_NAMES.has(input.toolName)) + ) + return 'search'; + if ( + input.kind === 'list' || + (input.toolName && LIST_TOOL_NAMES.has(input.toolName)) + ) + return 'list'; + if (input.kind === 'edit') return 'edit'; + if ( + input.kind === 'task' || + (input.toolName && TASK_TOOL_NAMES.has(input.toolName)) + ) + return 'task'; + if ( + input.kind === 'communication' || + (input.toolName && COMMUNICATION_TOOL_NAMES.has(input.toolName)) + ) + return 'communication'; + if ( + input.kind === 'memory' || + input.serverName === 'gbrain' || + input.toolName === 'save_memory' + ) + return 'memory'; + if (input.kind === 'artifact' || input.toolName === 'manage_artifacts') + return 'artifact'; + if (input.kind === 'widget' || input.toolName === 'show_widget') + return 'widget'; + return 'generic'; +} + +function categoryIconKey(category: ToolPresentationCategory): ToolIconKey { + if (category === 'execute') return 'terminal'; + if (category === 'read') return 'file'; + if (category === 'list') return 'folder'; + if (category === 'search') return 'search'; + if (category === 'edit') return 'edit'; + if (category === 'subagent') return 'bot'; + if (category === 'task') return 'task'; + if (category === 'communication') return 'message'; + if (category === 'memory') return 'memory'; + if (category === 'artifact') return 'artifact'; + if (category === 'widget') return 'widget'; + return 'tool'; +} + +function resolveToolGroupKey(input: { + category: ToolPresentationCategory; + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + kind: string | null; +}): string | null { + if (input.category === 'subagent') return null; + if (input.category === 'execute') return 'execute'; + if (input.toolName) { + return input.providerKind === 'mcp' && input.serverName + ? `mcp:${input.serverName}:${input.toolName}` + : `tool:${input.toolName}`; + } + return input.kind && input.kind !== 'mcp' ? `kind:${input.kind}` : null; +} + +function resolveReceiptLanguage( + toolName: string | null, + phase: ToolPresentationPhase, +): { verb: string; object: string } | null { + const byPhase = (running: string, completed: string, failed: string) => + phase === 'running' ? running : phase === 'failed' ? failed : completed; + + if (toolName === 'launch_task') + return { + verb: byPhase('Starting', 'Started', 'Failed to Start'), + object: 'Coding Task', + }; + if (toolName === 'cancel_task') + return { + verb: byPhase('Cancelling', 'Cancelled', 'Failed to Cancel'), + object: 'Task', + }; + if (toolName === 'retry_task_start') + return { + verb: byPhase('Retrying', 'Retried', 'Failed to Retry'), + object: 'Task', + }; + if (toolName === 'send_task_message') + return { + verb: byPhase('Sending', 'Sent', 'Failed to Send'), + object: 'Task Message', + }; + if (toolName === 'save_memory') + return { + verb: byPhase('Saving', 'Saved', 'Failed to Save'), + object: 'Memory', + }; + return null; +} + +export function summarizeToolGroup( + category: ToolPresentationCategory, + count: number, + displayName: string, +): { action: string; objectSummary: string } { + if (category === 'execute') + return { + action: 'Ran', + objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, + }; + if (category === 'search') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'search' : 'searches'}`, + }; + if (category === 'list') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'listing' : 'listings'}`, + }; + if (category === 'read') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + if (category === 'edit') + return { + action: 'Edited', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + + const label = displayName.toLowerCase(); + return { + action: 'Used', + objectSummary: count === 1 ? `1 ${label}` : `${count} ${label} calls`, + }; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index 61d3e12ff..d40f13449 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -52,6 +52,11 @@ import { useTaskSummary, } from '../hooks'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../../SandboxInfoPanel'; import { SidePanelHeader } from './SidePanelHeader'; import { getTaskParticipants } from './task-participants'; @@ -299,296 +304,270 @@ export function TaskInfoPanel({ PRODUCT_NAME; return ( - <> - -
-
- - - - - - - - {participants.length > 0 && ( - - - - - )} - - {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( - - - - - )} - - - - - - - {taskModelLabel && ( - - - - - )} - - - - - - - {showRuntimeRow && ( - - - - - )} - - {(taskRun.pullRequests?.length ?? 0) > 0 ? ( - - - - - ) : taskRun.prRepo && taskRun.prNumber ? ( - - - + + + + + {participants.length > 0 && ( + + + - - ) : null} - - {linkedWorkItems.length > 0 ? ( - - - - - ) : null} - - - - - - - - - - - -
- Creator - - {task.user && task.attributionKind === 'user' ? ( - <> - {task.user.imageUrl ? ( - {taskCreatorDisplayName} - ) : null} - {taskCreatorDisplayName} - - ) : ( - taskCreatorDisplayName - )} -
- Participants - -
- {participants.map((participant) => ( - - - {participant.displayName} - - ))} -
-
- Workspace - - -
- Sandbox Provider - - - - {sandboxProviderLabel} - -
- Model - - - - {taskModelLabel} - {taskRun.payload?.modelRoleOverrides && ( - - Customized - - )} - -
- Inference Cost - - - - {inferenceCostLabel} - -
- Runtime - - - - - {HARNESS_LABELS[effectiveHarness]} - - -
- Pull Requests - -
- {taskRun.pullRequests?.map((pullRequest) => ( - - ))} -
-
- Pull Request - - } + > + +
Creator + {task.user && task.attributionKind === 'user' ? ( + <> + {task.user.imageUrl ? ( + {taskCreatorDisplayName} + ) : null} + {taskCreatorDisplayName} + + ) : ( + taskCreatorDisplayName + )} +
+ Participants + +
+ {participants.map((participant) => ( + + -
- Linked Work - -
- {linkedWorkItems.map((item, index) => ( - - ))} -
-
- Started At - - - - - {formatStartedAt(taskRun.startedAt)} - + {participant.displayName} -
- Started From - - - {startedFrom.brandIcon ? ( - startedFrom.brandIcon === 'slack' ? ( - - ) : ( - - ) - ) : ( - - )} - {startedFrom.label} - -
- - {taskRunError && ( -
-
-

Last Error

- + ))}
-

- {taskRunError} -

-
- )} + + + )} + + {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( + + Workspace + + + + + )} + + + + Sandbox Provider + + + + + {sandboxProviderLabel} + + + + + {taskModelLabel && ( + + Model + + + + {taskModelLabel} + {taskRun.payload?.modelRoleOverrides && ( + + Customized + + )} + + + + )} + + + + + {inferenceCostLabel} + + + + {showRuntimeRow && ( + + Runtime + + + + + {HARNESS_LABELS[effectiveHarness]} + + + + + )} + + {(taskRun.pullRequests?.length ?? 0) > 0 ? ( + + + Pull Requests + + +
+ {taskRun.pullRequests?.map((pullRequest) => ( + + ))} +
+ + + ) : taskRun.prRepo && taskRun.prNumber ? ( + + + Pull Request + + + + + + ) : null} - {summaryEnabled && ( -
-
-

Summary

+ {linkedWorkItems.length > 0 ? ( + + + Linked Work + + +
+ {linkedWorkItems.map((item, index) => ( + + ))}
+ + + ) : null} - {isLoadingSummary ? ( -
- - Generating... -
- ) : summary ? ( - <> - {isSummaryStale && ( -
- New messages since last summarized. - -
- )} -
- - {summary} - -
- - ) : summaryErrorMessage ? ( -
-

{summaryErrorMessage}

+ + + + + {formatStartedAt(taskRun.startedAt)} + + + + + + + {startedFrom.brandIcon ? ( + startedFrom.brandIcon === 'slack' ? ( + + ) : ( + + ) + ) : ( + + )} + {startedFrom.label} + + + + + {taskRunError && ( +
+
+

Last Error

+ +
+

+ {taskRunError} +

+
+ )} + + {summaryEnabled && ( +
+
+

Summary

+
+ + {isLoadingSummary ? ( +
+ + Generating... +
+ ) : summary ? ( + <> + {isSummaryStale && ( +
+ New messages since last summarized.
- ) : null} + )} +
+ + {summary} + +
+ + ) : summaryErrorMessage ? ( +
+

{summaryErrorMessage}

+
- )} + ) : null}
-
- + )} + ); } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 60b6c110b..394c2e116 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -118,6 +118,9 @@ export default async function RootLayout({ return ( + {/* Must run synchronously before first paint: App Router queues + inline beforeInteractive Scripts until client bootstrap, which + flashes the wrong theme. */}