diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index db03d4c060..08a00f5b94 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -210,6 +210,12 @@ # Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". cot = "full" +# CoT display layout. "list" shows each step individually, "compact" collapses into one summary line. +# cot_display = "list" + +# Whether steps are expandable to show input/output details. +# show_step_details = true + # Specify a CSS file that can be used to customize the user interface. # The CSS file can be served from the public directory or via an external link. # custom_css = "/public/test.css" @@ -437,6 +443,8 @@ class UISettings(BaseModel): name: str description: str = "" cot: Literal["hidden", "tool_call", "full"] = "full" + cot_display: Literal["list", "compact"] = "list" + show_step_details: bool = True default_theme: Optional[Literal["light", "dark"]] = "dark" language: Optional[str] = None layout: Optional[Literal["default", "wide"]] = "default" diff --git a/backend/chainlit/translations/ar-SA.json b/backend/chainlit/translations/ar-SA.json index 3725231cc3..8a0feae0f2 100644 --- a/backend/chainlit/translations/ar-SA.json +++ b/backend/chainlit/translations/ar-SA.json @@ -99,7 +99,9 @@ "messages": { "status": { "using": "يستخدم", - "used": "مستخدم" + "used": "مستخدم", + "usedSteps": "الخطوات المستخدمة: {{count}}", + "usedTools": "الأدوات المستخدمة: {{count}}" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json index 9c9cc686a0..4b6c76cdcf 100644 --- a/backend/chainlit/translations/bn.json +++ b/backend/chainlit/translations/bn.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "ব্যবহার করছে", - "used": "ব্যবহৃত" + "used": "ব্যবহৃত", + "usedSteps": "{{count}}টি ধাপ ব্যবহার করেছে", + "usedTools": "{{count}}টি টুল ব্যবহার করেছে" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/da-DK.json b/backend/chainlit/translations/da-DK.json index 6f967f2415..3a020e50eb 100644 --- a/backend/chainlit/translations/da-DK.json +++ b/backend/chainlit/translations/da-DK.json @@ -99,7 +99,9 @@ "messages": { "status": { "using": "Bruger", - "used": "Brugte" + "used": "Brugte", + "usedSteps": "Brugte {{count}} trin", + "usedTools": "Brugte {{count}} værktøjer" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/de-DE.json b/backend/chainlit/translations/de-DE.json index 2a43cc17b2..7d4ef301db 100644 --- a/backend/chainlit/translations/de-DE.json +++ b/backend/chainlit/translations/de-DE.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "Verwendet", - "used": "Verwendete" + "used": "Verwendete", + "usedSteps": "{{count}} Schritte verwendet", + "usedTools": "{{count}} Tools verwendet" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/el-GR.json b/backend/chainlit/translations/el-GR.json index a0033f500b..32f58a1f0e 100644 --- a/backend/chainlit/translations/el-GR.json +++ b/backend/chainlit/translations/el-GR.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "Με τη χρήση", - "used": "Χρησιμοποιήθηκε" + "used": "Χρησιμοποιήθηκε", + "usedSteps": "Χρησιμοποιήθηκαν {{count}} βήματα", + "usedTools": "Χρησιμοποιήθηκαν {{count}} εργαλεία" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/en-US.json b/backend/chainlit/translations/en-US.json index f20f6a724d..67d7324d1d 100644 --- a/backend/chainlit/translations/en-US.json +++ b/backend/chainlit/translations/en-US.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "Using", - "used": "Used" + "used": "Used", + "usedSteps": "Used {{count}} steps", + "usedTools": "Used {{count}} tools" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/es.json b/backend/chainlit/translations/es.json index 6767ff6432..3849bb9519 100644 --- a/backend/chainlit/translations/es.json +++ b/backend/chainlit/translations/es.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "Usando", - "used": "Usado" + "used": "Usado", + "usedSteps": "Usó {{count}} pasos", + "usedTools": "Usó {{count}} herramientas" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/fr-FR.json b/backend/chainlit/translations/fr-FR.json index aaa98ded12..dd354fe93e 100644 --- a/backend/chainlit/translations/fr-FR.json +++ b/backend/chainlit/translations/fr-FR.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "Utilise", - "used": "Utilisé" + "used": "Utilisé", + "usedSteps": "{{count}} étapes utilisées", + "usedTools": "{{count}} outils utilisés" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json index 98f425fb69..50b6b756f9 100644 --- a/backend/chainlit/translations/gu.json +++ b/backend/chainlit/translations/gu.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "વાપરી રહ્યા છે", - "used": "વપરાયેલ" + "used": "વપરાયેલ", + "usedSteps": "{{count}} પગલાં વાપર્યાં", + "usedTools": "{{count}} ટૂલ્સ વાપર્યાં" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json index 0a4631ad2b..2abb7b5b32 100644 --- a/backend/chainlit/translations/he-IL.json +++ b/backend/chainlit/translations/he-IL.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "משתמש ב", - "used": "השתמש ב" + "used": "השתמש ב", + "usedSteps": "השתמש ב-{{count}} שלבים", + "usedTools": "השתמש ב-{{count}} כלים" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json index 04e56bd55f..77faa5a9ca 100644 --- a/backend/chainlit/translations/hi.json +++ b/backend/chainlit/translations/hi.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "उपयोग कर रहे हैं", - "used": "उपयोग किया" + "used": "उपयोग किया", + "usedSteps": "{{count}} चरणों का उपयोग किया", + "usedTools": "{{count}} टूल्स का उपयोग किया" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/it.json b/backend/chainlit/translations/it.json index e5ce813556..766d149733 100644 --- a/backend/chainlit/translations/it.json +++ b/backend/chainlit/translations/it.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "In uso", - "used": "Utilizzato" + "used": "Utilizzato", + "usedSteps": "Utilizzati {{count}} passaggi", + "usedTools": "Utilizzati {{count}} strumenti" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/ja.json b/backend/chainlit/translations/ja.json index 965a09ec04..e3c2c5b180 100644 --- a/backend/chainlit/translations/ja.json +++ b/backend/chainlit/translations/ja.json @@ -99,7 +99,9 @@ "messages": { "status": { "using": "使用中", - "used": "使用済み" + "used": "使用済み", + "usedSteps": "{{count}}個のステップを使用", + "usedTools": "{{count}}個のツールを使用" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json index d5fc2d8380..504d069183 100644 --- a/backend/chainlit/translations/kn.json +++ b/backend/chainlit/translations/kn.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "ಬಳಸುತ್ತಿರುವುದು", - "used": "ಬಳಸಲಾಗಿದೆ" + "used": "ಬಳಸಲಾಗಿದೆ", + "usedSteps": "{{count}} ಹಂತಗಳನ್ನು ಬಳಸಲಾಗಿದೆ", + "usedTools": "{{count}} ಸಾಧನಗಳನ್ನು ಬಳಸಲಾಗಿದೆ" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/ko.json b/backend/chainlit/translations/ko.json index be1dc295a6..065ea7b4d5 100644 --- a/backend/chainlit/translations/ko.json +++ b/backend/chainlit/translations/ko.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "사용 중", - "used": "사용됨" + "used": "사용됨", + "usedSteps": "{{count}}개의 단계 사용됨", + "usedTools": "{{count}}개의 도구 사용됨" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json index f35ac7762c..9c55164aa8 100644 --- a/backend/chainlit/translations/ml.json +++ b/backend/chainlit/translations/ml.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "ഉപയോഗിക്കുന്നു", - "used": "ഉപയോഗിച്ചു" + "used": "ഉപയോഗിച്ചു", + "usedSteps": "{{count}} ഘട്ടങ്ങൾ ഉപയോഗിച്ചു", + "usedTools": "{{count}} ടൂളുകൾ ഉപയോഗിച്ചു" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json index 52e83c69c1..b7d5aa1e01 100644 --- a/backend/chainlit/translations/mr.json +++ b/backend/chainlit/translations/mr.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "वापरत आहे", - "used": "वापरले" + "used": "वापरले", + "usedSteps": "{{count}} पायऱ्या वापरल्या", + "usedTools": "{{count}} साधने वापरली" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/nl.json b/backend/chainlit/translations/nl.json index c8177cbcca..b2098a6959 100644 --- a/backend/chainlit/translations/nl.json +++ b/backend/chainlit/translations/nl.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "In gebruik", - "used": "Gebruikt" + "used": "Gebruikt", + "usedSteps": "{{count}} stappen gebruikt", + "usedTools": "{{count}} hulpmiddelen gebruikt" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/pt-PT.json b/backend/chainlit/translations/pt-PT.json index b01e521b0d..3f1b81c6fc 100644 --- a/backend/chainlit/translations/pt-PT.json +++ b/backend/chainlit/translations/pt-PT.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "A utilizar", - "used": "Utilizado" + "used": "Utilizado", + "usedSteps": "{{count}} passos utilizados", + "usedTools": "{{count}} ferramentas utilizadas" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json index 0701c92adc..f352f4b54c 100644 --- a/backend/chainlit/translations/ta.json +++ b/backend/chainlit/translations/ta.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "பயன்படுத்துகிறது", - "used": "பயன்படுத்தப்பட்டது" + "used": "பயன்படுத்தப்பட்டது", + "usedSteps": "{{count}} படிகள் பயன்படுத்தப்பட்டன", + "usedTools": "{{count}} கருவிகள் பயன்படுத்தப்பட்டன" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json index ed83818c89..98eb8eb803 100644 --- a/backend/chainlit/translations/te.json +++ b/backend/chainlit/translations/te.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "ఉపయోగిస్తోంది", - "used": "ఉపయోగించబడింది" + "used": "ఉపయోగించబడింది", + "usedSteps": "{{count}} దశలు ఉపయోగించబడ్డాయి", + "usedTools": "{{count}} సాధనాలు ఉపయోగించబడ్డాయి" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/zh-CN.json b/backend/chainlit/translations/zh-CN.json index e8307ab423..66333a7675 100644 --- a/backend/chainlit/translations/zh-CN.json +++ b/backend/chainlit/translations/zh-CN.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "使用中", - "used": "已使用" + "used": "已使用", + "usedSteps": "使用了 {{count}} 个步骤", + "usedTools": "使用了 {{count}} 个工具" }, "actions": { "copy": { diff --git a/backend/chainlit/translations/zh-TW.json b/backend/chainlit/translations/zh-TW.json index fa0c62a5df..9d3a5fc201 100644 --- a/backend/chainlit/translations/zh-TW.json +++ b/backend/chainlit/translations/zh-TW.json @@ -100,7 +100,9 @@ "messages": { "status": { "using": "正在使用", - "used": "已使用" + "used": "已使用", + "usedSteps": "使用了 {{count}} 個步驟", + "usedTools": "使用了 {{count}} 個工具" }, "actions": { "copy": { diff --git a/cypress/e2e/compact_steps/.chainlit/config.toml b/cypress/e2e/compact_steps/.chainlit/config.toml new file mode 100644 index 0000000000..e4937335e4 --- /dev/null +++ b/cypress/e2e/compact_steps/.chainlit/config.toml @@ -0,0 +1,14 @@ +[project] +allow_origins = ["*"] + +[UI] +name = "Assistant" + +# Show every step of the chain of thought. +cot = "full" + +# Collapse the chain of thought steps into a single compact summary line. +cot_display = "compact" + +[meta] +generated_by = "2.11.1" diff --git a/cypress/e2e/compact_steps/main.py b/cypress/e2e/compact_steps/main.py new file mode 100644 index 0000000000..66389d1d02 --- /dev/null +++ b/cypress/e2e/compact_steps/main.py @@ -0,0 +1,68 @@ +import chainlit as cl + + +@cl.step(type="tool", name="tool1") +async def tool1(): + return "Result from tool1" + + +@cl.step(type="tool", name="tool2") +async def tool2(): + return "Result from tool2" + + +@cl.step(type="tool", name="tool3") +async def tool3(): + return "Result from tool3" + + +@cl.step(type="tool", name="tool_icon", icon="search") +async def tool_icon(): + return "Result from tool_icon" + + +@cl.step(type="tool", name="agent") +async def agent(): + # Two tools nested under an intermediate step. Compact mode must count the + # nested steps recursively (not just top-level ones) to activate. + await tool1() + await tool2() + return "Result from agent" + + +@cl.step(type="tool", name="answering-agent") +async def answering_agent(): + await tool1() + await tool2() + # Assistant message emitted from inside a nested step. + await cl.Message(content="Nested answer").send() + + +@cl.on_message +async def main(message: cl.Message): + content = message.content.strip().lower() + + if content == "single": + # A single visible step must not be collapsed into a compact summary. + await tool1() + elif content == "nested": + # Only one top-level step, but it nests two tools -> 3 visible steps. + await agent() + elif content == "nested_message": + # An assistant message produced inside a nested step must still render + # at the root, not be trapped inside the collapsed compact summary. + await answering_agent() + return + elif content == "icon": + # The last visible step carries an icon, so the compact summary avatar + # must render that icon instead of a fallback avatar image. + await tool1() + await tool2() + await tool_icon() + else: + # Three sequential tools -> collapsed into one compact summary. + await tool1() + await tool2() + await tool3() + + await cl.Message(content="Final answer").send() diff --git a/cypress/e2e/compact_steps/spec.cy.ts b/cypress/e2e/compact_steps/spec.cy.ts new file mode 100644 index 0000000000..8d2d14f178 --- /dev/null +++ b/cypress/e2e/compact_steps/spec.cy.ts @@ -0,0 +1,95 @@ +import { submitMessage } from '../../support/testUtils'; + +describe('Compact CoT display', () => { + it('collapses multiple steps into a single compact summary', () => { + cy.visit('/'); + submitMessage('multi'); + + // The three tool steps are grouped into a single compact summary line. + cy.get('[data-testid="compact-steps"]').should('have.length', 1); + cy.get('[data-testid="compact-steps-trigger"]').should( + 'contain', + 'Used 3 steps' + ); + + // The final answer renders at the root, outside the collapsed summary. + cy.contains('Final answer').should('be.visible'); + + // Individual steps stay hidden until the summary is expanded. + cy.get('#step-tool1').should('not.exist'); + + // Expanding the summary reveals the individual steps. + cy.get('[data-testid="compact-steps-trigger"]').click(); + cy.get('#step-tool1').should('be.visible'); + cy.get('#step-tool2').should('be.visible'); + cy.get('#step-tool3').should('be.visible'); + }); + + it('does not collapse a single step', () => { + cy.visit('/'); + submitMessage('single'); + + // A lone step is rendered directly, without a compact summary. + cy.get('#step-tool1').should('be.visible'); + cy.get('[data-testid="compact-steps"]').should('not.exist'); + cy.contains('Final answer').should('be.visible'); + }); + + it('counts nested steps recursively to trigger compact mode', () => { + cy.visit('/'); + submitMessage('nested'); + + // Only one top-level step, but it nests two tools, so the recursive count + // reaches three and the compact summary is used. + cy.get('[data-testid="compact-steps"]').should('have.length', 1); + cy.get('[data-testid="compact-steps-trigger"]').should( + 'contain', + 'Used 3 steps' + ); + + // Expanding the summary reveals the intermediate step. + cy.get('[data-testid="compact-steps-trigger"]').click(); + cy.get('#step-agent').should('be.visible'); + }); + + it('keeps a nested assistant message visible outside the collapsed summary', () => { + cy.visit('/'); + submitMessage('nested_message'); + + // The steps collapse into a summary that stays closed. + cy.get('[data-testid="compact-steps"]').should('have.length', 1); + cy.get('[data-testid="compact-steps-trigger"]').should( + 'have.attr', + 'data-state', + 'closed' + ); + + // The assistant message produced inside a nested step is lifted to the root: + // visible even though the summary is still collapsed. + cy.contains('Nested answer').should('be.visible'); + + // Expanding the summary shows only the steps; the message is not duplicated + // inside (the accordion content is mounted once open, so this is meaningful). + cy.get('[data-testid="compact-steps-trigger"]').click(); + cy.get('[data-testid="compact-steps-trigger"]').should( + 'have.attr', + 'data-state', + 'open' + ); + cy.get('[data-testid="compact-steps"]').should( + 'not.contain', + 'Nested answer' + ); + }); + + it("renders the last step's icon in the compact summary avatar", () => { + cy.visit('/'); + submitMessage('icon'); + + cy.get('[data-testid="compact-steps"]').should('have.length', 1); + + // The summary avatar reflects the last step's icon (an ), so no + // fallback avatar is rendered inside the compact summary. + cy.get('[data-testid="compact-steps"]').find('img').should('not.exist'); + }); +}); diff --git a/cypress/e2e/step_details/.chainlit/config.toml b/cypress/e2e/step_details/.chainlit/config.toml new file mode 100644 index 0000000000..23aec4e8e2 --- /dev/null +++ b/cypress/e2e/step_details/.chainlit/config.toml @@ -0,0 +1,12 @@ +[project] +allow_origins = ["*"] + +[UI] +name = "Assistant" +cot = "full" + +# Hide input/output detail; the nested step hierarchy must still be visible. +show_step_details = false + +[meta] +generated_by = "2.11.1" diff --git a/cypress/e2e/step_details/main.py b/cypress/e2e/step_details/main.py new file mode 100644 index 0000000000..82d7d2831a --- /dev/null +++ b/cypress/e2e/step_details/main.py @@ -0,0 +1,24 @@ +import chainlit as cl + + +@cl.step(type="tool", name="leaf") +async def leaf(): + return "Leaf result" + + +@cl.step(type="tool", name="branch") +async def branch(): + await leaf() + return "Branch result" + + +@cl.step(type="tool", name="root") +async def root(): + await branch() + return "Root result" + + +@cl.on_message +async def main(message: cl.Message): + await root() + await cl.Message(content="Final answer").send() diff --git a/cypress/e2e/step_details/spec.cy.ts b/cypress/e2e/step_details/spec.cy.ts new file mode 100644 index 0000000000..cbe19ecf59 --- /dev/null +++ b/cypress/e2e/step_details/spec.cy.ts @@ -0,0 +1,28 @@ +import { submitMessage } from '../../support/testUtils'; + +describe('Step details disabled', () => { + it('keeps a collapsible step hierarchy but hides input/output detail', () => { + cy.visit('/'); + submitMessage('go'); + + // Root is a collapsible accordion, collapsed by default. + cy.get('#step-root') + .should('be.visible') + .and('have.attr', 'data-state', 'closed'); + + // Nested steps stay hidden until expanded (hierarchy preserved, not forced open). + cy.get('#step-branch').should('not.exist'); + + // Drilling down reveals the hierarchy one level at a time. + cy.get('#step-root').click(); + cy.get('#step-branch').should('be.visible'); + cy.get('#step-branch').click(); + cy.get('#step-leaf').should('be.visible'); + + // Input/output payloads are hidden: the step return values never render. + cy.contains('Root result').should('not.exist'); + cy.contains('Branch result').should('not.exist'); + + cy.contains('Final answer').should('be.visible'); + }); +}); diff --git a/frontend/src/components/ReadOnlyThread.tsx b/frontend/src/components/ReadOnlyThread.tsx index 4036b4ed8c..b8b70b70f4 100644 --- a/frontend/src/components/ReadOnlyThread.tsx +++ b/frontend/src/components/ReadOnlyThread.tsx @@ -163,6 +163,8 @@ const ReadOnlyThread = ({ id }: Props) => { showFeedbackButtons: !!config?.dataPersistence, uiName: config?.ui?.name || '', cot: config?.ui?.cot || 'hidden', + cotDisplay: config?.ui?.cot_display || 'list', + showStepDetails: config?.ui?.show_step_details ?? true, onElementRefClick, onError, onFeedbackUpdated, @@ -171,8 +173,12 @@ const ReadOnlyThread = ({ id }: Props) => { }, [ config?.ui?.name, config?.ui?.cot, + config?.ui?.cot_display, + config?.ui?.show_step_details, config?.features?.unsafe_allow_html, + config?.features?.latex, config?.features?.user_message_markdown, + config?.dataPersistence, onElementRefClick, onError, onFeedbackUpdated, diff --git a/frontend/src/components/chat/Messages/CompactSteps.tsx b/frontend/src/components/chat/Messages/CompactSteps.tsx new file mode 100644 index 0000000000..9f05c04f5b --- /dev/null +++ b/frontend/src/components/chat/Messages/CompactSteps.tsx @@ -0,0 +1,162 @@ +import { cn } from '@/lib/utils'; +import { MessageContext } from 'contexts/MessageContext'; +import { memo, useContext, useMemo, useState } from 'react'; + +import type { IAction, IMessageElement, IStep } from '@chainlit/react-client'; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger +} from '@/components/ui/accordion'; +import { Translator } from 'components/i18n'; + +import { Messages } from '.'; +import { MessageAvatar } from './Message/Avatar'; + +interface Props { + steps: IStep[]; + elements: IMessageElement[]; + actions: IAction[]; + indent: number; + isRunning?: boolean; + scorableRun?: IStep; +} + +// Recursively collect tool/step-type nodes for counting and naming +const collectVisible = (steps: IStep[], cot: string): IStep[] => { + const result: IStep[] = []; + for (const s of steps) { + if (!s.type.includes('message')) { + if (cot !== 'tool_call' || s.type === 'tool') result.push(s); + } + if (s.steps) result.push(...collectVisible(s.steps, cot)); + } + return result; +}; + +// Assistant messages are lifted to the root by the parent, so drop them at +// every depth to avoid trapping them inside the collapsed accordion. +const stripMessages = (steps: IStep[]): IStep[] => + steps + .filter((s) => !s.type.includes('message')) + .map((s) => (s.steps ? { ...s, steps: stripMessages(s.steps) } : s)); + +const CompactSteps = memo( + ({ steps, elements, actions, indent, isRunning, scorableRun }: Props) => { + const { cot } = useContext(MessageContext); + const [openValue, setOpenValue] = useState(''); + + // Steps to render inside the accordion, with assistant messages stripped + // at every depth (they render at the root instead). + const stepChildren = useMemo(() => stripMessages(steps), [steps]); + + // Recursively collected visible steps for count and label + const visibleSteps = useMemo(() => { + return collectVisible(steps, cot); + }, [steps, cot]); + + // Show "Using" until an assistant_message appears (tools done, model + // streaming), which avoids flashing between sequential tools. But if a + // leaf step is still executing keep "Using" too, so an agent that emits an + // intermediate message then keeps working doesn't wrongly read "Used". + const hasAnswer = useMemo(() => { + const check = (items: IStep[]): boolean => + items.some( + (s) => + s.type === 'assistant_message' || (s.steps ? check(s.steps) : false) + ); + return check(steps); + }, [steps]); + + const hasActiveStep = useMemo(() => { + const check = (items: IStep[]): boolean => + items.some((s) => + s.steps?.length + ? check(s.steps) + : !s.type.includes('message') && !!s.start && !s.end && !s.isError + ); + return check(steps); + }, [steps]); + + const showUsing = !!isRunning && (!hasAnswer || hasActiveStep); + + // Get the last visible step name for the "Using X" label + const lastStep = visibleSteps[visibleSteps.length - 1]; + const lastStepName = lastStep?.name || ''; + + // Determine the label translation key for the finished state + const usedLabelPath = + cot === 'tool_call' + ? 'chat.messages.status.usedTools' + : 'chat.messages.status.usedSteps'; + + const accordionId = 'compact-steps'; + + return ( +
+
+
+ +
+ setOpenValue(val)} + className="w-full" + > + + + {showUsing ? ( + <> + {' '} + {lastStepName} + + ) : ( + + )} + + +
+ +
+
+
+
+
+
+
+
+ ); + } +); + +CompactSteps.displayName = 'CompactSteps'; + +export { CompactSteps }; diff --git a/frontend/src/components/chat/Messages/Message/Step.tsx b/frontend/src/components/chat/Messages/Message/Step.tsx index 96432437bc..6d414dc5b9 100644 --- a/frontend/src/components/chat/Messages/Message/Step.tsx +++ b/frontend/src/components/chat/Messages/Message/Step.tsx @@ -1,5 +1,13 @@ import { cn } from '@/lib/utils'; -import { PropsWithChildren, useEffect, useMemo, useState } from 'react'; +import { MessageContext } from 'contexts/MessageContext'; +import { + PropsWithChildren, + ReactNode, + useContext, + useEffect, + useMemo, + useState +} from 'react'; import type { IStep } from '@chainlit/react-client'; @@ -14,18 +22,28 @@ import { Translator } from 'components/i18n'; interface Props { step: IStep; isRunning?: boolean; + nestedSteps?: ReactNode; } export default function Step({ step, children, - isRunning + isRunning, + nestedSteps }: PropsWithChildren) { + const { showStepDetails } = useContext(MessageContext); + const using = useMemo(() => { return isRunning && step.start && !step.end && !step.isError; }, [step, isRunning]); - const hasContent = step.input || step.output || step.steps?.length; + const hasSubSteps = !!step.steps?.length; + const hasContent = step.input || step.output || hasSubSteps; + // Flat mode's accordion body renders only non-message sub-steps, so a step whose + // children are all messages isn't expandable there (it would open to nothing). + const hasVisibleSubSteps = + step.steps?.some((s) => !s.type.includes('message')) ?? false; + const expandable = showStepDetails ? Boolean(hasContent) : hasVisibleSubSteps; const isError = step.isError; const stepName = step.name; @@ -40,13 +58,13 @@ export default function Step({ } }, [using, step.autoCollapse]); - // If there's no content, just render the status without accordion - if (!hasContent) { + // Nothing to expand: render a flat status label. + if (!expandable) { return (

- {children} + {showStepDetails ? children : nestedSteps}
diff --git a/frontend/src/components/chat/Messages/Message/index.tsx b/frontend/src/components/chat/Messages/Message/index.tsx index a7ffe81ea4..959e8923d1 100644 --- a/frontend/src/components/chat/Messages/Message/index.tsx +++ b/frontend/src/components/chat/Messages/Message/index.tsx @@ -86,6 +86,16 @@ const Message = memo( ); } + const nestedSteps = message.steps ? ( + !s.type.includes('message'))} + elements={elements} + actions={actions} + indent={indent + 1} + isRunning={isRunning} + /> + ) : null; + return ( <>
@@ -117,7 +127,11 @@ const Message = memo( ) : null} {/* Display the step and its children */} {isStep ? ( - + {showInputSection ? ( ) : null} - {message.steps ? ( - !s.type.includes('message') - )} - elements={elements} - actions={actions} - indent={indent + 1} - isRunning={isRunning} - /> - ) : null} + {nestedSteps} {shouldRenderOutput ? ( { ); }; +const countVisibleSteps = (steps: IStep[], cot: string): number => { + let count = 0; + for (const s of steps) { + if (!s.type.includes('message')) { + if (cot !== 'tool_call' || s.type === 'tool') count++; + } + if (s.steps) count += countVisibleSteps(s.steps, cot); + } + return count; +}; + +// Assistant messages must surface at the root even when emitted from inside a +// nested step, so collect them recursively for compact mode. +const collectMessages = (steps: IStep[]): IStep[] => { + const out: IStep[] = []; + for (const s of steps) { + if (s.type.includes('message')) out.push(s); + else if (s.steps) out.push(...collectMessages(s.steps)); + } + return out; +}; + const Messages = memo( ({ messages, elements, actions, indent, isRunning, scorableRun }: Props) => { const messageContext = useContext(MessageContext); @@ -76,17 +99,52 @@ const Messages = memo( // Ignore on_chat_start for scorable run const scorableRun = !isRunning && m.name !== 'on_chat_start' ? m : undefined; + + // Determine if compact mode should be used + const useCompact = + messageContext.cotDisplay === 'compact' && + messageContext.cot !== 'hidden'; + + // Only use compact when there are 2+ steps worth grouping + const visibleStepCount = useCompact + ? countVisibleSteps(m.steps || [], messageContext.cot) + : 0; + + const showCompact = useCompact && visibleStepCount > 1; + return ( {m.steps?.length ? ( - + showCompact ? ( + <> + + {/* Lift assistant messages (at any depth) to the root */} + + + ) : ( + + ) ) : null} {(showToolCoTLoader || showHiddenCoTLoader) && m.name !== 'on_chat_start' ? ( diff --git a/frontend/src/components/chat/MessagesContainer/index.tsx b/frontend/src/components/chat/MessagesContainer/index.tsx index 18899fec92..edc20b2ca9 100644 --- a/frontend/src/components/chat/MessagesContainer/index.tsx +++ b/frontend/src/components/chat/MessagesContainer/index.tsx @@ -164,22 +164,30 @@ const MessagesContainer = ({ navigate }: Props) => { showFeedbackButtons: enableFeedback, uiName: config?.ui?.name || '', cot: config?.ui?.cot || 'hidden', + cotDisplay: config?.ui?.cot_display || 'list', + showStepDetails: config?.ui?.show_step_details ?? true, onElementRefClick, onError, onFeedbackUpdated, onFeedbackDeleted }; }, [ + uploadFile, askUser, enableFeedback, loading, config?.ui?.name, config?.ui?.cot, + config?.ui?.cot_display, + config?.ui?.show_step_details, config?.features?.unsafe_allow_html, + config?.features?.latex, config?.features?.user_message_markdown, + config?.features?.edit_message, onElementRefClick, onError, - onFeedbackUpdated + onFeedbackUpdated, + onFeedbackDeleted ]); return ( diff --git a/frontend/src/contexts/MessageContext.tsx b/frontend/src/contexts/MessageContext.tsx index 45735b61f2..e0350db23c 100644 --- a/frontend/src/contexts/MessageContext.tsx +++ b/frontend/src/contexts/MessageContext.tsx @@ -11,7 +11,9 @@ const defaultMessageContext = { showFeedbackButtons: true, onError: () => undefined, uiName: '', - cot: 'hidden' as const + cot: 'hidden' as const, + cotDisplay: 'list' as const, + showStepDetails: true }; const MessageContext = createContext(defaultMessageContext); diff --git a/frontend/src/types/messageContext.ts b/frontend/src/types/messageContext.ts index 87b79f1800..8b81f6c394 100644 --- a/frontend/src/types/messageContext.ts +++ b/frontend/src/types/messageContext.ts @@ -12,6 +12,8 @@ interface IMessageContext { onProgress: (progress: number) => void ) => { xhr: XMLHttpRequest; promise: Promise }; cot: 'hidden' | 'tool_call' | 'full'; + cotDisplay: 'list' | 'compact'; + showStepDetails: boolean; askUser?: IAsk; editable: boolean; loading: boolean; diff --git a/libs/react-client/src/types/config.ts b/libs/react-client/src/types/config.ts index df06adbe39..33359da5de 100644 --- a/libs/react-client/src/types/config.ts +++ b/libs/react-client/src/types/config.ts @@ -46,6 +46,8 @@ export interface IChainlitConfig { default_chat_settings_open?: boolean; confirm_new_chat?: boolean; cot: 'hidden' | 'tool_call' | 'full'; + cot_display?: 'list' | 'compact'; + show_step_details?: boolean; github?: string; custom_css?: string; custom_js?: string;