From 10985671ad9553e7ac594ede30981166f69ba3c5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 17 Feb 2026 07:16:23 -0600 Subject: feat(app): session timeline/turn rework (#13196) Co-authored-by: David Hill --- packages/ui/src/components/basic-tool.css | 83 ++- packages/ui/src/components/basic-tool.tsx | 83 +-- packages/ui/src/components/button.css | 12 + packages/ui/src/components/checkbox.css | 3 +- packages/ui/src/components/collapsible.css | 41 +- packages/ui/src/components/collapsible.tsx | 7 +- packages/ui/src/components/diff-changes.css | 7 +- packages/ui/src/components/diff-changes.tsx | 4 +- packages/ui/src/components/icon-button.css | 36 ++ packages/ui/src/components/icon-button.tsx | 1 + packages/ui/src/components/icon.tsx | 13 +- packages/ui/src/components/inline-input.css | 2 +- packages/ui/src/components/inline-input.tsx | 15 +- packages/ui/src/components/markdown.css | 52 +- packages/ui/src/components/markdown.tsx | 6 +- packages/ui/src/components/message-part.css | 661 +++++++++++++++++------ packages/ui/src/components/message-part.tsx | 797 +++++++++++++++++----------- packages/ui/src/components/session-turn.css | 591 +++------------------ packages/ui/src/components/session-turn.tsx | 769 ++++++--------------------- packages/ui/src/components/text-shimmer.css | 43 ++ packages/ui/src/components/text-shimmer.tsx | 36 ++ packages/ui/src/context/data.tsx | 4 - packages/ui/src/i18n/ar.ts | 5 +- packages/ui/src/i18n/br.ts | 5 +- packages/ui/src/i18n/bs.ts | 5 +- packages/ui/src/i18n/da.ts | 5 +- packages/ui/src/i18n/de.ts | 5 +- packages/ui/src/i18n/en.ts | 7 +- packages/ui/src/i18n/es.ts | 5 +- packages/ui/src/i18n/fr.ts | 5 +- packages/ui/src/i18n/ja.ts | 5 +- packages/ui/src/i18n/ko.ts | 5 +- packages/ui/src/i18n/no.ts | 5 +- packages/ui/src/i18n/pl.ts | 5 +- packages/ui/src/i18n/ru.ts | 5 +- packages/ui/src/i18n/th.ts | 5 +- packages/ui/src/i18n/zh.ts | 5 +- packages/ui/src/i18n/zht.ts | 5 +- packages/ui/src/styles/animations.css | 11 + packages/ui/src/styles/index.css | 1 + packages/ui/src/styles/theme.css | 1 + 41 files changed, 1643 insertions(+), 1718 deletions(-) create mode 100644 packages/ui/src/components/text-shimmer.css create mode 100644 packages/ui/src/components/text-shimmer.tsx (limited to 'packages/ui') diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 2c6bfeb67..1240ad7b9 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -4,15 +4,44 @@ display: flex; align-items: center; align-self: stretch; - gap: 20px; - justify-content: space-between; + gap: 0px; + justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: 100%; + width: auto; display: flex; align-items: center; align-self: stretch; - gap: 20px; + gap: 8px; + } + + [data-slot="basic-tool-tool-indicator"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-slot="basic-tool-tool-spinner"] { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } } [data-slot="icon-svg"] { @@ -20,16 +49,17 @@ } [data-slot="basic-tool-tool-info"] { - flex-grow: 1; + flex: 0 1 auto; min-width: 0; + font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { - width: 100%; + width: auto; display: flex; align-items: center; gap: 8px; - justify-content: space-between; + justify-content: flex-start; } [data-slot="basic-tool-tool-info-main"] { @@ -43,16 +73,21 @@ [data-slot="basic-tool-tool-title"] { flex-shrink: 0; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-base); + color: var(--text-strong); &.capitalize { text-transform: capitalize; } + + &.agent-title { + color: var(--text-strong); + font-weight: var(--font-weight-medium); + } } [data-slot="basic-tool-tool-subtitle"] { @@ -62,12 +97,12 @@ text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-weak); + color: var(--text-base); &.clickable { cursor: pointer; @@ -78,6 +113,26 @@ color: var(--text-base); } } + + &.subagent-link { + color: var(--text-interactive-base); + text-decoration: none; + text-underline-offset: 2px; + font-weight: var(--font-weight-regular); + + &:hover { + color: var(--text-interactive-base); + text-decoration: underline; + } + + &:active { + color: var(--text-interactive-base); + } + + &:visited { + color: var(--text-interactive-base); + } + } } [data-slot="basic-tool-tool-arg"] { @@ -87,11 +142,11 @@ text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-weak); + color: var(--text-base); } } diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 725a7d0d6..5cc4367a6 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,6 +1,7 @@ import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { Collapsible } from "./collapsible" -import { Icon, IconProps } from "./icon" +import type { IconProps } from "./icon" +import { TextShimmer } from "./text-shimmer" export type TriggerTitle = { title: string @@ -22,6 +23,7 @@ export interface BasicToolProps { icon: IconProps["name"] trigger: TriggerTitle | JSX.Element children?: JSX.Element + status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean @@ -31,22 +33,23 @@ export interface BasicToolProps { export function BasicTool(props: BasicToolProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) + const pending = () => props.status === "pending" || props.status === "running" createEffect(() => { if (props.forceOpen) setOpen(true) }) const handleOpenChange = (value: boolean) => { + if (pending()) return if (props.locked && !value) return setOpen(value) } return ( - +
-
@@ -59,41 +62,45 @@ export function BasicTool(props: BasicToolProps) { [trigger().titleClass ?? ""]: !!trigger().titleClass, }} > - {trigger().title} + + + - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - + + + { + if (props.onSubtitleClick) { + e.stopPropagation() + props.onSubtitleClick() + } + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + +
- {trigger().action} + {trigger().action}
)} @@ -101,7 +108,7 @@ export function BasicTool(props: BasicToolProps) {
- + @@ -113,6 +120,6 @@ export function BasicTool(props: BasicToolProps) { ) } -export function GenericTool(props: { tool: string; hideDetails?: boolean }) { - return +export function GenericTool(props: { tool: string; status?: string; hideDetails?: boolean }) { + return } diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index d9b345923..465ebd880 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -170,3 +170,15 @@ outline: none; } } + +[data-component="button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) { + background-color: var(--surface-raised-base-active); +} + +[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] { + background-color: var(--surface-base-active); +} + +[data-component="button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) { + background-color: var(--surface-base-active); +} diff --git a/packages/ui/src/components/checkbox.css b/packages/ui/src/components/checkbox.css index 8ca73d349..82994bb88 100644 --- a/packages/ui/src/components/checkbox.css +++ b/packages/ui/src/components/checkbox.css @@ -1,6 +1,6 @@ [data-component="checkbox"] { display: flex; - align-items: center; + align-items: var(--checkbox-align, center); gap: 12px; cursor: default; @@ -23,6 +23,7 @@ width: 16px; height: 16px; padding: 2px; + margin-top: var(--checkbox-offset, 0px); aspect-ratio: 1; flex-shrink: 0; border-radius: var(--radius-sm); diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index f23746bf1..88f37ea7f 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -2,23 +2,44 @@ width: 100%; display: flex; flex-direction: column; - background-color: var(--surface-inset-base); - border: 1px solid var(--border-weaker-base); + background-color: transparent; + border: none; transition: background-color 0.15s ease; border-radius: var(--radius-md); overflow: clip; + &.tool-collapsible { + gap: 8px; + } + [data-slot="collapsible-trigger"] { width: 100%; display: flex; height: 32px; - padding: 6px 8px 6px 12px; + padding: 0; align-items: center; align-self: stretch; cursor: default; user-select: none; color: var(--text-base); + [data-slot="collapsible-arrow"] { + opacity: 0; + transition: opacity 0.15s ease; + } + + [data-slot="collapsible-arrow-icon"] { + display: none; + } + + [data-slot="collapsible-arrow-icon"][data-direction="right"] { + display: inline-flex; + } + + &:hover [data-slot="collapsible-arrow"] { + opacity: 1; + } + /* text-12-medium */ font-family: var(--font-family-sans); font-size: var(--font-size-small); @@ -48,6 +69,20 @@ } } + [data-slot="collapsible-trigger"][aria-expanded="true"] { + [data-slot="collapsible-arrow"] { + opacity: 1; + } + + [data-slot="collapsible-arrow-icon"][data-direction="right"] { + display: none; + } + + [data-slot="collapsible-arrow-icon"][data-direction="down"] { + display: inline-flex; + } + } + [data-slot="collapsible-content"] { overflow: hidden; /* animation: slideUp 250ms ease-out; */ diff --git a/packages/ui/src/components/collapsible.tsx b/packages/ui/src/components/collapsible.tsx index 903afc308..548088287 100644 --- a/packages/ui/src/components/collapsible.tsx +++ b/packages/ui/src/components/collapsible.tsx @@ -34,7 +34,12 @@ function CollapsibleContent(props: ComponentProps) { function CollapsibleArrow(props?: ComponentProps<"div">) { return (
- + + + + + +
) } diff --git a/packages/ui/src/components/diff-changes.css b/packages/ui/src/components/diff-changes.css index be3cca885..1d1ed8c9d 100644 --- a/packages/ui/src/components/diff-changes.css +++ b/packages/ui/src/components/diff-changes.css @@ -7,7 +7,7 @@ [data-slot="diff-changes-additions"] { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); @@ -19,7 +19,7 @@ [data-slot="diff-changes-deletions"] { font-family: var(--font-family-mono); font-feature-settings: var(--font-family-mono--font-feature-settings); - font-size: var(--font-size-small); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-regular); line-height: var(--line-height-large); @@ -31,11 +31,12 @@ [data-component="diff-changes"][data-variant="bars"] { width: 18px; + height: 14px; flex-shrink: 0; svg { display: block; width: 100%; - height: auto; + height: 100%; } } diff --git a/packages/ui/src/components/diff-changes.tsx b/packages/ui/src/components/diff-changes.tsx index 9e29dbb2b..0794b9728 100644 --- a/packages/ui/src/components/diff-changes.tsx +++ b/packages/ui/src/components/diff-changes.tsx @@ -96,10 +96,10 @@ export function DiffChanges(props: {
- + - {(color, i) => } + {(color, i) => } diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css index cb5f61f2b..e6e00e407 100644 --- a/packages/ui/src/components/icon-button.css +++ b/packages/ui/src/components/icon-button.css @@ -143,3 +143,39 @@ outline: none; } } + +@media (prefers-reduced-motion: no-preference) { + [data-component="icon-button"][data-icon="stop"] [data-slot="icon-svg"] rect { + transform-origin: center; + transform-box: fill-box; + animation: stop-pulse 1.8s ease-in-out infinite; + } +} + +@keyframes stop-pulse { + 0%, + 100% { + transform: scale(0.95); + } + 50% { + transform: scale(1.12); + } +} + +[data-component="icon-button"].titlebar-icon { + width: 32px; + height: 24px; + aspect-ratio: auto; +} + +[data-component="icon-button"].titlebar-icon[data-variant="ghost"]:hover:not(:disabled) { + background-color: var(--surface-raised-base-active); +} + +[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"] { + background-color: var(--surface-base-active); +} + +[data-component="icon-button"].titlebar-icon[data-variant="ghost"][aria-expanded="true"]:hover:not(:disabled) { + background-color: var(--surface-base-active); +} diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx index 89b8b5506..89ab00fcd 100644 --- a/packages/ui/src/components/icon-button.tsx +++ b/packages/ui/src/components/icon-button.tsx @@ -15,6 +15,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) { `, archive: ``, "bubble-5": ``, + prompt: ``, brain: ``, "bullet-list": ``, "check-small": ``, "chevron-down": ``, - "chevron-right": ``, + "chevron-left": ``, + "chevron-right": ``, "chevron-grabber-vertical": ``, "chevron-double-right": ``, "circle-x": ``, @@ -28,9 +30,12 @@ const icons = { eye: ``, enter: ``, folder: ``, + "file-tree": ``, + "file-tree-active": ``, "magnifying-glass": ``, "plus-small": ``, plus: ``, + "new-session": ``, "pencil-line": ``, mcp: ``, glasses: ``, @@ -41,9 +46,9 @@ const icons = { "layout-left": ``, "layout-left-partial": ``, "layout-left-full": ``, - "layout-right": ``, - "layout-right-partial": ``, - "layout-right-full": ``, + "layout-right": ``, + "layout-right-partial": ``, + "layout-right-full": ``, "square-arrow-top-right": ``, "speech-bubble": ``, comment: ``, diff --git a/packages/ui/src/components/inline-input.css b/packages/ui/src/components/inline-input.css index 1d8a00e08..503dfe286 100644 --- a/packages/ui/src/components/inline-input.css +++ b/packages/ui/src/components/inline-input.css @@ -12,6 +12,6 @@ &:focus { outline: none; - box-shadow: 0 0 0 1px var(--border-interactive-focus); + box-shadow: var(--inline-input-shadow, 0 0 0 1px var(--border-interactive-focus)); } } diff --git a/packages/ui/src/components/inline-input.tsx b/packages/ui/src/components/inline-input.tsx index 72711a197..d0a603ee3 100644 --- a/packages/ui/src/components/inline-input.tsx +++ b/packages/ui/src/components/inline-input.tsx @@ -6,6 +6,17 @@ export type InlineInputProps = ComponentProps<"input"> & { } export function InlineInput(props: InlineInputProps) { - const [local, others] = splitProps(props, ["class", "width"]) - return + const [local, others] = splitProps(props, ["class", "width", "style"]) + + const style = () => { + if (!local.style) return { width: local.width } + if (typeof local.style === "string") { + if (!local.width) return local.style + return `${local.style};width:${local.width}` + } + if (!local.width) return local.style + return { ...local.style, width: local.width } + } + + return } diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index 27c8f238d..1fe11a7de 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -3,7 +3,7 @@ min-width: 0; max-width: 100%; overflow-wrap: break-word; - color: var(--text-base); + color: var(--text-strong); font-family: var(--font-family-sans); font-size: var(--font-size-base); /* 14px */ line-height: var(--line-height-x-large); @@ -117,7 +117,7 @@ .shiki { font-size: 13px; padding: 8px 12px; - border-radius: 4px; + border-radius: 6px; border: 0.5px solid var(--border-weak-base); } @@ -127,11 +127,55 @@ [data-slot="markdown-copy-button"] { position: absolute; - top: 8px; - right: 8px; + top: 4px; + right: 4px; opacity: 0; transition: opacity 0.15s ease; z-index: 1; + + &::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 4px); + transform: translateX(-50%); + z-index: 1000; + + max-width: 320px; + border-radius: var(--radius-sm); + background: var(--surface-float-base); + color: var(--text-invert-strong); + padding: 2px 8px; + border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); + box-shadow: var(--shadow-md); + + pointer-events: none; + white-space: nowrap; + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + opacity: 0; + transition: opacity 0.15s ease; + } + } + + [data-slot="markdown-copy-button"]:hover::after, + [data-slot="markdown-copy-button"]:focus-visible::after { + opacity: 1; + } + + [data-slot="markdown-copy-button"][data-variant="secondary"] { + box-shadow: none; + border: 1px solid var(--border-weak-base); + } + + [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { + color: var(--icon-base); } [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 4c3d56284..be43eca81 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -85,7 +85,7 @@ function createCopyButton(labels: CopyLabels) { button.setAttribute("data-size", "small") button.setAttribute("data-slot", "markdown-copy-button") button.setAttribute("aria-label", labels.copy) - button.setAttribute("title", labels.copy) + button.setAttribute("data-tooltip", labels.copy) button.appendChild(createIcon(iconPaths.copy, "copy-icon")) button.appendChild(createIcon(iconPaths.check, "check-icon")) return button @@ -95,12 +95,12 @@ function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boo if (copied) { button.setAttribute("data-copied", "true") button.setAttribute("aria-label", labels.copied) - button.setAttribute("title", labels.copied) + button.setAttribute("data-tooltip", labels.copied) return } button.removeAttribute("data-copied") button.setAttribute("aria-label", labels.copy) - button.setAttribute("title", labels.copy) + button.setAttribute("data-tooltip", labels.copy) } function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9a18810dc..ebd0e7d58 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -14,15 +14,27 @@ font-weight: var(--font-weight-regular); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); - color: var(--text-base); + color: var(--text-strong); display: flex; flex-direction: column; + align-items: flex-end; + align-self: stretch; + width: 100%; + max-width: 100%; gap: 8px; + &[data-interrupted] { + color: var(--text-weak); + } + [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; + justify-content: flex-end; gap: 8px; + width: fit-content; + max-width: min(82%, 64ch); + margin-left: auto; } [data-slot="user-message-attachment"] { @@ -71,15 +83,24 @@ } } + [data-slot="user-message-body"] { + width: fit-content; + max-width: min(82%, 64ch); + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + } + [data-slot="user-message-text"] { - position: relative; + display: inline-block; white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--surface-weak); + background: var(--surface-base); border: 1px solid var(--border-weak-base); padding: 8px 12px; - border-radius: 4px; + border-radius: 6px; [data-highlight="file"] { color: var(--syntax-property); @@ -89,19 +110,36 @@ color: var(--syntax-type); } - [data-slot="user-message-copy-wrapper"] { - position: absolute; - top: 7px; - right: 7px; - opacity: 0; - transition: opacity 0.15s ease; - } + max-width: 100%; + } - &:hover [data-slot="user-message-copy-wrapper"] { - opacity: 1; + [data-slot="user-message-copy-wrapper"] { + min-height: 24px; + margin-top: 4px; + display: flex; + align-items: center; + justify-content: flex-end; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; } } + [data-slot="user-message-copy-wrapper"][data-interrupted] { + gap: 12px; + } + + &:hover [data-slot="user-message-copy-wrapper"], + &:focus-within [data-slot="user-message-copy-wrapper"] { + opacity: 1; + pointer-events: auto; + } + .text-text-strong { color: var(--text-strong); } @@ -115,21 +153,36 @@ width: 100%; [data-slot="text-part-body"] { - position: relative; - margin-top: 32px; + margin-top: 0; } [data-slot="text-part-copy-wrapper"] { - position: absolute; - top: -28px; - right: 8px; + min-height: 24px; + margin-top: 4px; + display: flex; + align-items: center; + justify-content: flex-start; opacity: 0; + pointer-events: none; transition: opacity 0.15s ease; - z-index: 1; + will-change: opacity; + + [data-component="tooltip-trigger"] { + display: inline-flex; + width: fit-content; + } } - [data-slot="text-part-body"]:hover [data-slot="text-part-copy-wrapper"] { + [data-slot="text-part-copy-wrapper"][data-interrupted] { + width: 100%; + justify-content: flex-end; + gap: 12px; + } + + &:hover [data-slot="text-part-copy-wrapper"], + &:focus-within [data-slot="text-part-copy-wrapper"] { opacity: 1; + pointer-events: auto; } [data-component="markdown"] { @@ -146,7 +199,7 @@ [data-component="markdown"] { margin-top: 24px; - font-style: italic !important; + font-style: normal; p:has(strong) { margin-top: 24px; @@ -196,7 +249,8 @@ [data-component="tool-output"] { white-space: pre; - padding: 8px 12px; + padding: 0; + margin-bottom: 24px; height: fit-content; display: flex; flex-direction: column; @@ -238,6 +292,79 @@ } } +[data-slot="collapsible-content"]:has([data-component="edit-content"]), +[data-slot="collapsible-content"]:has([data-component="write-content"]), +[data-slot="collapsible-content"]:has([data-component="apply-patch-files"]) { + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + overflow: hidden; +} + +[data-component="bash-output"] { + width: 100%; + border: 1px solid var(--border-weak-base); + border-radius: 6px; + background: transparent; + position: relative; + overflow: hidden; + + [data-slot="bash-copy"] { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + } + + &:hover [data-slot="bash-copy"], + &:focus-within [data-slot="bash-copy"] { + opacity: 1; + pointer-events: auto; + } + + [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { + box-shadow: none; + border: 1px solid var(--border-weak-base); + } + + [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] [data-slot="icon-svg"] { + color: var(--icon-base); + } + + [data-slot="bash-scroll"] { + width: 100%; + overflow-y: auto; + overflow-x: hidden; + max-height: 240px; + + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + + [data-slot="bash-pre"] { + margin: 0; + padding: 12px; + } + + [data-slot="bash-pre"] code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: 13px; + line-height: var(--line-height-large); + white-space: pre-wrap; + overflow-wrap: anywhere; + } +} + +[data-slot="collapsible-content"]:has([data-component="edit-content"]) [data-component="edit-content"], +[data-slot="collapsible-content"]:has([data-component="write-content"]) [data-component="write-content"] { + border-top: none; +} + [data-component="edit-trigger"], [data-component="write-trigger"] { display: flex; @@ -258,9 +385,9 @@ flex-shrink: 0; display: flex; align-items: center; - gap: 4px; + gap: 8px; font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: 14px; font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); @@ -268,18 +395,37 @@ color: var(--text-base); } + [data-slot="message-part-title-spinner"] { + margin-left: 4px; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-weak); + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + [data-slot="message-part-title-text"] { text-transform: capitalize; + color: var(--text-strong); } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ + font-weight: var(--font-weight-regular); } [data-slot="message-part-path"] { display: flex; flex-grow: 1; min-width: 0; + font-weight: var(--font-weight-regular); } [data-slot="message-part-directory"] { @@ -344,12 +490,19 @@ } [data-component="todos"] { - padding: 10px 12px 24px 48px; + padding: 10px 0 24px 0; display: flex; flex-direction: column; gap: 8px; + [data-component="checkbox"] { + --checkbox-align: flex-start; + --checkbox-offset: 0.5px; + } + [data-slot="message-part-todo-content"] { + line-height: var(--line-height-normal); + &[data-completed="completed"] { text-decoration: line-through; color: var(--text-weaker); @@ -357,41 +510,55 @@ } } -[data-component="task-tools"] { - padding: 8px 12px; +[data-component="context-tool-group-trigger"] { + width: 100%; + min-height: 24px; display: flex; - flex-direction: column; - gap: 6px; + align-items: center; + justify-content: flex-start; + gap: 0px; + cursor: pointer; - [data-slot="task-tool-item"] { + [data-slot="context-tool-group-title"] { + min-width: 0; display: flex; align-items: center; gap: 8px; - color: var(--text-weak); - - [data-slot="icon-svg"] { - flex-shrink: 0; - color: var(--icon-weak); - } - } - - [data-slot="task-tool-title"] { font-family: var(--font-family-sans); - font-size: var(--font-size-small); + font-size: 14px; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); - color: var(--text-weak); + color: var(--text-strong); } - [data-slot="task-tool-subtitle"] { - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - color: var(--text-weaker); + [data-slot="context-tool-group-label"] { + flex-shrink: 0; + } + + [data-slot="context-tool-group-summary"] { + flex-shrink: 1; + min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-weight: var(--font-weight-regular); + color: var(--text-base); + } + + [data-slot="collapsible-arrow"] { + color: var(--icon-weaker); + } +} + +[data-component="context-tool-group-list"] { + padding: 6px 0 4px 0; + display: flex; + flex-direction: column; + gap: 2px; + + [data-slot="context-tool-group-item"] { + min-width: 0; + padding: 6px 0; } } @@ -549,170 +716,322 @@ } [data-component="question-prompt"] { + position: relative; display: flex; flex-direction: column; - padding: 12px; - background-color: var(--surface-inset-base); - border-radius: 0 0 6px 6px; - gap: 12px; + gap: 0; + min-height: 0; + max-height: var(--question-prompt-max-height, 100dvh); - [data-slot="question-tabs"] { + [data-slot="question-body"] { display: flex; - gap: 4px; - flex-wrap: wrap; + flex-direction: column; + gap: 16px; + flex: 1; + min-height: 0; + padding: 8px 8px 0; + background-color: var(--surface-raised-stronger-non-alpha); + border-radius: 12px; + box-shadow: var(--shadow-xs-border); + overflow: clip; + position: relative; + z-index: 10; + } - [data-slot="question-tab"] { - padding: 4px 12px; - font-size: 13px; - border-radius: 4px; - background-color: var(--surface-base); - color: var(--text-base); - border: none; - cursor: pointer; - transition: - color 0.15s, - background-color 0.15s; + [data-slot="question-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 10px; + } - &:hover { - background-color: var(--surface-base-hover); - } + [data-slot="question-header-title"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + min-width: 0; + } - &[data-active="true"] { - background-color: var(--surface-raised-base); - } + [data-slot="question-progress"] { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } - &[data-answered="true"] { - color: var(--text-strong); - } + [data-slot="question-progress-segment"] { + width: 16px; + height: 16px; + padding: 0; + border: 0; + background: transparent; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + touch-action: manipulation; + + &::after { + content: ""; + width: 16px; + height: 2px; + border-radius: 999px; + background-color: var(--icon-weak-base); + transition: background-color 0.2s ease; + } + + &[data-active="true"]::after { + background-color: var(--icon-strong-base); + } + + &[data-answered="true"]::after { + background-color: var(--icon-interactive-base); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; } } [data-slot="question-content"] { display: flex; flex-direction: column; - gap: 8px; + gap: 4px; + flex: 1; + min-height: 0; + } - [data-slot="question-text"] { - font-size: 14px; - color: var(--text-base); - line-height: 1.5; - } + [data-slot="question-text"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + padding: 0 10px; + } + + [data-slot="question-hint"] { + font-family: var(--font-family-sans); + font-size: 13px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-weak); + padding: 0 10px; } [data-slot="question-options"] { display: flex; flex-direction: column; - gap: 4px; + gap: 6px; + margin-top: 12px; + padding: 1px 1px 8px; + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; - [data-slot="question-option"] { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - padding: 8px 12px; - background-color: var(--surface-base); - border: 1px solid var(--border-weaker-base); - border-radius: 6px; - cursor: pointer; - text-align: left; - width: 100%; - transition: - background-color 0.15s, - border-color 0.15s; - position: relative; - - &:hover { - background-color: var(--surface-base-hover); - border-color: var(--border-default); - } + &::-webkit-scrollbar { + display: none; + } + } - &[data-picked="true"] { - [data-component="icon"] { - position: absolute; - right: 12px; - top: 50%; - transform: translateY(-50%); - color: var(--text-strong); - } - } + [data-slot="question-option"] { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 8px 8px 10px; + background-color: var(--surface-raised-stronger-non-alpha); + border: 1px solid var(--border-weak-base); + border-radius: 6px; + box-shadow: none; + text-align: left; + width: 100%; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; + + &:hover:not([data-picked="true"]) { + background-color: var(--background-base); + } - [data-slot="option-label"] { - font-size: 14px; - color: var(--text-base); - font-weight: 500; - } + &[data-picked="true"] { + background-color: var(--surface-interactive-weak); + border-color: transparent; + box-shadow: var(--shadow-xs-border-hover); + } - [data-slot="option-description"] { - font-size: 12px; - color: var(--text-weak); - } + &:disabled { + cursor: not-allowed; + opacity: 0.6; } + } - [data-slot="custom-input-form"] { - display: flex; - gap: 8px; - padding: 8px 0; - align-items: stretch; - - [data-slot="custom-input"] { - flex: 1; - padding: 8px 12px; - font-size: 14px; - border: 1px solid var(--border-default); - border-radius: 6px; - background-color: var(--surface-base); - color: var(--text-base); - outline: none; - - &:focus { - border-color: var(--border-focus); - } + [data-slot="question-option-check"] { + display: inline-flex; + transform: translateY(2px); + } - &::placeholder { - color: var(--text-weak); - } + [data-slot="question-option-box"] { + width: 16px; + height: 16px; + padding: 2px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-weak-base); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background-color: transparent; + transition: + background-color 0.15s ease, + border-color 0.15s ease; + + [data-component="icon"] { + opacity: 0; + color: var(--icon-base); + } + + &[data-type="radio"] { + border-radius: 999px; + } + + [data-slot="question-option-radio-dot"] { + width: 6px; + height: 6px; + border-radius: 999px; + background-color: var(--background-stronger); + opacity: 0; + } + + &[data-picked="true"] { + border-color: var(--icon-interactive-base); + + [data-component="icon"] { + opacity: 1; + color: var(--icon-invert-base); } - [data-component="button"] { - height: auto; + &[data-type="checkbox"] { + background-color: var(--icon-interactive-base); + } + + &[data-type="radio"] { + background-color: var(--icon-interactive-base); + [data-slot="question-option-radio-dot"] { + opacity: 1; + } } } } - [data-slot="question-review"] { + [data-slot="question-option-main"] { display: flex; flex-direction: column; - gap: 12px; + gap: 2px; + min-width: 0; + flex: 1; + } - [data-slot="review-title"] { - display: none; + [data-slot="option-label"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + color: var(--text-strong); + } + + [data-slot="option-description"] { + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-base); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-slot="question-option"][data-custom="true"] { + [data-slot="option-description"] { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; } + } - [data-slot="review-item"] { - display: flex; - flex-direction: column; - gap: 2px; - font-size: 13px; + [data-slot="question-custom"] { + display: flex; + flex-direction: column; + gap: 8px; + } - [data-slot="review-label"] { - color: var(--text-weak); - } + [data-slot="question-custom-input-wrap"] { + padding-left: 36px; + } + + [data-slot="question-custom-input"] { + width: 100%; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + outline: none; + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + color: var(--text-base); + min-width: 0; + cursor: text; + resize: none; + overflow: hidden; + overflow-wrap: anywhere; - [data-slot="review-value"] { - color: var(--text-strong); + &::placeholder { + color: var(--text-weak); + } - &[data-answered="false"] { - color: var(--text-weak); - } - } + &:focus-visible { + outline: 1px solid var(--border-interactive-base); + outline-offset: 2px; + border-radius: var(--radius-xs); } + + &:disabled { + opacity: 0.6; + } + } + + [data-slot="question-footer"] { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 32px 8px 8px; + background-color: var(--background-base); + border: 1px solid var(--border-weak-base); + border-radius: 12px; + overflow: clip; + margin-top: -24px; + position: relative; + z-index: 0; } - [data-slot="question-actions"] { + [data-slot="question-footer-actions"] { display: flex; align-items: center; gap: 8px; - justify-content: flex-end; } } @@ -720,7 +1039,7 @@ display: flex; flex-direction: column; gap: 12px; - padding: 8px 12px; + padding: 8px 0; [data-slot="question-answer-item"] { display: flex; @@ -746,18 +1065,13 @@ [data-component="apply-patch-file"] { display: flex; flex-direction: column; - border-top: 1px solid var(--border-weaker-base); - - &:first-child { - border-top: 1px solid var(--border-weaker-base); - } [data-slot="apply-patch-file-header"] { display: flex; align-items: center; gap: 8px; padding: 8px 12px; - background-color: var(--surface-inset-base); + background-color: transparent; } [data-slot="apply-patch-file-action"] { @@ -799,7 +1113,12 @@ } } +[data-component="apply-patch-file"] + [data-component="apply-patch-file"] { + border-top: 1px solid var(--border-weaker-base); +} + [data-component="apply-patch-file-diff"] { + border-top: 1px solid var(--border-weaker-base); max-height: 420px; overflow-y: auto; scrollbar-width: none; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 3f61b3186..6c2b2eaec 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -37,18 +37,17 @@ import { BasicTool } from "./basic-tool" import { GenericTool } from "./basic-tool" import { Button } from "./button" import { Card } from "./card" +import { Collapsible } from "./collapsible" import { Icon } from "./icon" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { findLast } from "@opencode-ai/util/array" import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" -import { createAutoScroll } from "../hooks" -import { createResizeObserver } from "@solid-primitives/resize-observer" +import { TextShimmer } from "./text-shimmer" interface Diagnostic { range: { @@ -92,6 +91,8 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { export interface MessageProps { message: MessageType parts: PartType[] + showAssistantCopyPartID?: string | null + interrupted?: boolean } export interface MessagePartProps { @@ -99,6 +100,7 @@ export interface MessagePartProps { message: MessageType hideDetails?: boolean defaultOpen?: boolean + showAssistantCopyPartID?: string | null } export type PartComponent = Component @@ -107,12 +109,6 @@ export const PART_MAPPING: Record = {} const TEXT_RENDER_THROTTLE_MS = 100 -function same(a: readonly T[], b: readonly T[]) { - if (a === b) return true - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - function createThrottledValue(getValue: () => string) { const [value, setValue] = createSignal(getValue()) let timeout: ReturnType | undefined @@ -157,22 +153,6 @@ function getDirectory(path: string | undefined) { return relativizeProjectPaths(_getDirectory(path), data.directory) } -export function getSessionToolParts(store: ReturnType["store"], sessionId: string): ToolPart[] { - const messages = store.message[sessionId]?.filter((m) => m.role === "assistant") - if (!messages) return [] - - const parts: ToolPart[] = [] - for (const m of messages) { - const msgParts = store.part[m.id] - if (msgParts) { - for (const p of msgParts) { - if (p && p.type === "tool") parts.push(p as ToolPart) - } - } - } - return parts -} - import type { IconProps } from "./icon" export type ToolInfo = { @@ -269,6 +249,86 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { } } +const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) + +function isContextGroupTool(part: PartType): part is ToolPart { + return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) +} + +function contextToolDetail(part: ToolPart): string | undefined { + const info = getToolInfo(part.tool, part.state.input ?? {}) + if (info.subtitle) return info.subtitle + if (part.state.status === "error") return part.state.error + if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) + return part.state.title + const description = part.state.input?.description + if (typeof description === "string") return description + return undefined +} + +function contextToolTrigger(part: ToolPart, i18n: ReturnType) { + const input = (part.state.input ?? {}) as Record + const path = typeof input.path === "string" ? input.path : "/" + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const pattern = typeof input.pattern === "string" ? input.pattern : undefined + const include = typeof input.include === "string" ? input.include : undefined + const offset = typeof input.offset === "number" ? input.offset : undefined + const limit = typeof input.limit === "number" ? input.limit : undefined + + switch (part.tool) { + case "read": { + const args: string[] = [] + if (offset !== undefined) args.push("offset=" + offset) + if (limit !== undefined) args.push("limit=" + limit) + return { + title: i18n.t("ui.tool.read"), + subtitle: filePath ? getFilename(filePath) : "", + args, + } + } + case "list": + return { + title: i18n.t("ui.tool.list"), + subtitle: getDirectory(path), + } + case "glob": + return { + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(path), + args: pattern ? ["pattern=" + pattern] : [], + } + case "grep": { + const args: string[] = [] + if (pattern) args.push("pattern=" + pattern) + if (include) args.push("include=" + include) + return { + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(path), + args, + } + } + default: { + const info = getToolInfo(part.tool, input) + return { + title: info.title, + subtitle: info.subtitle || contextToolDetail(part), + args: [], + } + } + } +} + +function contextToolSummary(parts: ToolPart[]) { + const read = parts.filter((part) => part.tool === "read").length + const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length + const list = parts.filter((part) => part.tool === "list").length + return [ + read ? `${read} ${read === 1 ? "read" : "reads"}` : undefined, + search ? `${search} ${search === 1 ? "search" : "searches"}` : undefined, + list ? `${list} ${list === 1 ? "list" : "lists"}` : undefined, + ].filter((value): value is string => !!value) +} + export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -277,51 +337,178 @@ export function Message(props: MessageProps) { return ( - {(userMessage) => } + {(userMessage) => ( + + )} {(assistantMessage) => ( - + )} ) } -export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { - const emptyParts: PartType[] = [] - const filteredParts = createMemo( - () => - props.parts.filter((x) => { - return x.type !== "tool" || (x as ToolPart).tool !== "todoread" - }), - emptyParts, - { equals: same }, +export function AssistantMessageDisplay(props: { + message: AssistantMessage + parts: PartType[] + showAssistantCopyPartID?: string | null +}) { + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record = {} + const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => { + keys.push(key) + items[key] = item + } + + const parts = props.parts + let start = -1 + + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + const last = parts[end] + if (!first || !last) { + start = -1 + return + } + push(`context:${first.id}`, { + type: "context", + parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)), + }) + start = -1 + } + + parts.forEach((part, index) => { + if (isContextGroupTool(part)) { + if (start < 0) start = index + return + } + + flush(index - 1) + push(`part:${part.id}`, { type: "part", part }) + }) + + flush(parts.length - 1) + + return { keys, items } + }) + + return ( + + {(key) => { + const item = createMemo(() => grouped().items[key]) + return ( + + {(value) => { + const entry = value() + if (entry.type === "context") return + return ( + + ) + }} + + ) + }} + ) - return {(part) => } } -export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { - const dialog = useDialog() +function ContextToolGroup(props: { parts: ToolPart[] }) { const i18n = useI18n() - const [copied, setCopied] = createSignal(false) - const [expanded, setExpanded] = createSignal(false) - const [canExpand, setCanExpand] = createSignal(false) - let textRef: HTMLDivElement | undefined - - const updateCanExpand = () => { - const el = textRef - if (!el) return - if (expanded()) return - setCanExpand(el.scrollHeight > el.clientHeight + 2) - } + const [open, setOpen] = createSignal(false) + const pending = createMemo(() => + props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), + ) + const summary = createMemo(() => contextToolSummary(props.parts)) + const details = createMemo(() => summary().join(", ")) - createResizeObserver( - () => textRef, - () => { - updateCanExpand() - }, + return ( + + +
+ + Gathered context + + {details()} + + + } + > + + + + + + {details()} + + + + +
+ + +
+ + {(part) => { + const trigger = contextToolTrigger(part, i18n) + const running = part.state.status === "pending" || part.state.status === "running" + return ( +
+
+
+
+
+
+ + + + + + + {trigger.subtitle} + + + + {(arg) => {arg}} + + +
+
+
+
+
+
+ ) + }} +
+
+
+ ) +} + +export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[]; interrupted?: boolean }) { + const dialog = useDialog() + const i18n = useI18n() + const [copied, setCopied] = createSignal(false) const textPart = createMemo( () => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined, @@ -329,11 +516,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const text = createMemo(() => textPart()?.text || "") - createEffect(() => { - text() - updateCanExpand() - }) - const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? []) const attachments = createMemo(() => @@ -364,13 +546,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp setTimeout(() => setCopied(false), 2000) } - const toggleExpanded = () => { - if (!canExpand()) return - setExpanded((value) => !value) - } - return ( -
+
0}>
@@ -404,29 +581,25 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
-
(textRef = el)} onClick={toggleExpanded}> - - -
+
+
+ +
+
+ + + {i18n.t("ui.message.interrupted")} + + e.preventDefault()} onClick={(event) => { event.stopPropagation() @@ -491,6 +664,7 @@ export function Part(props: MessagePartProps) { message={props.message} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} + showAssistantCopyPartID={props.showAssistantCopyPartID} /> ) @@ -536,6 +710,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const i18n = useI18n() const part = props.part as ToolPart + if (part.tool === "todowrite" || part.tool === "todoread") return null + + const hideQuestion = createMemo( + () => part.tool === "question" && (part.state.status === "pending" || part.state.status === "running"), + ) const permission = createMemo(() => { const next = data.store.permission?.[props.message.sessionID]?.[0] @@ -604,65 +783,76 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = ToolRegistry.render(part.tool) ?? GenericTool return ( -
- - - {(error) => { - const cleaned = error().replace("Error: ", "") - const [title, ...rest] = cleaned.split(": ") - return ( - -
- - - -
-
{title}
- {rest.join(": ")} -
-
- - {cleaned} - -
-
-
- ) - }} -
- - - -
- -
-
- - - + +
+ + + {(error) => { + const cleaned = error().replace("Error: ", "") + if (part.tool === "question" && cleaned.includes("dismissed this question")) { + return ( +
+ + {i18n.t("ui.tool.questions")} dismissed + +
+ ) + } + const [title, ...rest] = cleaned.split(": ") + return ( + +
+ + + +
+
{title}
+ {rest.join(": ")} +
+
+ + {cleaned} + +
+
+
+ ) + }} +
+ + + +
+ +
+
+ + + +
-
-
- {(request) => } -
+ + {(request) => } +
+
) } @@ -670,8 +860,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const data = useData() const i18n = useI18n() const part = props.part as TextPart + const interrupted = createMemo( + () => + props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", + ) const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory) const throttledText = createThrottledValue(displayText) + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part.id + }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part.id + return isLastTextPart() + }) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -687,23 +893,30 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
-
+
+ +
+ + + {i18n.t("ui.message.interrupted")} + + e.preventDefault()} onClick={handleCopy} aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} />
-
+
) @@ -844,29 +1057,46 @@ ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() + const pending = createMemo(() => props.status === "pending" || props.status === "running") + const url = createMemo(() => { + const value = props.input.url + if (typeof value !== "string") return "" + return value + }) return ( - -
- ), - }} - > - - {(output) => ( -
- + trigger={ +
+ - )} - - + +
+ +
+
+
+ } + /> ) }, }) @@ -877,6 +1107,13 @@ ToolRegistry.register({ const data = useData() const i18n = useI18n() const childSessionId = () => props.metadata.sessionId as string | undefined + const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const description = createMemo(() => { + const value = props.input.description + if (typeof value === "string") return value + return undefined + }) + const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => { const sessionId = childSessionId() @@ -892,14 +1129,6 @@ ToolRegistry.register({ return `${path.slice(0, idx)}/session/${sessionId}` }) - createEffect(() => { - const sessionId = childSessionId() - if (!sessionId) return - const sync = data.syncSession - if (!sync) return - Promise.resolve(sync(sessionId)).catch(() => undefined) - }) - const handleLinkClick = (e: MouseEvent) => { const sessionId = childSessionId() const url = href() @@ -921,23 +1150,30 @@ ToolRegistry.register({ }, 50) } + const titleContent = () => + const trigger = () => (
- - {i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })} + + {titleContent()} - + {(url) => ( - - {props.input.description} + + {description()} )} - {props.input.description} + {description()} @@ -945,134 +1181,7 @@ ToolRegistry.register({
) - const childToolParts = createMemo(() => { - const sessionId = childSessionId() - if (!sessionId) return [] - return getSessionToolParts(data.store, sessionId) - }) - - const autoScroll = createAutoScroll({ - working: () => true, - overflowAnchor: "auto", - }) - - const childPermission = createMemo(() => { - const sessionId = childSessionId() - if (!sessionId) return undefined - const permissions = data.store.permission?.[sessionId] ?? [] - return permissions[0] - }) - - const childToolPart = createMemo(() => { - const perm = childPermission() - if (!perm || !perm.tool) return undefined - const sessionId = childSessionId() - if (!sessionId) return undefined - // Find the tool part that matches the permission's callID - const messages = data.store.message[sessionId] ?? [] - const message = findLast(messages, (m) => m.id === perm.tool!.messageID) - if (!message) return undefined - const parts = data.store.part[message.id] ?? [] - for (const part of parts) { - if (part.type === "tool" && (part as ToolPart).callID === perm.tool!.callID) { - return { part: part as ToolPart, message } - } - } - - return undefined - }) - - const respond = (response: "once" | "always" | "reject") => { - const perm = childPermission() - if (!perm || !data.respondToPermission) return - data.respondToPermission({ - sessionID: perm.sessionID, - permissionID: perm.id, - response, - }) - } - - const renderChildToolPart = () => { - const toolData = childToolPart() - if (!toolData) return null - const { part } = toolData - const render = ToolRegistry.render(part.tool) ?? GenericTool - // @ts-expect-error - const metadata = part.state?.metadata ?? {} - const input = part.state?.input ?? {} - return ( - - ) - } - - return ( -
- - - <> - }> - {renderChildToolPart()} - -
-
- - - -
-
- -
- - -
-
- - {(item) => { - const info = createMemo(() => getToolInfo(item.tool, item.state.input)) - const subtitle = createMemo(() => { - if (info().subtitle) return info().subtitle - if (item.state.status === "completed" || item.state.status === "running") { - return item.state.title - } - }) - return ( -
- - {info().title} - - {subtitle()} - -
- ) - }} -
-
-
-
-
-
-
- ) + return }, }) @@ -1080,6 +1189,21 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() + const text = createMemo(() => { + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "") + return `$ ${cmd}${out ? "\n\n" + out : ""}` + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = text() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( -
- +
+
+ + e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} + /> + +
+
+
+              {text()}
+            
+
) @@ -1106,6 +1248,7 @@ ToolRegistry.register({ const diffComponent = useDiffComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" return (
- {i18n.t("ui.messagePart.title.edit")} - {filename()} + + + + + + + {filename()} +
- +
{getDirectory(props.input.filePath!)}
- +
@@ -1159,6 +1308,7 @@ ToolRegistry.register({ const codeComponent = useCodeComponent() const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const filename = () => getFilename(props.input.filePath ?? "") + const pending = () => props.status === "pending" || props.status === "running" return (
- {i18n.t("ui.messagePart.title.write")} - {filename()} + + + + + + + {filename()} +
- +
{getDirectory(props.input.filePath!)}
@@ -1323,9 +1479,12 @@ ToolRegistry.register({ {(todo: Todo) => ( -
+ {todo.content} -
+
)}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 414e8a359..5d58f0f71 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -1,7 +1,5 @@ [data-component="session-turn"] { - --session-turn-sticky-height: 0px; - --sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 24px); - /* flex: 1; */ + --sticky-header-height: calc(var(--session-title-height, 0px) + 24px); height: 100%; min-height: 0; min-width: 0; @@ -30,580 +28,147 @@ min-width: 0; gap: 18px; overflow-anchor: none; - - [data-slot="session-turn-badge"] { - display: inline-flex; - align-items: center; - padding: 2px 6px; - border-radius: 4px; - font-family: var(--font-family-mono); - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-normal); - white-space: nowrap; - color: var(--text-base); - background: var(--surface-raised-base); - } - } - - [data-slot="session-turn-attachments"] { - width: 100%; - min-width: 0; - align-self: stretch; - } - - [data-slot="session-turn-sticky"] { - width: calc(100% + 9px); - position: sticky; - top: var(--session-title-height, 0px); - z-index: 20; - background-color: var(--background-stronger); - margin-left: -9px; - padding-left: 9px; - /* padding-bottom: 12px; */ - display: flex; - flex-direction: column; - gap: 12px; - - &::before { - content: ""; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - background-color: var(--background-stronger); - z-index: -1; - } - - &::after { - content: ""; - position: absolute; - top: 100%; - left: 0; - right: 0; - height: 32px; - background: linear-gradient(to bottom, var(--background-stronger), transparent); - pointer-events: none; - } - } - - [data-slot="session-turn-message-header"] { - display: flex; - align-items: center; - align-self: stretch; - height: 32px; } [data-slot="session-turn-message-content"] { margin-top: 0; + width: 100%; + min-width: 0; max-width: 100%; } - [data-component="user-message"] [data-slot="user-message-text"] { - max-height: var(--user-message-collapsed-height, 64px); - } - - [data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] { - max-height: none; - } - - [data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] { - padding-right: 36px; - padding-bottom: 28px; - } - - [data-component="user-message"][data-can-expand="true"]:not([data-expanded="true"]) - [data-slot="user-message-text"]::after { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 8px; - bottom: 0px; - background: - linear-gradient(to bottom, transparent, var(--surface-weak)), - linear-gradient(to bottom, transparent, var(--surface-weak)); - pointer-events: none; - } - - [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] { - display: none; - position: absolute; - bottom: 6px; - right: 6px; - padding: 0; - } - - [data-component="user-message"][data-can-expand="true"] - [data-slot="user-message-text"] - [data-slot="user-message-expand"], - [data-component="user-message"][data-expanded="true"] - [data-slot="user-message-text"] - [data-slot="user-message-expand"] { - display: inline-flex; + [data-slot="session-turn-thinking"] { + display: flex; align-items: center; - justify-content: center; - height: 22px; - width: 22px; - border: none; - border-radius: 6px; - background: transparent; - cursor: pointer; + gap: 8px; color: var(--text-weak); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + min-height: 20px; - [data-slot="icon-svg"] { - transition: transform 0.15s ease; + [data-component="spinner"] { + width: 16px; + height: 16px; } } - [data-component="user-message"][data-expanded="true"] - [data-slot="user-message-text"] - [data-slot="user-message-expand"] - [data-slot="icon-svg"] { - transform: rotate(180deg); - } - - [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover { - background: var(--surface-raised-base); - color: var(--text-base); - } - - [data-slot="session-turn-user-badges"] { - display: flex; - align-items: center; - gap: 6px; - padding-left: 16px; + .error-card { + color: var(--text-on-critical-base); + max-height: 240px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + overflow-y: auto; } - [data-slot="session-turn-message-title"] { + [data-slot="session-turn-assistant-content"] { width: 100%; - font-size: var(--font-size-large); - font-weight: 500; - color: var(--text-strong); - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - white-space: nowrap; - } - - [data-slot="session-turn-message-title"] h1 { - overflow: hidden; - text-overflow: ellipsis; - min-width: 0; - white-space: nowrap; - font-size: inherit; - font-weight: inherit; - } - - [data-slot="session-turn-typewriter"] { - overflow: hidden; - text-overflow: ellipsis; min-width: 0; - white-space: nowrap; - } - - [data-slot="session-turn-summary-section"] { - width: 100%; display: flex; flex-direction: column; - gap: 24px; - align-items: flex-start; align-self: stretch; - } - - [data-slot="session-turn-summary-header"] { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - align-self: stretch; - - [data-slot="session-turn-summary-title-row"] { - width: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - } - - [data-slot="session-turn-response"] { - width: 100%; - } - - [data-slot="session-turn-response-copy-wrapper"] { - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - } - - &:hover [data-slot="session-turn-response-copy-wrapper"], - &:focus-within [data-slot="session-turn-response-copy-wrapper"] { - opacity: 1; - pointer-events: auto; - } - - p { - font-size: var(--font-size-base); - line-height: var(--line-height-x-large); - } - } - - [data-slot="session-turn-summary-title"] { - font-size: 13px; - /* text-12-medium */ - font-weight: 500; - color: var(--text-weak); - } - - [data-slot="session-turn-markdown"], - [data-slot="session-turn-accordion"] [data-slot="accordion-content"] { - -webkit-user-select: text; - user-select: text; - } - - [data-slot="session-turn-markdown"] { - &[data-diffs="true"] { - font-size: 15px; - } - - &[data-fade="true"] > * { - animation: fadeUp 0.4s ease-out forwards; - opacity: 0; - - &:nth-child(1) { - animation-delay: 0.1s; - } - - &:nth-child(2) { - animation-delay: 0.2s; - } - - &:nth-child(3) { - animation-delay: 0.3s; - } - - &:nth-child(4) { - animation-delay: 0.4s; - } - - &:nth-child(5) { - animation-delay: 0.5s; - } - - &:nth-child(6) { - animation-delay: 0.6s; - } - - &:nth-child(7) { - animation-delay: 0.7s; - } - - &:nth-child(8) { - animation-delay: 0.8s; - } - - &:nth-child(9) { - animation-delay: 0.9s; - } - - &:nth-child(10) { - animation-delay: 1s; - } - - &:nth-child(11) { - animation-delay: 1.1s; - } - - &:nth-child(12) { - animation-delay: 1.2s; - } - - &:nth-child(13) { - animation-delay: 1.3s; - } - - &:nth-child(14) { - animation-delay: 1.4s; - } - - &:nth-child(15) { - animation-delay: 1.5s; - } - - &:nth-child(16) { - animation-delay: 1.6s; - } - - &:nth-child(17) { - animation-delay: 1.7s; - } - - &:nth-child(18) { - animation-delay: 1.8s; - } - - &:nth-child(19) { - animation-delay: 1.9s; - } - - &:nth-child(20) { - animation-delay: 2s; - } - - &:nth-child(21) { - animation-delay: 2.1s; - } - - &:nth-child(22) { - animation-delay: 2.2s; - } - - &:nth-child(23) { - animation-delay: 2.3s; - } - - &:nth-child(24) { - animation-delay: 2.4s; - } - - &:nth-child(25) { - animation-delay: 2.5s; - } - - &:nth-child(26) { - animation-delay: 2.6s; - } - - &:nth-child(27) { - animation-delay: 2.7s; - } - - &:nth-child(28) { - animation-delay: 2.8s; - } - - &:nth-child(29) { - animation-delay: 2.9s; - } - - &:nth-child(30) { - animation-delay: 3s; - } - } - } - - [data-slot="session-turn-summary-section"] { - position: relative; - - [data-slot="session-turn-summary-copy"] { - position: absolute; - top: 0; - right: 0; - opacity: 0; - transition: opacity 0.15s ease; - } + gap: 12px; - &:hover [data-slot="session-turn-summary-copy"] { - opacity: 1; + > :first-child > [data-component="markdown"]:first-child { + margin-top: 0; } } - [data-slot="session-turn-accordion"] { + [data-slot="session-turn-diffs"] { width: 100%; + min-width: 0; } - [data-component="sticky-accordion-header"] { - top: var(--sticky-header-height, 0px); - } - - [data-component="sticky-accordion-header"][data-expanded]::before, - [data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"]::before { - top: calc(-1 * var(--sticky-header-height, 0px)); - } - - [data-slot="session-turn-accordion-trigger-content"] { - display: flex; - align-items: center; - justify-content: space-between; + [data-component="session-turn-diffs-trigger"] { width: 100%; - gap: 20px; - - [data-expandable="false"] { - pointer-events: none; - } - } - - [data-slot="session-turn-file-info"] { - flex-grow: 1; display: flex; align-items: center; - gap: 20px; - min-width: 0; + justify-content: flex-start; + gap: 8px; + padding: 0; } - [data-slot="session-turn-file-icon"] { - flex-shrink: 0; - width: 16px; - height: 16px; + [data-slot="session-turn-diffs-title"] { + display: inline-flex; + align-items: baseline; + gap: 8px; } - [data-slot="session-turn-file-path"] { - display: flex; - flex-grow: 1; - min-width: 0; + [data-slot="session-turn-diffs-label"] { + color: var(--text-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); } - [data-slot="session-turn-directory"] { + [data-slot="session-turn-diffs-count"] { color: var(--text-base); - text-overflow: ellipsis; - overflow: hidden; - white-space: nowrap; - direction: rtl; - text-align: left; + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); } - [data-slot="session-turn-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="session-turn-accordion-actions"] { - flex-shrink: 0; - display: flex; - gap: 16px; + [data-slot="session-turn-diffs-meta"] { + display: inline-flex; align-items: center; - justify-content: flex-end; - } - - [data-slot="session-turn-accordion-content"] { - max-height: 240px; - /* max-h-60 */ - overflow-y: auto; - scrollbar-width: none; - } + gap: 8px; + flex-shrink: 0; - [data-slot="session-turn-accordion-content"]::-webkit-scrollbar { - display: none; + [data-component="diff-changes"][data-variant="bars"] { + transform: translateY(1px); + } } - [data-slot="session-turn-response-section"] { - width: calc(100% + 9px); - min-width: 0; - margin-left: -9px; - padding-left: 9px; + [data-component="session-turn-diffs-content"] { + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 12px; } - [data-slot="session-turn-collapsible"] { - gap: 32px; - overflow: visible; + [data-component="session-turn-diff"] { + border: 1px solid var(--border-weaker-base); + border-radius: var(--radius-md); + overflow: clip; } - [data-slot="session-turn-collapsible-trigger-content"] { - max-width: 100%; - min-width: 0; + [data-slot="session-turn-diff-header"] { display: flex; align-items: center; - gap: 8px; - color: var(--text-weak); - - [data-slot="session-turn-trigger-icon"] { - color: var(--icon-base); - } - - [data-component="spinner"] { - width: 12px; - height: 12px; - margin-right: 4px; - } - - [data-component="icon"] { - width: 14px; - height: 14px; - } + justify-content: space-between; + gap: 12px; + padding: 6px 10px; + border-bottom: 1px solid var(--border-weaker-base); } - [data-slot="session-turn-retry-message"] { - font-weight: 500; - color: var(--syntax-critical); + [data-slot="session-turn-diff-path"] { + display: inline-flex; min-width: 0; + align-items: baseline; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + line-height: var(--line-height-large); } - [data-slot="session-turn-retry-seconds"] { + [data-slot="session-turn-diff-directory"] { color: var(--text-weak); } - [data-slot="session-turn-retry-attempt"] { - color: var(--text-weak); - } - - [data-slot="session-turn-status-text"] { - overflow: hidden; - text-overflow: ellipsis; - } - - [data-slot="session-turn-details-text"] { - font-size: 13px; - /* text-12-medium */ - font-weight: 500; - } - - .error-card { - color: var(--text-on-critical-base); - max-height: 240px; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - overflow-y: auto; - } - - .retry-error-link, - .error-card-link { + [data-slot="session-turn-diff-filename"] { color: var(--text-strong); - text-decoration: underline; - } - - [data-slot="session-turn-collapsible-content-inner"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - align-self: stretch; - gap: 12px; - margin-left: 12px; - padding-left: 12px; - padding-right: 12px; - border-left: 1px solid var(--border-base); - - > :first-child > [data-component="markdown"]:first-child { - margin-top: 0; - } - } - - [data-slot="session-turn-permission-parts"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; + font-weight: var(--font-weight-medium); } - [data-slot="session-turn-question-parts"] { + [data-slot="session-turn-diff-view"] { + background-color: var(--surface-inset-base); width: 100%; min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; - } - - [data-slot="session-turn-answered-question-parts"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; } } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c03622105..a99cc8d03 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -1,31 +1,18 @@ -import { - AssistantMessage, - FilePart, - Message as MessageType, - Part as PartType, - type PermissionRequest, - type QuestionRequest, - TextPart, - ToolPart, -} from "@opencode-ai/sdk/v2/client" +import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client" import { useData } from "../context" -import { type UiI18nKey, type UiI18nParams, useI18n } from "../context/i18n" +import { useDiffComponent } from "../context/diff" import { Binary } from "@opencode-ai/util/binary" -import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js" -import { Message, Part } from "./message-part" -import { Markdown } from "./markdown" -import { IconButton } from "./icon-button" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { createMemo, createSignal, For, ParentProps, Show } from "solid-js" +import { Dynamic } from "solid-js/web" +import { Message } from "./message-part" import { Card } from "./card" -import { Button } from "./button" -import { Spinner } from "./spinner" -import { Tooltip } from "./tooltip" -import { createStore } from "solid-js/store" -import { DateTime, DurationUnit, Interval } from "luxon" +import { Collapsible } from "./collapsible" +import { DiffChanges } from "./diff-changes" +import { TextShimmer } from "./text-shimmer" import { createAutoScroll } from "../hooks" -import { createResizeObserver } from "@solid-primitives/resize-observer" - -type Translator = (key: UiI18nKey, params?: UiI18nParams) => string +import { useI18n } from "../context/i18n" function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) @@ -80,117 +67,42 @@ function unwrap(message: string) { return message } -function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { - if (!part) return undefined - - if (part.type === "tool") { - switch (part.tool) { - case "task": - return t("ui.sessionTurn.status.delegating") - case "todowrite": - case "todoread": - return t("ui.sessionTurn.status.planning") - case "read": - return t("ui.sessionTurn.status.gatheringContext") - case "list": - case "grep": - case "glob": - return t("ui.sessionTurn.status.searchingCodebase") - case "webfetch": - return t("ui.sessionTurn.status.searchingWeb") - case "edit": - case "write": - return t("ui.sessionTurn.status.makingEdits") - case "bash": - return t("ui.sessionTurn.status.runningCommands") - default: - return undefined - } - } - if (part.type === "reasoning") { - const text = part.text ?? "" - const match = text.trimStart().match(/^\*\*(.+?)\*\*/) - if (match) return t("ui.sessionTurn.status.thinkingWithTopic", { topic: match[1].trim() }) - return t("ui.sessionTurn.status.thinking") - } - if (part.type === "text") { - return t("ui.sessionTurn.status.gatheringThoughts") - } - return undefined -} - function same(a: readonly T[], b: readonly T[]) { if (a === b) return true if (a.length !== b.length) return false return a.every((x, i) => x === b[i]) } -function isAttachment(part: PartType | undefined) { - if (part?.type !== "file") return false - const mime = (part as FilePart).mime ?? "" - return mime.startsWith("image/") || mime === "application/pdf" -} - function list(value: T[] | undefined | null, fallback: T[]) { if (Array.isArray(value)) return value return fallback } -function AssistantMessageItem(props: { - message: AssistantMessage - responsePartId: string | undefined - hideResponsePart: boolean - hideReasoning: boolean - hidden?: () => readonly { messageID: string; callID: string }[] -}) { +const hidden = new Set(["todowrite", "todoread"]) + +function visible(part: PartType) { + if (part.type === "tool") { + if (hidden.has(part.tool)) return false + if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running" + return true + } + if (part.type === "text") return !!part.text?.trim() + if (part.type === "reasoning") return !!part.text?.trim() + return false +} + +function AssistantMessageItem(props: { message: AssistantMessage; showAssistantCopyPartID?: string | null }) { const data = useData() const emptyParts: PartType[] = [] const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) - const lastTextPart = createMemo(() => { - const parts = msgParts() - for (let i = parts.length - 1; i >= 0; i--) { - const part = parts[i] - if (part?.type === "text") return part as TextPart - } - return undefined - }) - - const filteredParts = createMemo(() => { - let parts = msgParts() - - if (props.hideReasoning) { - parts = parts.filter((part) => part?.type !== "reasoning") - } - - if (props.hideResponsePart) { - const responsePartId = props.responsePartId - if (responsePartId && responsePartId === lastTextPart()?.id) { - parts = parts.filter((part) => part?.id !== responsePartId) - } - } - - const hidden = props.hidden?.() ?? [] - if (hidden.length === 0) return parts - - const id = props.message.id - return parts.filter((part) => { - if (part?.type !== "tool") return true - const tool = part as ToolPart - return !hidden.some((h) => h.messageID === id && h.callID === tool.callID) - }) - }) - - return + return } export function SessionTurn( props: ParentProps<{ sessionID: string - sessionTitle?: string messageID: string lastUserMessageID?: string - stepsExpanded?: boolean - onStepsExpandedToggle?: () => void onUserInteracted?: () => void classes?: { root?: string @@ -199,16 +111,14 @@ export function SessionTurn( } }>, ) { - const i18n = useI18n() const data = useData() + const i18n = useI18n() + const diffComponent = useDiffComponent() const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] - const emptyFiles: FilePart[] = [] const emptyAssistant: AssistantMessage[] = [] - const emptyPermissions: PermissionRequest[] = [] - const emptyQuestions: QuestionRequest[] = [] - const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] + const emptyDiffs: FileDiff[] = [] const idle = { type: "idle" as const } const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) @@ -256,18 +166,22 @@ export function SessionTurn( return list(data.store.part?.[msg.id], emptyParts) }) - const attachmentParts = createMemo(() => { - const msgParts = parts() - if (msgParts.length === 0) return emptyFiles - return msgParts.filter((part) => isAttachment(part)) as FilePart[] - }) - - const stickyParts = createMemo(() => { - const msgParts = parts() - if (msgParts.length === 0) return emptyParts - if (attachmentParts().length === 0) return msgParts - return msgParts.filter((part) => !isAttachment(part)) + const diffs = createMemo(() => { + const files = message()?.summary?.diffs + if (!files?.length) return emptyDiffs + + const seen = new Set() + return files + .reduceRight((result, diff) => { + if (seen.has(diff.file)) return result + seen.add(diff.file) + result.push(diff) + return result + }, []) + .reverse() }) + const edited = createMemo(() => diffs().length) + const [open, setOpen] = createSignal(false) const assistantMessages = createMemo( () => { @@ -291,9 +205,27 @@ export function SessionTurn( { equals: same }, ) - const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) + const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) + const error = createMemo( + () => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error, + ) + const showAssistantCopyPartID = createMemo(() => { + const messages = assistantMessages() + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (!message) continue + + const parts = list(data.store.part?.[message.id], emptyParts) + for (let j = parts.length - 1; j >= 0; j--) { + const part = parts[j] + if (!part || part.type !== "text" || !part.text?.trim()) continue + return part.id + } + } - const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + return undefined + }) const errorText = createMemo(() => { const msg = error()?.data?.message if (typeof msg === "string") return unwrap(msg) @@ -301,314 +233,29 @@ export function SessionTurn( return unwrap(String(msg)) }) - const lastTextPart = createMemo(() => { - const msgs = assistantMessages() - for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (part?.type === "text") return part as TextPart - } - } - return undefined - }) - - const hasSteps = createMemo(() => { - for (const m of assistantMessages()) { - const msgParts = list(data.store.part?.[m.id], emptyParts) - for (const p of msgParts) { - if (p?.type === "tool") return true - } - } - return false - }) - - const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions)) - const nextPermission = createMemo(() => permissions()[0]) - - const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions)) - const nextQuestion = createMemo(() => questions()[0]) - - const hidden = createMemo(() => { - const out: { messageID: string; callID: string }[] = [] - const perm = nextPermission() - if (perm?.tool) out.push(perm.tool) - const question = nextQuestion() - if (question?.tool) out.push(question.tool) - return out - }) - - const answeredQuestionParts = createMemo(() => { - if (props.stepsExpanded) return emptyQuestionParts - if (questions().length > 0) return emptyQuestionParts - - const result: { part: ToolPart; message: AssistantMessage }[] = [] - - for (const msg of assistantMessages()) { - const parts = list(data.store.part?.[msg.id], emptyParts) - for (const part of parts) { - if (part?.type !== "tool") continue - const tool = part as ToolPart - if (tool.tool !== "question") continue - // @ts-expect-error metadata may not exist on all tool states - const answers = tool.state?.metadata?.answers - if (answers && answers.length > 0) { - result.push({ part: tool, message: msg }) - } - } - } - - return result - }) - - const shellModePart = createMemo(() => { - const p = parts() - if (p.length === 0) return - if (!p.every((part) => part?.type === "text" && part?.synthetic)) return - - const msgs = assistantMessages() - if (msgs.length !== 1) return - - const msgParts = list(data.store.part?.[msgs[0].id], emptyParts) - if (msgParts.length !== 1) return - - const assistantPart = msgParts[0] - if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart - }) - - const isShellMode = createMemo(() => !!shellModePart()) - - const rawStatus = createMemo(() => { - const msgs = assistantMessages() - let last: PartType | undefined - let currentTask: ToolPart | undefined - - for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (!part) continue - if (!last) last = part - - if ( - part.type === "tool" && - part.tool === "task" && - part.state && - "metadata" in part.state && - part.state.metadata?.sessionId && - part.state.status === "running" - ) { - currentTask = part as ToolPart - break - } - } - if (currentTask) break - } - - const taskSessionId = - currentTask?.state && "metadata" in currentTask.state - ? (currentTask.state.metadata?.sessionId as string | undefined) - : undefined - - if (taskSessionId) { - const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages) - for (let mi = taskMessages.length - 1; mi >= 0; mi--) { - const msg = taskMessages[mi] - if (!msg || msg.role !== "assistant") continue - - const msgParts = list(data.store.part?.[msg.id], emptyParts) - for (let pi = msgParts.length - 1; pi >= 0; pi--) { - const part = msgParts[pi] - if (part) return computeStatusFromPart(part, i18n.t) - } - } - } - - return computeStatusFromPart(last, i18n.t) - }) - const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle) const working = createMemo(() => status().type !== "idle" && isLastUserMessage()) - const retry = createMemo(() => { - // session_status is session-scoped; only show retry on the active (last) turn - if (!isLastUserMessage()) return - const s = status() - if (s.type !== "retry") return - return s - }) - const isRetryFreeUsageLimitError = createMemo(() => { - const r = retry() - if (!r) return false - return r.message.includes("Free usage exceeded") - }) - - const response = createMemo(() => lastTextPart()?.text) - const responsePartId = createMemo(() => lastTextPart()?.id) - const hasDiffs = createMemo(() => (message()?.summary?.diffs?.length ?? 0) > 0) - const hideResponsePart = createMemo(() => !working() && !!responsePartId()) - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const content = response() ?? "" - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - const [rootRef, setRootRef] = createSignal() - const [stickyRef, setStickyRef] = createSignal() - - const updateStickyHeight = (height: number) => { - const root = rootRef() - if (!root) return - const next = Math.ceil(height) - root.style.setProperty("--session-turn-sticky-height", `${next}px`) - } - - function duration() { - const msg = message() - if (!msg) return "" - const completed = lastAssistantMessage()?.time.completed - const from = DateTime.fromMillis(msg.time.created) - const to = completed ? DateTime.fromMillis(completed) : DateTime.now() - const interval = Interval.fromDateTimes(from, to) - const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"] - - const locale = i18n.locale() - const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({ - notation: "compact", - unitDisplay: "narrow", - compactDisplay: "short", - showZeros: false, - }) - return locale.startsWith("zh") ? human.replaceAll("、", "") : human - } + const assistantCopyPartID = createMemo(() => { + if (!isLastUserMessage()) return null + if (status().type !== "idle") return null + return showAssistantCopyPartID() ?? null + }) + const assistantVisible = createMemo(() => + assistantMessages().reduce((count, message) => { + const parts = list(data.store.part?.[message.id], emptyParts) + return count + parts.filter(visible).length + }, 0), + ) const autoScroll = createAutoScroll({ working, onUserInteracted: props.onUserInteracted, - overflowAnchor: "auto", - }) - - createResizeObserver( - () => stickyRef(), - ({ height }) => { - updateStickyHeight(height) - }, - ) - - createEffect(() => { - const root = rootRef() - if (!root) return - const sticky = stickyRef() - if (!sticky) { - root.style.setProperty("--session-turn-sticky-height", "0px") - return - } - updateStickyHeight(sticky.getBoundingClientRect().height) - }) - - const [store, setStore] = createStore({ - retrySeconds: 0, - status: rawStatus(), - duration: duration(), - }) - - createEffect(() => { - const r = retry() - if (!r) { - setStore("retrySeconds", 0) - return - } - const updateSeconds = () => { - const next = r.next - if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000))) - } - updateSeconds() - const timer = setInterval(updateSeconds, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let retryLog = "" - createEffect(() => { - const r = retry() - if (!r) return - const key = `${r.attempt}:${r.next}:${r.message}` - if (key === retryLog) return - retryLog = key - console.warn("[session-turn] retry", { - sessionID: props.sessionID, - messageID: props.messageID, - attempt: r.attempt, - next: r.next, - raw: r.message, - parsed: unwrap(r.message), - }) - }) - - let errorLog = "" - createEffect(() => { - const value = error()?.data?.message - if (value === undefined || value === null) return - const raw = typeof value === "string" ? value : String(value) - if (!raw) return - if (raw === errorLog) return - errorLog = raw - console.warn("[session-turn] assistant-error", { - sessionID: props.sessionID, - messageID: props.messageID, - raw, - parsed: unwrap(raw), - }) - }) - - createEffect(() => { - const update = () => { - setStore("duration", duration()) - } - - update() - - // Only keep ticking while the active (in-progress) turn is running. - if (!working()) return - - const timer = setInterval(update, 1000) - onCleanup(() => clearInterval(timer)) - }) - - let lastStatusChange = Date.now() - let statusTimeout: number | undefined - createEffect(() => { - const newStatus = rawStatus() - if (newStatus === store.status || !newStatus) return - - const timeSinceLastChange = Date.now() - lastStatusChange - if (timeSinceLastChange >= 2500) { - setStore("status", newStatus) - lastStatusChange = Date.now() - if (statusTimeout) { - clearTimeout(statusTimeout) - statusTimeout = undefined - } - } else { - if (statusTimeout) clearTimeout(statusTimeout) - statusTimeout = setTimeout(() => { - setStore("status", rawStatus()) - lastStatusChange = Date.now() - statusTimeout = undefined - }, 2500 - timeSinceLastChange) as unknown as number - } - }) - - onCleanup(() => { - if (!statusTimeout) return - clearTimeout(statusTimeout) + overflowAnchor: "dynamic", }) return ( -
+
- - - - - - 0}> -
- -
-
-
- {/* User Message */} -
- -
- - {/* Trigger (sticky) */} - -
- -
-
-
- {/* Response */} - 0}> -
- - {(assistantMessage) => ( - - - - {errorText()} - - -
-
- 0}> -
- - {({ part, message }) => } - -
-
- {/* Response */} -
- {!working() && response() ? response() : ""} -
- -
-
-
-

{i18n.t("ui.sessionTurn.summary.response")}

- -
- - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - -
-
-
-
- +
+ +
+ +
+ +
+
+ 0}> +
+ + {(assistantMessage) => ( + + )} + +
+
+ 0}> +
+ + +
+
+ + {i18n.t("ui.sessionReview.change.modified")} + + + {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")} + +
+ + +
-
-
- - - {errorText()} - - - - + + + +
+ + {(diff) => ( +
+
+ + + {getDirectory(diff.file)} + + {getFilename(diff.file)} + + + + +
+
+ +
+
+ )} +
+
+
+
+ +
+ + + + {errorText()} + +
)} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css new file mode 100644 index 000000000..929a2d851 --- /dev/null +++ b/packages/ui/src/components/text-shimmer.css @@ -0,0 +1,43 @@ +[data-component="text-shimmer"] { + --text-shimmer-step: 45ms; + --text-shimmer-duration: 1200ms; +} + +[data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + white-space: pre; + color: inherit; +} + +[data-component="text-shimmer"][data-active="true"] [data-slot="text-shimmer-char"] { + animation-name: text-shimmer-char; + animation-duration: var(--text-shimmer-duration); + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + animation-delay: calc(var(--text-shimmer-step) * var(--text-shimmer-index)); +} + +@keyframes text-shimmer-char { + 0%, + 100% { + color: var(--text-weaker); + } + + 30% { + color: var(--text-weak); + } + + 55% { + color: var(--text-base); + } + + 75% { + color: var(--text-strong); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="text-shimmer"] [data-slot="text-shimmer-char"] { + animation: none !important; + color: inherit; + } +} diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx new file mode 100644 index 000000000..6ee4ef402 --- /dev/null +++ b/packages/ui/src/components/text-shimmer.tsx @@ -0,0 +1,36 @@ +import { For, createMemo, type ValidComponent } from "solid-js" +import { Dynamic } from "solid-js/web" + +export const TextShimmer = (props: { + text: string + class?: string + as?: T + active?: boolean + stepMs?: number + durationMs?: number +}) => { + const chars = createMemo(() => Array.from(props.text)) + const active = () => props.active ?? true + + return ( + + + {(char, index) => ( + + )} + + + ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 51bffa050..5ff3d9131 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -50,8 +50,6 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string -export type SyncSessionFn = (sessionID: string) => void | Promise - export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -62,7 +60,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn - onSyncSession?: SyncSessionFn }) => { return { get store() { @@ -76,7 +73,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, - syncSession: props.onSyncSession, } }, }) diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 4a1525d46..f4d6c8788 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "أسئلة", "ui.common.add": "إضافة", + "ui.common.back": "رجوع", "ui.common.cancel": "إلغاء", "ui.common.confirm": "تأكيد", "ui.common.dismiss": "رفض", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "طي الرسالة", "ui.message.copy": "نسخ", "ui.message.copied": "تم النسخ!", + "ui.message.interrupted": "تمت المقاطعة", "ui.message.attachment.alt": "مرفق", "ui.patch.action.deleted": "محذوف", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} أجيب", "ui.question.answer.none": "(لا توجد إجابة)", "ui.question.review.notAnswered": "(لم يتم الرد)", - "ui.question.multiHint": "(حدد كل ما ينطبق)", + "ui.question.multiHint": "حدد كل ما ينطبق", + "ui.question.singleHint": "حدد إجابة واحدة", "ui.question.custom.placeholder": "اكتب إجابتك...", } diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 160d07aee..2dda9d92b 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "perguntas", "ui.common.add": "Adicionar", + "ui.common.back": "Voltar", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", "ui.common.dismiss": "Descartar", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "Recolher mensagem", "ui.message.copy": "Copiar", "ui.message.copied": "Copiado!", + "ui.message.interrupted": "Interrompido", "ui.message.attachment.alt": "anexo", "ui.patch.action.deleted": "Excluído", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} respondidas", "ui.question.answer.none": "(sem resposta)", "ui.question.review.notAnswered": "(não respondida)", - "ui.question.multiHint": "(selecione todas que se aplicam)", + "ui.question.multiHint": "Selecione todas que se aplicam", + "ui.question.singleHint": "Selecione uma resposta", "ui.question.custom.placeholder": "Digite sua resposta...", } diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 9a049c14b..21e9e5354 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "pitanja", "ui.common.add": "Dodaj", + "ui.common.back": "Nazad", "ui.common.cancel": "Otkaži", "ui.common.confirm": "Potvrdi", "ui.common.dismiss": "Odbaci", @@ -101,6 +102,7 @@ export const dict = { "ui.message.collapse": "Sažmi poruku", "ui.message.copy": "Kopiraj", "ui.message.copied": "Kopirano!", + "ui.message.interrupted": "Prekinuto", "ui.message.attachment.alt": "prilog", "ui.patch.action.deleted": "Obrisano", @@ -111,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} odgovoreno", "ui.question.answer.none": "(nema odgovora)", "ui.question.review.notAnswered": "(nije odgovoreno)", - "ui.question.multiHint": "(odaberi sve što važi)", + "ui.question.multiHint": "Odaberi sve što važi", + "ui.question.singleHint": "Odaberi jedan odgovor", "ui.question.custom.placeholder": "Unesi svoj odgovor...", } satisfies Partial> diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index de0e854be..9d8221698 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "spørgsmål", "ui.common.add": "Tilføj", + "ui.common.back": "Tilbage", "ui.common.cancel": "Annuller", "ui.common.confirm": "Bekræft", "ui.common.dismiss": "Afvis", @@ -96,6 +97,7 @@ export const dict = { "ui.message.collapse": "Skjul besked", "ui.message.copy": "Kopier", "ui.message.copied": "Kopieret!", + "ui.message.interrupted": "Afbrudt", "ui.message.attachment.alt": "vedhæftning", "ui.patch.action.deleted": "Slettet", @@ -106,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} besvaret", "ui.question.answer.none": "(intet svar)", "ui.question.review.notAnswered": "(ikke besvaret)", - "ui.question.multiHint": "(vælg alle der gælder)", + "ui.question.multiHint": "Vælg alle der gælder", + "ui.question.singleHint": "Vælg ét svar", "ui.question.custom.placeholder": "Skriv dit svar...", } diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 977065db4..09d5141e3 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -85,6 +85,7 @@ export const dict = { "ui.common.question.other": "Fragen", "ui.common.add": "Hinzufügen", + "ui.common.back": "Zurück", "ui.common.cancel": "Abbrechen", "ui.common.confirm": "Bestätigen", "ui.common.dismiss": "Verwerfen", @@ -100,6 +101,7 @@ export const dict = { "ui.message.collapse": "Nachricht reduzieren", "ui.message.copy": "Kopieren", "ui.message.copied": "Kopiert!", + "ui.message.interrupted": "Unterbrochen", "ui.message.attachment.alt": "Anhang", "ui.patch.action.deleted": "Gelöscht", @@ -110,6 +112,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} beantwortet", "ui.question.answer.none": "(keine Antwort)", "ui.question.review.notAnswered": "(nicht beantwortet)", - "ui.question.multiHint": "(alle zutreffenden auswählen)", + "ui.question.multiHint": "Alle zutreffenden auswählen", + "ui.question.singleHint": "Eine Antwort auswählen", "ui.question.custom.placeholder": "Geben Sie Ihre Antwort ein...", } satisfies Partial> diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 59f08e48d..b89fea3c3 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "questions", "ui.common.add": "Add", + "ui.common.back": "Back", "ui.common.cancel": "Cancel", "ui.common.confirm": "Confirm", "ui.common.dismiss": "Dismiss", @@ -96,7 +97,8 @@ export const dict = { "ui.message.expand": "Expand message", "ui.message.collapse": "Collapse message", "ui.message.copy": "Copy", - "ui.message.copied": "Copied!", + "ui.message.copied": "Copied", + "ui.message.interrupted": "Interrupted", "ui.message.attachment.alt": "attachment", "ui.patch.action.deleted": "Deleted", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} answered", "ui.question.answer.none": "(no answer)", "ui.question.review.notAnswered": "(not answered)", - "ui.question.multiHint": "(select all that apply)", + "ui.question.multiHint": "Select all answers that apply", + "ui.question.singleHint": "Select one answer", "ui.question.custom.placeholder": "Type your answer...", } diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 6706515ec..6dbf9f599 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "preguntas", "ui.common.add": "Añadir", + "ui.common.back": "Atrás", "ui.common.cancel": "Cancelar", "ui.common.confirm": "Confirmar", "ui.common.dismiss": "Descartar", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "Colapsar mensaje", "ui.message.copy": "Copiar", "ui.message.copied": "¡Copiado!", + "ui.message.interrupted": "Interrumpido", "ui.message.attachment.alt": "adjunto", "ui.patch.action.deleted": "Eliminado", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} respondidas", "ui.question.answer.none": "(sin respuesta)", "ui.question.review.notAnswered": "(no respondida)", - "ui.question.multiHint": "(selecciona todas las que correspondan)", + "ui.question.multiHint": "Selecciona todas las que correspondan", + "ui.question.singleHint": "Selecciona una respuesta", "ui.question.custom.placeholder": "Escribe tu respuesta...", } diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 68a687e84..6a6114dc1 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "questions", "ui.common.add": "Ajouter", + "ui.common.back": "Retour", "ui.common.cancel": "Annuler", "ui.common.confirm": "Confirmer", "ui.common.dismiss": "Ignorer", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "Réduire le message", "ui.message.copy": "Copier", "ui.message.copied": "Copié !", + "ui.message.interrupted": "Interrompu", "ui.message.attachment.alt": "pièce jointe", "ui.patch.action.deleted": "Supprimé", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} répondu(s)", "ui.question.answer.none": "(pas de réponse)", "ui.question.review.notAnswered": "(non répondu)", - "ui.question.multiHint": "(sélectionnez tout ce qui s'applique)", + "ui.question.multiHint": "Sélectionnez tout ce qui s'applique", + "ui.question.singleHint": "Sélectionnez une réponse", "ui.question.custom.placeholder": "Tapez votre réponse...", } diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 6fff28cff..7cce41666 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "質問", "ui.common.add": "追加", + "ui.common.back": "戻る", "ui.common.cancel": "キャンセル", "ui.common.confirm": "確認", "ui.common.dismiss": "閉じる", @@ -96,6 +97,7 @@ export const dict = { "ui.message.collapse": "メッセージを折りたたむ", "ui.message.copy": "コピー", "ui.message.copied": "コピーしました!", + "ui.message.interrupted": "中断", "ui.message.attachment.alt": "添付ファイル", "ui.patch.action.deleted": "削除済み", @@ -106,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}}件回答済み", "ui.question.answer.none": "(回答なし)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(該当するものをすべて選択)", + "ui.question.multiHint": "該当するものをすべて選択", + "ui.question.singleHint": "1 つ選択", "ui.question.custom.placeholder": "回答を入力...", } diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 6fac1590d..108f98ae9 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "질문", "ui.common.add": "추가", + "ui.common.back": "뒤로", "ui.common.cancel": "취소", "ui.common.confirm": "확인", "ui.common.dismiss": "닫기", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "메시지 접기", "ui.message.copy": "복사", "ui.message.copied": "복사됨!", + "ui.message.interrupted": "중단됨", "ui.message.attachment.alt": "첨부 파일", "ui.patch.action.deleted": "삭제됨", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}}개 답변됨", "ui.question.answer.none": "(답변 없음)", "ui.question.review.notAnswered": "(답변되지 않음)", - "ui.question.multiHint": "(해당하는 항목 모두 선택)", + "ui.question.multiHint": "해당하는 항목 모두 선택", + "ui.question.singleHint": "하나의 답변을 선택", "ui.question.custom.placeholder": "답변 입력...", } diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 160f26a54..70c5df5b0 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -85,6 +85,7 @@ export const dict: Record = { "ui.common.question.other": "spørsmål", "ui.common.add": "Legg til", + "ui.common.back": "Tilbake", "ui.common.cancel": "Avbryt", "ui.common.confirm": "Bekreft", "ui.common.dismiss": "Avvis", @@ -100,6 +101,7 @@ export const dict: Record = { "ui.message.collapse": "Skjul melding", "ui.message.copy": "Kopier", "ui.message.copied": "Kopiert!", + "ui.message.interrupted": "Avbrutt", "ui.message.attachment.alt": "vedlegg", "ui.patch.action.deleted": "Slettet", @@ -110,6 +112,7 @@ export const dict: Record = { "ui.question.subtitle.answered": "{{count}} besvart", "ui.question.answer.none": "(ingen svar)", "ui.question.review.notAnswered": "(ikke besvart)", - "ui.question.multiHint": "(velg alle som gjelder)", + "ui.question.multiHint": "Velg alle som gjelder", + "ui.question.singleHint": "Velg ett svar", "ui.question.custom.placeholder": "Skriv svaret ditt...", } diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 4882ba034..f017ac880 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "pytania", "ui.common.add": "Dodaj", + "ui.common.back": "Wstecz", "ui.common.cancel": "Anuluj", "ui.common.confirm": "Potwierdź", "ui.common.dismiss": "Odrzuć", @@ -96,6 +97,7 @@ export const dict = { "ui.message.collapse": "Zwiń wiadomość", "ui.message.copy": "Kopiuj", "ui.message.copied": "Skopiowano!", + "ui.message.interrupted": "Przerwano", "ui.message.attachment.alt": "załącznik", "ui.patch.action.deleted": "Usunięto", @@ -106,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} odpowiedzi", "ui.question.answer.none": "(brak odpowiedzi)", "ui.question.review.notAnswered": "(bez odpowiedzi)", - "ui.question.multiHint": "(zaznacz wszystkie pasujące)", + "ui.question.multiHint": "Zaznacz wszystkie pasujące", + "ui.question.singleHint": "Wybierz jedną odpowiedź", "ui.question.custom.placeholder": "Wpisz swoją odpowiedź...", } diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 93a9883d2..81e3f9fb5 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -81,6 +81,7 @@ export const dict = { "ui.common.question.other": "вопросов", "ui.common.add": "Добавить", + "ui.common.back": "Назад", "ui.common.cancel": "Отмена", "ui.common.confirm": "Подтвердить", "ui.common.dismiss": "Закрыть", @@ -96,6 +97,7 @@ export const dict = { "ui.message.collapse": "Свернуть сообщение", "ui.message.copy": "Копировать", "ui.message.copied": "Скопировано!", + "ui.message.interrupted": "Прервано", "ui.message.attachment.alt": "вложение", "ui.patch.action.deleted": "Удалено", @@ -106,6 +108,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} отвечено", "ui.question.answer.none": "(нет ответа)", "ui.question.review.notAnswered": "(не отвечено)", - "ui.question.multiHint": "(выберите все подходящие)", + "ui.question.multiHint": "Выберите все подходящие", + "ui.question.singleHint": "Выберите один ответ", "ui.question.custom.placeholder": "Введите ваш ответ...", } diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 1a5438a2a..238b03782 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -82,6 +82,7 @@ export const dict = { "ui.common.question.other": "คำถาม", "ui.common.add": "เพิ่ม", + "ui.common.back": "ย้อนกลับ", "ui.common.cancel": "ยกเลิก", "ui.common.confirm": "ยืนยัน", "ui.common.dismiss": "ปิด", @@ -97,6 +98,7 @@ export const dict = { "ui.message.collapse": "ย่อข้อความ", "ui.message.copy": "คัดลอก", "ui.message.copied": "คัดลอกแล้ว!", + "ui.message.interrupted": "ถูกขัดจังหวะ", "ui.message.attachment.alt": "ไฟล์แนบ", "ui.patch.action.deleted": "ลบ", @@ -107,6 +109,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} ตอบแล้ว", "ui.question.answer.none": "(ไม่มีคำตอบ)", "ui.question.review.notAnswered": "(ไม่ได้ตอบ)", - "ui.question.multiHint": "(เลือกทั้งหมดที่ใช้)", + "ui.question.multiHint": "เลือกทั้งหมดที่ใช้", + "ui.question.singleHint": "เลือกหนึ่งคำตอบ", "ui.question.custom.placeholder": "พิมพ์คำตอบของคุณ...", } diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index dbebfb3f9..5f6eaaaeb 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "个问题", "ui.common.add": "添加", + "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "确认", "ui.common.dismiss": "忽略", @@ -101,6 +102,7 @@ export const dict = { "ui.message.collapse": "收起消息", "ui.message.copy": "复制", "ui.message.copied": "已复制!", + "ui.message.interrupted": "已中断", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已删除", @@ -111,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} 已回答", "ui.question.answer.none": "(无答案)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(可多选)", + "ui.question.multiHint": "可多选", + "ui.question.singleHint": "选择一个答案", "ui.question.custom.placeholder": "输入你的答案...", } satisfies Partial> diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 5cec9c399..c413fe8cd 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -86,6 +86,7 @@ export const dict = { "ui.common.question.other": "個問題", "ui.common.add": "新增", + "ui.common.back": "返回", "ui.common.cancel": "取消", "ui.common.confirm": "確認", "ui.common.dismiss": "忽略", @@ -101,6 +102,7 @@ export const dict = { "ui.message.collapse": "收合訊息", "ui.message.copy": "複製", "ui.message.copied": "已複製!", + "ui.message.interrupted": "已中斷", "ui.message.attachment.alt": "附件", "ui.patch.action.deleted": "已刪除", @@ -111,6 +113,7 @@ export const dict = { "ui.question.subtitle.answered": "{{count}} 已回答", "ui.question.answer.none": "(無答案)", "ui.question.review.notAnswered": "(未回答)", - "ui.question.multiHint": "(可多選)", + "ui.question.multiHint": "可多選", + "ui.question.singleHint": "選擇一個答案", "ui.question.custom.placeholder": "輸入你的答案...", } satisfies Partial> diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index 3480976dd..f8d11e0e5 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -1,5 +1,6 @@ :root { --animate-pulse: pulse-opacity 2s ease-in-out infinite; + --animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite; } @keyframes pulse-opacity { @@ -12,6 +13,16 @@ } } +@keyframes pulse-scale { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(0.6666667); + } +} + @keyframes pulse-opacity-dim { 0%, 100% { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 167eb64c8..f0a1275c3 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,7 @@ @import "../components/sticky-accordion-header.css" layer(components); @import "../components/tabs.css" layer(components); @import "../components/tag.css" layer(components); +@import "../components/text-shimmer.css" layer(components); @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 7ecac53fe..6b8f151c1 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -10,6 +10,7 @@ --font-size-x-large: 20px; --font-weight-regular: 400; --font-weight-medium: 500; + --line-height-normal: 130%; --line-height-large: 150%; --line-height-x-large: 180%; --line-height-2x-large: 200%; -- cgit v1.2.3