From 2d0d80d58c97f33705ff470f66acf66d0031829b Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Thu, 30 Apr 2026 10:37:57 +0500 Subject: [PATCH 1/2] feat(bichat): always-visible message actions and Fast/Deep regenerate picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop hover-gated opacity on assistant/user message action buttons so copy/regenerate/edit are always visible (touch + a11y friendly). - Clicking the regenerate (↻) button on the last assistant message now opens a small popover with the available models (Fast / Deep) sourced from `extensions.llm.models`. Selecting one updates the session model via setModel and triggers regeneration with that model. Falls back to immediate regeneration when fewer than 2 models are configured. - ChatMachine.handleRegenerate now accepts an optional model id and applies it via _setModel before delegating to _sendMessageDirect, so ModelSelector and the active session stay in sync. - Outside-click handler uses event.composedPath() so it works correctly inside the BiChat shadow root (event retargeting otherwise made every click look "outside" and unmounted the menu before its onClick fired). --- ui/src/bichat/components/AssistantMessage.tsx | 160 ++++++++++++++++-- .../bichat/components/AssistantTurnView.tsx | 16 +- ui/src/bichat/components/UserMessage.tsx | 2 +- ui/src/bichat/index.ts | 1 + ui/src/bichat/machine/ChatMachine.ts | 11 +- ui/src/bichat/machine/types.ts | 2 +- ui/src/bichat/types/index.ts | 2 +- 7 files changed, 170 insertions(+), 24 deletions(-) diff --git a/ui/src/bichat/components/AssistantMessage.tsx b/ui/src/bichat/components/AssistantMessage.tsx index f238c2f..4ba6754 100644 --- a/ui/src/bichat/components/AssistantMessage.tsx +++ b/ui/src/bichat/components/AssistantMessage.tsx @@ -18,6 +18,8 @@ import { Copy, ArrowsClockwise, CaretRight, + Lightning, + Brain, } from "@phosphor-icons/react"; import { formatRelativeTime } from "../utils/dateFormatting"; import CodeOutputsPanel from "./CodeOutputsPanel"; @@ -95,8 +97,10 @@ export interface AssistantMessageArtifactsSlotProps { export interface AssistantMessageActionsSlotProps { /** Copy content to clipboard */ onCopy: () => void; - /** Regenerate response */ - onRegenerate?: () => void; + /** Regenerate response, optionally with a specific model id */ + onRegenerate?: (model?: string) => void; + /** Available models that can be picked for regenerate */ + regenerateModels?: RegenerateModelOption[]; /** Formatted timestamp */ timestamp: string; /** Whether copy action is available */ @@ -105,6 +109,13 @@ export interface AssistantMessageActionsSlotProps { canRegenerate: boolean; } +export interface RegenerateModelOption { + /** Model id passed to onRegenerate */ + id: string; + /** Translation key (or label) shown in the picker */ + label: string; +} + export interface AssistantMessageExplanationSlotProps { /** Explanation content (markdown) */ explanation: string; @@ -197,8 +208,13 @@ export interface AssistantMessageProps { classNames?: AssistantMessageClassNames; /** Copy handler */ onCopy?: (content: string) => Promise | void; - /** Regenerate handler */ - onRegenerate?: (turnId: string) => Promise | void; + /** Regenerate handler. The optional `model` argument is the id chosen from the + * Fast/Deep picker; when omitted the current session model is used. */ + onRegenerate?: (turnId: string, model?: string) => Promise | void; + /** Models offered when the user clicks the regenerate button. When two or more + * options are provided a Fast/Deep picker is shown; otherwise regenerate is + * triggered immediately with the active model. */ + regenerateModels?: RegenerateModelOption[]; /** Send message handler (for markdown links) */ onSendMessage?: (content: string) => void; /** Whether sending is disabled */ @@ -241,8 +257,7 @@ const defaultClassNames: Required = { artifacts: "mb-1 flex flex-wrap gap-2", sources: "", explanation: "mt-4 border-t border-gray-100 dark:border-gray-700 pt-4", - actions: - "flex items-center gap-1 transition-opacity duration-150 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100", + actions: "flex items-center gap-1", actionButton: "cursor-pointer p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-500 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50", timestamp: "text-xs text-gray-400 dark:text-gray-500 mr-1", @@ -286,6 +301,7 @@ export function AssistantMessage({ classNames: classNameOverrides, onCopy, onRegenerate, + regenerateModels, onSendMessage, sendDisabled = false, hideAvatar = false, @@ -296,6 +312,8 @@ export function AssistantMessage({ const { t } = useTranslation(); const [explanationExpanded, setExplanationExpanded] = useState(false); const [isCopied, setIsCopied] = useState(false); + const [showRegenPicker, setShowRegenPicker] = useState(false); + const regenPickerRef = useRef(null); const copyFeedbackTimeoutRef = useRef | null>( null, ); @@ -392,11 +410,62 @@ export function AssistantMessage({ } }, [onCopy, turn.content]); + const hasRegenChoices = (regenerateModels?.length ?? 0) >= 2; + const handleRegenerateClick = useCallback(async () => { - if (onRegenerate && turnId) { - await onRegenerate(turnId); + if (!onRegenerate || !turnId) { + return; } - }, [onRegenerate, turnId]); + if (hasRegenChoices) { + setShowRegenPicker((prev) => !prev); + return; + } + await onRegenerate(turnId); + }, [onRegenerate, turnId, hasRegenChoices]); + + const handleRegenerateWithModel = useCallback( + async (modelId: string) => { + setShowRegenPicker(false); + if (onRegenerate && turnId) { + await onRegenerate(turnId, modelId); + } + }, + [onRegenerate, turnId], + ); + + // Close picker on outside click / Escape. + useEffect(() => { + if (!showRegenPicker) { + return; + } + const handlePointer = (e: MouseEvent | TouchEvent) => { + const root = regenPickerRef.current; + if (!root) {return;} + // Use composedPath so the check works across shadow DOM boundaries + // (BiChat is hosted inside a shadow root; e.target gets retargeted to + // the shadow host and `root.contains(target)` is always false). + const path = + typeof e.composedPath === "function" ? e.composedPath() : []; + const insideViaPath = path.includes(root); + const insideViaContains = root.contains(e.target as Node); + if (!insideViaPath && !insideViaContains) { + setShowRegenPicker(false); + } + }; + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setShowRegenPicker(false); + } + }; + document.addEventListener("mousedown", handlePointer); + document.addEventListener("touchstart", handlePointer); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handlePointer); + document.removeEventListener("touchstart", handlePointer); + document.removeEventListener("keydown", handleKey); + }; + }, [showRegenPicker]); const timestamp = formatRelativeTime(turn.createdAt, t); @@ -426,7 +495,19 @@ export function AssistantMessage({ }; const actionsSlotProps: AssistantMessageActionsSlotProps = { onCopy: handleCopyClick, - onRegenerate: canRegenerate ? handleRegenerateClick : undefined, + onRegenerate: canRegenerate + ? (model) => { + if (!onRegenerate || !turnId) { + return; + } + if (model) { + void handleRegenerateWithModel(model); + } else { + void handleRegenerateClick(); + } + } + : undefined, + regenerateModels, timestamp, canCopy: hasContent, canRegenerate, @@ -707,14 +788,57 @@ export function AssistantMessage({ {canRegenerate && ( - +
+ + {hasRegenChoices && showRegenPicker && ( +
+ {regenerateModels!.map((m, i) => { + const isFast = i === 0; + const Icon = isFast ? Lightning : Brain; + const accent = isFast + ? "text-amber-600 dark:text-amber-400" + : "text-blue-600 dark:text-blue-400"; + return ( + + ); + })} +
+ )} +
)} , )} diff --git a/ui/src/bichat/components/AssistantTurnView.tsx b/ui/src/bichat/components/AssistantTurnView.tsx index 5cb9e9a..f881bc3 100644 --- a/ui/src/bichat/components/AssistantTurnView.tsx +++ b/ui/src/bichat/components/AssistantTurnView.tsx @@ -8,8 +8,15 @@ * For more customization, use the AssistantMessage component directly with slots. */ +import { useMemo } from 'react'; import { useChatSession, useChatMessaging } from '../context/ChatContext'; -import { AssistantMessage, type AssistantMessageSlots, type AssistantMessageClassNames } from './AssistantMessage'; +import { useIotaContext } from '../context/IotaContext'; +import { + AssistantMessage, + type AssistantMessageSlots, + type AssistantMessageClassNames, + type RegenerateModelOption, +} from './AssistantMessage'; import { SystemMessage } from './SystemMessage'; import type { ConversationTurn } from '../types'; @@ -47,6 +54,12 @@ export function AssistantTurnView({ }: AssistantTurnViewProps) { const { debugMode } = useChatSession(); const { handleCopy, handleRegenerate, pendingQuestion, sendMessage, loading } = useChatMessaging(); + const iotaContext = useIotaContext(); + const regenerateModels = useMemo(() => { + const models = iotaContext.extensions?.llm?.models; + if (!models || models.length < 2) {return undefined;} + return models.map((m) => ({ id: m.id, label: m.label })); + }, [iotaContext.extensions?.llm?.models]); const assistantTurn = turn.assistantTurn; if (!assistantTurn) {return null;} @@ -74,6 +87,7 @@ export function AssistantTurnView({ classNames={classNames} onCopy={handleCopy} onRegenerate={allowRegenerate ? handleRegenerate : undefined} + regenerateModels={allowRegenerate ? regenerateModels : undefined} onSendMessage={sendMessage} sendDisabled={loading || isStreaming} hideAvatar={hideAvatar} diff --git a/ui/src/bichat/components/UserMessage.tsx b/ui/src/bichat/components/UserMessage.tsx index 2fa1f85..2c4d65a 100644 --- a/ui/src/bichat/components/UserMessage.tsx +++ b/ui/src/bichat/components/UserMessage.tsx @@ -121,7 +121,7 @@ const defaultClassNames: Required = { bubble: 'bg-primary-600 text-white rounded-2xl rounded-br-sm px-4 py-3 shadow-sm', content: 'text-sm whitespace-pre-wrap break-words leading-relaxed', attachments: 'mb-2 w-full', - actions: 'flex items-center gap-1 mt-2 transition-opacity duration-150 group-focus-within:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100', + actions: 'flex items-center gap-1 mt-2', actionButton: 'cursor-pointer p-2 min-h-[44px] min-w-[44px] flex items-center justify-center text-gray-500 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 rounded-md transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/50', timestamp: 'text-xs text-gray-400 dark:text-gray-500 mr-1', }; diff --git a/ui/src/bichat/index.ts b/ui/src/bichat/index.ts index 26e1d81..6632766 100644 --- a/ui/src/bichat/index.ts +++ b/ui/src/bichat/index.ts @@ -140,6 +140,7 @@ export { type AssistantMessageArtifactsSlotProps, type AssistantMessageActionsSlotProps, type AssistantMessageExplanationSlotProps, + type RegenerateModelOption, } from './components/AssistantMessage'; // ============================================================================= diff --git a/ui/src/bichat/machine/ChatMachine.ts b/ui/src/bichat/machine/ChatMachine.ts index 8b12da0..fdf1042 100644 --- a/ui/src/bichat/machine/ChatMachine.ts +++ b/ui/src/bichat/machine/ChatMachine.ts @@ -162,7 +162,7 @@ export class ChatMachine { content: string, attachments?: Attachment[], ) => Promise; - readonly handleRegenerate: (turnId: string) => Promise; + readonly handleRegenerate: (turnId: string, model?: string) => Promise; readonly handleEdit: (turnId: string, newContent: string) => Promise; readonly handleCopy: (text: string) => Promise; readonly handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void; @@ -1570,7 +1570,10 @@ export class ChatMachine { // ── Regenerate / Edit ─────────────────────────────────────────────────── - private async _handleRegenerate(turnId: string): Promise { + private async _handleRegenerate( + turnId: string, + model?: string, + ): Promise { const curSessionId = this.state.session.currentSessionId; if (!curSessionId || curSessionId === "new") { return; @@ -1581,6 +1584,10 @@ export class ChatMachine { return; } + if (model && model !== this.state.session.model) { + this._setModel(model); + } + this._updateSession({ error: null, errorRetryable: false }); // _sendMessageDirect delegates to _sendMessageCore which handles all errors internally await this._sendMessageDirect( diff --git a/ui/src/bichat/machine/types.ts b/ui/src/bichat/machine/types.ts index 8fc98ec..2f36b4e 100644 --- a/ui/src/bichat/machine/types.ts +++ b/ui/src/bichat/machine/types.ts @@ -125,7 +125,7 @@ export interface MessagingSnapshot { showActivityTrace: boolean showTypingIndicator: boolean sendMessage: (content: string, attachments?: Attachment[]) => Promise - handleRegenerate?: (turnId: string) => Promise + handleRegenerate?: (turnId: string, model?: string) => Promise handleEdit?: (turnId: string, newContent: string) => Promise handleCopy: (text: string) => Promise handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void diff --git a/ui/src/bichat/types/index.ts b/ui/src/bichat/types/index.ts index 789426a..f4558da 100644 --- a/ui/src/bichat/types/index.ts +++ b/ui/src/bichat/types/index.ts @@ -733,7 +733,7 @@ export interface ChatMessagingStateValue { showActivityTrace: boolean; showTypingIndicator: boolean; sendMessage: (content: string, attachments?: Attachment[]) => Promise; - handleRegenerate?: (turnId: string) => Promise; + handleRegenerate?: (turnId: string, model?: string) => Promise; handleEdit?: (turnId: string, newContent: string) => Promise; handleCopy: (text: string) => Promise; handleSubmitQuestionAnswers: (answers: QuestionAnswers) => void; From 1c0514913ffea05bcf52e54a8f2eaf014fad0c70 Mon Sep 17 00:00:00 2001 From: Diyor Khaydarov Date: Thu, 30 Apr 2026 10:45:39 +0500 Subject: [PATCH 2/2] chore(deps): bump indirect deps to versions required by latest CLI build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulled in by `go install ./cmd/applet` against the latest module graph (golang.org/x/text v0.34.0, golang.org/x/sys v0.41.0, etc). No code or behaviour change — just keeps go.mod / go.sum in sync. --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index ca6bdeb..230300f 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 - golang.org/x/text v0.30.0 + golang.org/x/text v0.34.0 ) require ( @@ -26,10 +26,10 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 383d227..1e65daa 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= @@ -53,15 +53,15 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=