diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index 0526d870448..b9833b04165 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -26,7 +26,8 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn modelControlsVisible={!props.model.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} - alternateKeybind={[formatKeybind("mod", language.t), formatKeybind("enter", language.t)]} + alternateKeybind={[formatKeybind("mod", language.t), "↵"]} + exitShellKeybind={[formatKeybind("esc", language.t)]} modelControl={ + + + event.preventDefault()} > @@ -701,6 +721,7 @@ export function ComposerEditorPopover(props: { ) } export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) { const language = useLanguage() const [open, setOpen] = createSignal(false) + const [triggerRef, setTriggerRef] = createSignal() + const tasks = createMemo((previous = []) => (props.tasks.length > 0 ? props.tasks : previous)) + const presence = createAnimatedPresence( + () => (props.tasks.length > 0 ? true : undefined), + () => triggerRef() ?? null, + ) + createEffect(() => { + if (props.tasks.length > 0) return + setOpen(false) + }) const taskType = (task: BackgroundTask) => { if (task.type === "shell") return language.t("ui.tool.shell") if (!task.agent) return language.t("ui.tool.agent.default") @@ -84,31 +94,38 @@ export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: open={open()} placement={props.mobile ? "top-end" : language.direction() === "rtl" ? "right-end" : "left-end"} gutter={4} - onOpenChange={setOpen} + onOpenChange={(value) => setOpen(value && props.tasks.length > 0)} > - - - {props.tasks.length} - - - + + + + + + - + {(task) => (
- {language.t("session.summary.noBranch")} + {language.t("session.summary.noBranch")} {(base) => ( <> @@ -288,9 +305,17 @@ export function SessionSummaryPanel(props: { )} - 0}> - - +
0, + "grid-rows-[0fr]": props.backgroundTasks.length === 0, + }} + > +
+ +
+
0 && props.moveEligible}> - + +
-
- -
+ +
+ +
+
+ +
+ +
+
- - -
-
- -
-
-
- +
+
} deferred={(row) => { if (row._tag !== "AssistantPart" || row.group.type !== "part") return false @@ -738,7 +757,7 @@ function MessageTimelineView( />
- + {(id) => ( [data-slot="collapsible-content"] [data-component="bash-output"][data-variant="shell"] ) { - --tool-content-gap: 6px; + --tool-content-gap: 8px; } [data-component="edit-trigger"], diff --git a/packages/session-ui/src/components/session-error.tsx b/packages/session-ui/src/components/session-error.tsx new file mode 100644 index 00000000000..c4414a627c0 --- /dev/null +++ b/packages/session-ui/src/components/session-error.tsx @@ -0,0 +1,33 @@ +import { createMemo, Show } from "solid-js" +import { Card } from "@opencode-ai/ui/card" +import { Icon } from "@opencode-ai/ui/icon" + +export function SessionErrorMessage(props: { message: string }) { + const content = createMemo(() => { + const separator = props.message.indexOf(":") + if (separator === -1) return { detail: props.message } + return { + title: props.message.slice(0, separator + 1), + detail: props.message.slice(separator + 1), + } + }) + return ( + <> + {(title) => {title()}} + {content().detail} + + ) +} + +export function SessionError(props: { message: string }) { + return ( + +
+ +
+ +
+
+
+ ) +} diff --git a/packages/session-ui/src/components/session-retry.tsx b/packages/session-ui/src/components/session-retry.tsx index 71c6bcd9cdc..13605e2bfa6 100644 --- a/packages/session-ui/src/components/session-retry.tsx +++ b/packages/session-ui/src/components/session-retry.tsx @@ -2,8 +2,10 @@ import { createEffect, createMemo, createSignal, on, onCleanup, Show } from "sol import type { SessionStatus } from "@opencode-ai/client/promise" import { useI18n } from "@opencode-ai/ui/context/i18n" import { Card } from "@opencode-ai/ui/card" +import { Icon } from "@opencode-ai/ui/icon" +import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Spinner } from "@opencode-ai/ui/spinner" +import { SessionErrorMessage } from "./session-error" export function SessionRetry(props: { status: SessionStatus; show?: boolean }) { const i18n = useI18n() @@ -34,11 +36,6 @@ export function SessionRetry(props: { status: SessionStatus; show?: boolean }) { if (current.message.length > 80) return current.message.slice(0, 80) + "…" return current.message }) - const truncated = createMemo(() => { - const current = retry() - if (!current) return false - return current.message.length > 80 - }) const info = createMemo(() => { const current = retry() if (!current) return "" @@ -46,25 +43,29 @@ export function SessionRetry(props: { status: SessionStatus; show?: boolean }) { const delay = count > 0 ? i18n.t("ui.sessionTurn.retry.inSeconds", { seconds: count }) : "" const retrying = i18n.t("ui.sessionTurn.retry.retrying") const line = [retrying, delay].filter(Boolean).join(" ") - if (!line) return i18n.t("ui.sessionTurn.retry.attempt", { attempt: current.attempt }) - return i18n.t("ui.sessionTurn.retry.attemptLine", { line, attempt: current.attempt }) + if (!line) return i18n.t("ui.sessionTurn.retry.attemptLabel", { attempt: current.attempt }) + return i18n.t("ui.sessionTurn.retry.attemptRetrying", { line, attempt: current.attempt }) }) return ( -
- -
- -
- {message()}
}> - -
- {message()} +
+ +
+ +
+ +
+ +
+
+ + {(line) => ( +
+
- + )}
- {(line) =>
{line()}
}
diff --git a/packages/session-ui/src/components/session-turn.css b/packages/session-ui/src/components/session-turn.css index 25516f70d33..64140e661b6 100644 --- a/packages/session-ui/src/components/session-turn.css +++ b/packages/session-ui/src/components/session-turn.css @@ -83,6 +83,48 @@ overflow-y: auto; } + [data-component="card"]:is([data-kind="session-retry-card"], [data-kind="session-error-card"]) { + font-size: 13px; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + } + + [data-component="card"][data-kind="session-retry-card"] { + --card-pad-l: 0px; + --card-pad-r: 0px; + + &::before { + display: none; + } + + [data-slot="session-turn-retry-message"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + [data-slot="session-turn-retry-info"] { + margin-top: 4px; + color: var(--v2-text-text-faint); + font-family: var(--v2-font-family-sans); + font-size: 13px; + font-weight: 440; + line-height: var(--line-height-compact); + letter-spacing: -0.04px; + } + } + + [data-component="card"][data-kind="session-error-card"] { + --card-pad-y: 0px; + --card-pad-l: 0px; + --card-pad-r: 0px; + + &::before { + display: none; + } + } + [data-slot="session-turn-assistant-content"] { width: 100%; min-width: 0; diff --git a/packages/session-ui/src/components/tool-error-card.css b/packages/session-ui/src/components/tool-error-card.css index 495721cfc05..a22e54d993c 100644 --- a/packages/session-ui/src/components/tool-error-card.css +++ b/packages/session-ui/src/components/tool-error-card.css @@ -2,6 +2,7 @@ --card-pad-y: 0px; --card-line-pad: 0px; --card-pad-r: 0px; + --card-pad-l: 0px; &::before { display: none; @@ -26,16 +27,6 @@ color: var(--v2-text-text-muted); } - [data-slot="tool-error-card-dot"] { - flex-shrink: 0; - font-family: var(--v2-font-family-sans); - font-size: 11px; - font-weight: 530; - line-height: var(--line-height-tight); - letter-spacing: 0.05px; - color: var(--v2-text-text-muted); - } - [data-slot="tool-error-card-summary"] { font-family: var(--v2-font-family-sans); font-size: 13px; diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index f27c267cee2..5f995bca263 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -97,7 +97,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
- +
@@ -121,9 +121,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) { - {summary()}
diff --git a/packages/session-ui/src/message/attachment-card.css b/packages/session-ui/src/message/attachment-card.css index 488c141183a..095ebf46485 100644 --- a/packages/session-ui/src/message/attachment-card.css +++ b/packages/session-ui/src/message/attachment-card.css @@ -68,3 +68,7 @@ } } } + +[data-color-scheme="dark"] [data-component="attachment-card"][data-surface="base"] { + background: var(--v2-overlay-simple-overlay-hover); +} diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index e5f120ca525..8a3c266e795 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -4,7 +4,6 @@ import type { SessionMessageUser, SessionStatus, } from "@opencode-ai/client/promise" -import { Card } from "@opencode-ai/ui/card" import { useI18n } from "@opencode-ai/ui/context/i18n" import { Tooltip } from "@opencode-ai/ui/tooltip" import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" @@ -22,6 +21,7 @@ import { import { AssistantReasoningContent, SessionCompactionMessage } from "../message/message-content" import type { ContextGroupPart } from "../tools/tool-renderer" import { SessionRetry } from "../components/session-retry" +import { SessionError } from "../components/session-error" import { timelineCategory, type TimelineDetail } from "./detail" import { currentToolFailed } from "../message/current-tool-state" import { @@ -293,8 +293,10 @@ export function createSessionTimelineRowRenderer(input: { const notice = (message: SessionMessageInfo) => { if (message.type === "agent-switched") return { - label: i18n.t("ui.tool.agent.default"), - data: message.previous ? `${message.previous} → ${message.agent}` : message.agent, + label: i18n.t("ui.sessionTimeline.notice.agentChanged"), + data: message.previous + ? `${capitalizeAgent(message.previous)} → ${capitalizeAgent(message.agent)}` + : capitalizeAgent(message.agent), } if (message.type === "model-switched") return undefined if (message.type === "skill") return { label: i18n.t("ui.tool.skill"), data: message.name } @@ -412,14 +414,18 @@ export function createSessionTimelineRowRenderer(input: { data-slot="session-timeline-notice" class={`w-full truncate ${props.grouped ? "py-1" : "pt-3 pb-1"} text-13-regular leading-text-compact text-text-weak ${inset()}`} > - + {content().label} {(data) => ( - - {" "} - · {data()} + + {" · "} + {data()} )} @@ -683,9 +689,7 @@ export function createSessionTimelineRowRenderer(input: { return (
- - {current().text} - +
) @@ -701,3 +705,7 @@ export function createSessionTimelineRowRenderer(input: { return { Row } } + +function capitalizeAgent(agent: string) { + return agent.slice(0, 1).toUpperCase() + agent.slice(1) +} diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 0946d736a5d..078c3a4eba5 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -1441,7 +1441,7 @@ ToolRegistry.register({
- +
@@ -1468,7 +1468,7 @@ ToolRegistry.register({ >
diff --git a/packages/ui/src/actions/button/button.css b/packages/ui/src/actions/button/button.css index 20a8855503f..7433bac33bc 100644 --- a/packages/ui/src/actions/button/button.css +++ b/packages/ui/src/actions/button/button.css @@ -260,3 +260,32 @@ opacity: 0.5; cursor: not-allowed; } + +/* Ghost faint */ +[data-component="button-v2"][data-variant="ghost-faint"] { + background-color: transparent; + color: var(--v2-text-text-faint); +} + +[data-component="button-v2"][data-variant="ghost-faint"] [data-slot="icon-svg"] { + color: var(--v2-icon-icon-faint); +} + +[data-component="button-v2"][data-variant="ghost-faint"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] + ) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="button-v2"][data-variant="ghost-faint"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="button-v2"][data-variant="ghost-faint"]:where([data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="button-v2"][data-variant="ghost-faint"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/packages/ui/src/actions/button/button.stories.tsx b/packages/ui/src/actions/button/button.stories.tsx index 0e85bf7651b..e065f133015 100644 --- a/packages/ui/src/actions/button/button.stories.tsx +++ b/packages/ui/src/actions/button/button.stories.tsx @@ -5,7 +5,7 @@ const docs = `### Overview Button v2 with visual variants and three sizes. ### API -- \`variant\`: "neutral" | "danger" | "warning" | "contrast" | "ghost" | "ghost-muted" | "loading". +- \`variant\`: "neutral" | "danger" | "warning" | "contrast" | "ghost" | "ghost-muted" | "ghost-faint" | "loading". - \`size\`: "small" | "normal" | "large". - \`icon\`: Optional icon name. - Inherits Kobalte Button props and native button attributes. @@ -40,7 +40,7 @@ export default { }, variant: { control: "select", - options: ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "loading"], + options: ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "ghost-faint", "loading"], }, size: { control: "select", @@ -69,6 +69,9 @@ export const Variants = { +
), @@ -119,7 +122,16 @@ export const Icon = { export const AllStates = { render: () => { - const variants = ["neutral", "danger", "warning", "contrast", "ghost", "ghost-muted", "loading"] as const + const variants = [ + "neutral", + "danger", + "warning", + "contrast", + "ghost", + "ghost-muted", + "ghost-faint", + "loading", + ] as const const states = ["default", "hover", "pressed", "focus", "disabled"] as const const toTitleCase = (value: string) => value.charAt(0).toUpperCase() + value.slice(1) return ( diff --git a/packages/ui/src/actions/button/button.tsx b/packages/ui/src/actions/button/button.tsx index 7442dc5d642..8e753f4477d 100644 --- a/packages/ui/src/actions/button/button.tsx +++ b/packages/ui/src/actions/button/button.tsx @@ -7,7 +7,16 @@ export interface ButtonProps extends ComponentProps, Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" - variant?: "neutral" | "danger" | "warning" | "outline" | "contrast" | "ghost" | "ghost-muted" | "loading" + variant?: + | "neutral" + | "danger" + | "warning" + | "outline" + | "contrast" + | "ghost" + | "ghost-muted" + | "ghost-faint" + | "loading" icon?: IconProps["name"] } diff --git a/packages/ui/src/forms/text-input/text-input.css b/packages/ui/src/forms/text-input/text-input.css index 595a37098b1..d208a44df5a 100644 --- a/packages/ui/src/forms/text-input/text-input.css +++ b/packages/ui/src/forms/text-input/text-input.css @@ -13,7 +13,7 @@ outline: 1px solid transparent; outline-offset: 0; background: - linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); + linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base); box-shadow: var(--v2-elevation-button-neutral); flex: none; align-self: stretch; @@ -30,7 +30,7 @@ [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) { background: linear-gradient(0deg, var(--v2-overlay-simple-overlay-hover), var(--v2-overlay-simple-overlay-hover)), - linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base); + linear-gradient(180deg, var(--v2-alpha-light-6) 0%, var(--v2-alpha-light-2) 100%), var(--v2-background-bg-base); } [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index f26534b52b2..da2ff5b90a3 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -69,6 +69,8 @@ const source = { "ui.sessionTurn.retry.inSeconds": "in {{seconds}}s", "ui.sessionTurn.retry.attempt": "attempt #{{attempt}}", "ui.sessionTurn.retry.attemptLine": "{{line}} - attempt #{{attempt}}", + "ui.sessionTurn.retry.attemptLabel": "Attempt {{attempt}}", + "ui.sessionTurn.retry.attemptRetrying": "Attempt {{attempt}} - {{line}}", "ui.sessionTurn.retry.geminiHot": "gemini is way too hot right now", "ui.sessionTurn.error.freeUsageExceeded": "Free usage exceeded", "ui.sessionTurn.error.addCredits": "Add credits", @@ -146,6 +148,7 @@ const source = { "ui.promptInput.chooseVariant": "Choose model variant", "ui.promptInput.send": "Send", "ui.promptInput.stop": "Stop", + "ui.promptInput.exitShell": "Exit", "ui.promptInput.steer": "Steer", "ui.promptInput.queue": "Queue", "ui.promptInput.steerHint": "Send without interrupting", @@ -227,6 +230,7 @@ const source = { "ui.message.interrupted": "Interrupted", "ui.sessionTimeline.notice.model": "Model", "ui.sessionTimeline.notice.modelSwitched": "Switched to {{model}}", + "ui.sessionTimeline.notice.agentChanged": "Agent changed", "ui.sessionTimeline.notice.movedTo": "Moved to", "ui.sessionTimeline.notice.movedTooltip": "Session working directory changed", "ui.sessionTimeline.notice.failed": "{{actor}} failed", diff --git a/packages/ui/src/icons/icon/icon.stories.tsx b/packages/ui/src/icons/icon/icon.stories.tsx index bd06cf3dcc6..fd38c58d747 100644 --- a/packages/ui/src/icons/icon/icon.stories.tsx +++ b/packages/ui/src/icons/icon/icon.stories.tsx @@ -15,8 +15,10 @@ const names = [ "magnifying-glass", "menu", "monitor", + "outline-arrow-to-corner-top-right", "outline-copy", "outline-dots", + "outline-hexagonal-warning", "plus", "review", "settings-gear", diff --git a/packages/ui/src/icons/icon/icon.tsx b/packages/ui/src/icons/icon/icon.tsx index 9e6941f45c3..43274d4f4ee 100644 --- a/packages/ui/src/icons/icon/icon.tsx +++ b/packages/ui/src/icons/icon/icon.tsx @@ -152,6 +152,14 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-arrow-to-corner-top-right": { + viewBox: "0 0 16 16", + body: ``, + }, + "outline-hexagonal-warning": { + viewBox: "0 0 16 16", + body: ``, + }, "outline-share": { viewBox: "0 0 16 16", body: ``, @@ -187,10 +195,18 @@ function getIcon(name: IconName) { return { body, viewBox: additionalIconViewBox(name as keyof typeof additionalIcons) } } +function spriteContent() { + return Array.from(new Set([...Object.keys(additionalIcons), ...Object.keys(icons)] as IconName[])) + .map((name) => `${getIcon(name).body}`) + .join("") +} + function ensureSprite() { if (spriteInserted) return if (typeof document === "undefined") return - if (document.getElementById(spriteID)) { + const existing = document.getElementById(spriteID) + if (existing) { + existing.innerHTML = spriteContent() spriteInserted = true return } @@ -202,9 +218,7 @@ function ensureSprite() { svg.setAttribute("height", "0") svg.style.position = "absolute" svg.style.overflow = "hidden" - svg.innerHTML = Array.from(new Set([...Object.keys(additionalIcons), ...Object.keys(icons)] as IconName[])) - .map((name) => `${getIcon(name).body}`) - .join("") + svg.innerHTML = spriteContent() document.body.insertBefore(svg, document.body.firstChild) spriteInserted = true }