mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 22:35:28 +00:00
feat(desktop): polish session activity controls (#47033)
This commit is contained in:
parent
c7263309d4
commit
4fcb59e4c7
24 changed files with 343 additions and 135 deletions
|
|
@ -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={
|
||||
<ComposerModelControl
|
||||
loading={props.model.model.loading}
|
||||
|
|
|
|||
|
|
@ -6,3 +6,7 @@
|
|||
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="composer-suggestions"] [data-active] {
|
||||
background: var(--v2-alpha-light-10);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type ComposerEditorProps = {
|
|||
attachKeybind?: string[]
|
||||
attachShortcut?: string
|
||||
alternateKeybind?: string[]
|
||||
exitShellKeybind?: string[]
|
||||
}
|
||||
|
||||
export function ComposerEditor(props: ComposerEditorProps) {
|
||||
|
|
@ -281,6 +282,24 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
|||
keybind={props.alternateKeybind ?? ["Mod", "Enter"]}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={state.mode === "shell"}>
|
||||
<Button
|
||||
data-action="composer-exit-shell"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5"
|
||||
onClick={() => {
|
||||
props.controller.dispatch({ type: "mode.normal" })
|
||||
props.controller.restoreFocus()
|
||||
}}
|
||||
>
|
||||
{i18n.t("ui.promptInput.exitShell")}
|
||||
<span class="hidden sm:block">
|
||||
<Keybind keys={props.exitShellKeybind ?? ["ESC"]} variant="neutral" />
|
||||
</span>
|
||||
</Button>
|
||||
</Show>
|
||||
<ComposerEditorSubmitButton
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
|
|
@ -673,6 +692,7 @@ export function ComposerEditorPopover(props: {
|
|||
}) {
|
||||
return (
|
||||
<div
|
||||
data-component="composer-suggestions"
|
||||
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
|
|
@ -701,6 +721,7 @@ export function ComposerEditorPopover(props: {
|
|||
<button
|
||||
type="button"
|
||||
data-suggestion-id={item.id}
|
||||
data-active={props.activeID === item.id ? "" : undefined}
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
|
||||
onPointerMove={() => props.onActiveChange(item)}
|
||||
|
|
@ -749,9 +770,9 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
|
|||
ref={setButton}
|
||||
data-action="composer-alternate-delivery"
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
|
||||
class="me-3 gap-1.5 px-1.5 ![font-weight:530] duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": presence.animate() && presence.show(),
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
|
|
|
|||
|
|
@ -676,11 +676,14 @@ export const dict = {
|
|||
"session.error.incompatible.description":
|
||||
"{{server}} is running OpenCode {{version}}, which isn't compatible with this app. Upgrade the server to OpenCode V2 to continue.",
|
||||
"session.background.moveTasks": "Move {{tasks}} to background",
|
||||
"session.background.moveRunning": "Move running work to background",
|
||||
"session.background.inBackground": "Running {{tasks}} in background",
|
||||
"session.background.moveInline": "Press {{keybind}} to move running work to the background",
|
||||
"session.background.running": "Running work in background",
|
||||
"session.background.runningCount.one": "{{count}} item running in background",
|
||||
"session.background.runningCount.other": "{{count}} items running in background",
|
||||
"session.background.tasksRunning.one": "{{count}} background task running",
|
||||
"session.background.tasksRunning.other": "{{count}} background tasks running",
|
||||
"session.background.combine": "{{first}} and {{second}}",
|
||||
"session.background.shell.one": "{{count}} shell",
|
||||
"session.background.shell.other": "{{count}} shells",
|
||||
|
|
|
|||
|
|
@ -156,10 +156,10 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
|||
data-action="session-queue-steer"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
variant="ghost-faint"
|
||||
icon="arrow-up"
|
||||
disabled={props.queue.busy()}
|
||||
class="text-v2-text-text-muted ![font-weight:530]"
|
||||
class="![font-weight:530]"
|
||||
onClick={() => void props.queue.steer(props.id)}
|
||||
>
|
||||
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
|
||||
import { Badge } from "@opencode-ai/ui/badge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
|
|
@ -47,32 +47,42 @@ type SessionBackground = {
|
|||
move: () => Promise<void>
|
||||
}
|
||||
|
||||
export function BackgroundMoveHint(props: { keybind?: string[] }) {
|
||||
export function BackgroundMoveHint(props: { keybind?: string[]; onMove?: () => void }) {
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const marker = "__OPENCODE_BACKGROUND_KEYBIND__"
|
||||
const parts = createMemo(() => language.t("session.background.moveInline", { keybind: marker }).split(marker))
|
||||
const keys = () => props.keybind ?? command.keybindParts("session.background")
|
||||
const keybind = () => props.keybind?.join("+") ?? command.keybind("session.background")
|
||||
|
||||
return (
|
||||
<div
|
||||
<Button
|
||||
data-component="session-background-hint"
|
||||
class="flex h-6 max-w-full items-center justify-center gap-[3px] overflow-hidden text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
type="button"
|
||||
variant="ghost-faint"
|
||||
size="small"
|
||||
icon="outline-arrow-to-corner-top-right"
|
||||
class="max-w-full"
|
||||
aria-label={language.t("session.background.moveInline", { keybind: keybind() })}
|
||||
onClick={() => props.onMove?.()}
|
||||
>
|
||||
<span data-slot="session-background-hint-prefix" class="shrink-0">
|
||||
{parts()[0].trim()}
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{language.t("session.background.moveRunning")}</span>
|
||||
<Keybind keys={keys()} variant="neutral" />
|
||||
<span class="min-w-0 truncate">{parts()[1].trim()}</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundWorkSummary(props: { tasks: BackgroundTask[]; mobile?: boolean }) {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [triggerRef, setTriggerRef] = createSignal<HTMLButtonElement>()
|
||||
const tasks = createMemo<BackgroundTask[]>((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)}
|
||||
>
|
||||
<Popover.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
|
||||
aria-label={language.plural("session.background.runningCount", props.tasks.length)}
|
||||
>
|
||||
<Badge class="!w-4 !px-0 !border-v2-border-border-strong !bg-v2-background-bg-layer-03">
|
||||
{props.tasks.length}
|
||||
</Badge>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.t("session.background.running")}
|
||||
active
|
||||
class="min-w-0 flex-1 truncate text-start"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Show when={presence.present()}>
|
||||
<Popover.Trigger
|
||||
ref={setTriggerRef}
|
||||
as="button"
|
||||
type="button"
|
||||
data-component="session-background-summary"
|
||||
class="flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
aria-label={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
>
|
||||
<Icon
|
||||
name="outline-arrow-to-corner-top-right"
|
||||
class="shrink-0 text-v2-icon-icon-muted"
|
||||
/>
|
||||
<TextShimmer
|
||||
as="span"
|
||||
text={language.plural("session.background.tasksRunning", tasks().length)}
|
||||
active
|
||||
class="min-w-0 flex-1 truncate text-start"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
</Show>
|
||||
<Popover.Portal>
|
||||
<Popover.Content
|
||||
data-component="session-background-list"
|
||||
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none"
|
||||
class="z-[60] w-[200px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 p-0.5 shadow-[var(--v2-elevation-floating)] outline-none data-[closed]:animate-out data-[closed]:fade-out data-[closed]:duration-150 motion-reduce:data-[closed]:animate-none"
|
||||
>
|
||||
<For each={props.tasks.slice(0, 10)}>
|
||||
<For each={tasks().slice(0, 10)}>
|
||||
{(task) => (
|
||||
<div
|
||||
data-component="session-background-list-item"
|
||||
|
|
@ -253,7 +270,7 @@ export function SessionSummaryPanel(props: {
|
|||
when={props.branch}
|
||||
fallback={
|
||||
<span class="flex min-w-0 items-center gap-1.5">
|
||||
<span>{language.t("session.summary.noBranch")}</span>
|
||||
<span class="shrink-0 whitespace-nowrap">{language.t("session.summary.noBranch")}</span>
|
||||
<Show when={props.baseBranch}>
|
||||
{(base) => (
|
||||
<>
|
||||
|
|
@ -288,9 +305,17 @@ export function SessionSummaryPanel(props: {
|
|||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.backgroundTasks.length > 0}>
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</Show>
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-150 ease-out motion-reduce:transition-none"
|
||||
classList={{
|
||||
"grid-rows-[1fr]": props.backgroundTasks.length > 0,
|
||||
"grid-rows-[0fr]": props.backgroundTasks.length === 0,
|
||||
}}
|
||||
>
|
||||
<div class="min-h-0 overflow-hidden">
|
||||
<BackgroundWorkSummary tasks={props.backgroundTasks} mobile={props.mobile} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.local && props.diffs && props.diffs.length > 0 && props.moveEligible}>
|
||||
<WorkspaceMoveAction
|
||||
|
|
@ -597,44 +622,38 @@ function MessageTimelineView(
|
|||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
bottomSpacer={
|
||||
<>
|
||||
<Show when={showWorking()}>
|
||||
<Show when={showWorking() || backgroundHintPresence.present()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
class={`flex h-9 items-center gap-2 pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}
|
||||
>
|
||||
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
<Show when={showWorking()}>
|
||||
<div data-component="session-working" role="status">
|
||||
<TextShimmer text={language.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
data-component="session-background-hint-row"
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint onMove={props.background.move} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={backgroundHintPresence.present()}>
|
||||
<div
|
||||
data-component="session-background-hint-row"
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-[1000px] md:mx-auto": props.centered,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={setBackgroundHintRef}
|
||||
class="duration-150 motion-reduce:animate-none"
|
||||
classList={{
|
||||
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
|
||||
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
|
||||
"animate-out fade-out fill-mode-forwards":
|
||||
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
|
||||
}}
|
||||
>
|
||||
<BackgroundMoveHint />
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
deferred={(row) => {
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false
|
||||
|
|
@ -738,7 +757,7 @@ function MessageTimelineView(
|
|||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={sessionID()} keyed>
|
||||
<Show when={!parentID() && sessionID()} keyed>
|
||||
{(id) => (
|
||||
<Menu
|
||||
gutter={6}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@
|
|||
outline: none;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .command-palette-row:is([data-active], :focus-visible) {
|
||||
background: var(--v2-alpha-light-10);
|
||||
}
|
||||
|
||||
.command-palette-row-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@
|
|||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="task-tool-spinner"] {
|
||||
|
|
@ -250,12 +251,16 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
color: var(--v2-text-text-faint);
|
||||
margin-inline-start: auto;
|
||||
margin-inline-start: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
width 0.15s ease,
|
||||
margin-inline-start 0.15s ease,
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
|
@ -265,6 +270,8 @@
|
|||
}
|
||||
|
||||
@media (hover: none) {
|
||||
width: 16px;
|
||||
margin-inline-start: 2px;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
|
@ -296,6 +303,8 @@
|
|||
background: color-mix(in srgb, var(--background-stronger) 88%, transparent);
|
||||
|
||||
[data-component="task-tool-action"] {
|
||||
width: 16px;
|
||||
margin-inline-start: 2px;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
|
@ -320,8 +329,9 @@
|
|||
|
||||
[data-component="task-tool-surface"] {
|
||||
padding: 8px 12px;
|
||||
gap: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
|
||||
[data-component="task-tool-spinner"],
|
||||
|
|
@ -372,7 +382,7 @@
|
|||
background: transparent;
|
||||
|
||||
[data-component="task-tool-surface"] {
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -463,7 +463,7 @@
|
|||
[data-component="collapsible"].tool-collapsible:has(
|
||||
> [data-slot="collapsible-content"] [data-component="bash-output"][data-variant="shell"]
|
||||
) {
|
||||
--tool-content-gap: 6px;
|
||||
--tool-content-gap: 8px;
|
||||
}
|
||||
|
||||
[data-component="edit-trigger"],
|
||||
|
|
|
|||
33
packages/session-ui/src/components/session-error.tsx
Normal file
33
packages/session-ui/src/components/session-error.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<Show when={content().title}>{(title) => <strong class="font-[530]">{title()}</strong>}</Show>
|
||||
{content().detail}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionError(props: { message: string }) {
|
||||
return (
|
||||
<Card variant="error" class="error-card" data-kind="session-error-card">
|
||||
<div class="flex w-full min-w-0 items-center gap-2">
|
||||
<Icon name="outline-hexagonal-warning" class="shrink-0 text-v2-state-fg-danger" />
|
||||
<div class="min-w-0">
|
||||
<SessionErrorMessage message={props.message} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Show when={retry() && (props.show ?? true)}>
|
||||
<div data-slot="session-turn-retry">
|
||||
<Card variant="error" class="error-card">
|
||||
<div class="flex items-start gap-2">
|
||||
<Spinner class="size-4 mt-0.5" />
|
||||
<div class="min-w-0">
|
||||
<Show when={truncated()} fallback={<div data-slot="session-turn-retry-message">{message()}</div>}>
|
||||
<Tooltip appearance="standard" value={retry()?.message ?? ""} placement="top">
|
||||
<div data-slot="session-turn-retry-message" class="cursor-help truncate">
|
||||
{message()}
|
||||
<div data-slot="session-turn-retry" class="w-full min-w-0">
|
||||
<Card variant="error" class="error-card" data-kind="session-retry-card">
|
||||
<div class="flex w-full items-start gap-2">
|
||||
<Icon name="outline-hexagonal-warning" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<Tooltip appearance="standard" value={retry()?.message ?? ""} placement="top">
|
||||
<div data-slot="session-turn-retry-message" class="cursor-help truncate">
|
||||
<SessionErrorMessage message={message()} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Show when={info()}>
|
||||
{(line) => (
|
||||
<div data-slot="session-turn-retry-info">
|
||||
<TextShimmer text={line()} active />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={info()}>{(line) => <div data-slot="session-turn-retry-info">{line()}</div>}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
|||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<span data-slot="basic-tool-tool-indicator" data-component="tool-error-card-icon">
|
||||
<Icon name="circle-exclamation" />
|
||||
<Icon name="outline-hexagonal-warning" />
|
||||
</span>
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
|
|
@ -121,9 +121,6 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
|
|||
</a>
|
||||
</Show>
|
||||
</Show>
|
||||
<span data-slot="tool-error-card-dot" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span data-slot="tool-error-card-summary">{summary()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -68,3 +68,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] [data-component="attachment-card"][data-surface="base"] {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}`}
|
||||
>
|
||||
<bdi dir="auto" class="text-13-medium">
|
||||
<bdi
|
||||
dir="auto"
|
||||
class="font-[530]"
|
||||
classList={{ "text-v2-text-text-faint": message()?.type === "agent-switched" }}
|
||||
>
|
||||
{content().label}
|
||||
</bdi>
|
||||
<Show when={content().data}>
|
||||
{(data) => (
|
||||
<span>
|
||||
{" "}
|
||||
· <bdi dir="auto">{data()}</bdi>
|
||||
<span classList={{ "ms-2": message()?.type === "agent-switched" }}>
|
||||
<Show when={message()?.type !== "agent-switched"}>{" · "}</Show>
|
||||
<bdi dir="auto">{data()}</bdi>
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
|
|
@ -683,9 +689,7 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
return (
|
||||
<Frame row={current()}>
|
||||
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
|
||||
<Card variant="error" class="error-card">
|
||||
{current().text}
|
||||
</Card>
|
||||
<SessionError message={current().text} />
|
||||
</div>
|
||||
</Frame>
|
||||
)
|
||||
|
|
@ -701,3 +705,7 @@ export function createSessionTimelineRowRenderer(input: {
|
|||
|
||||
return { Row }
|
||||
}
|
||||
|
||||
function capitalizeAgent(agent: string) {
|
||||
return agent.slice(0, 1).toUpperCase() + agent.slice(1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1441,7 +1441,7 @@ ToolRegistry.register({
|
|||
</div>
|
||||
<Show when={clickable()}>
|
||||
<div data-component="task-tool-action">
|
||||
<Icon name="chevron-right" size="small" />
|
||||
<Icon name="chevron-right" size="normal" />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
@ -1468,7 +1468,7 @@ ToolRegistry.register({
|
|||
>
|
||||
<div
|
||||
data-component="task-tool-delegating"
|
||||
class="flex h-9 w-fit max-w-full items-center gap-2 rounded-[8px] bg-v2-background-bg-layer-01 p-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px]"
|
||||
class="flex h-9 w-fit max-w-full items-center gap-2 rounded-[8px] bg-v2-background-bg-layer-02 p-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px]"
|
||||
>
|
||||
<Icon name="subagent" size="small" class="shrink-0 text-v2-icon-icon-faint" />
|
||||
<TextShimmer text={i18n.t("ui.tool.agent.delegating")} class="min-w-0 truncate" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
|||
<Button variant="ghost-muted" icon="edit">
|
||||
Ghost muted
|
||||
</Button>
|
||||
<Button variant="ghost-faint" icon="edit">
|
||||
Ghost faint
|
||||
</Button>
|
||||
<Button variant="loading">Loading</Button>
|
||||
</div>
|
||||
),
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -7,7 +7,16 @@ export interface ButtonProps
|
|||
extends ComponentProps<typeof Root>,
|
||||
Pick<ComponentProps<"button">, "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"]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -152,6 +152,14 @@ const icons = {
|
|||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5555 6.66656V2.44434H9.33326M13.5555 2.44434L7.99993 7.99989M13.5555 9.33324V13.5555C13.5555 13.5555 12.7599 13.5555 11.7777 13.5555H2.44438C2.44438 13.5555 2.44438 12.7599 2.44438 11.7777V4.22213C2.44438 3.2399 2.44434 2.44435 2.44434 2.44435H6.66661" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-arrow-to-corner-top-right": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 8.5V2H14V14H8M5 5.66667H10.3333V11M3.66797 12.333L9.94292 6.05806" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"outline-hexagonal-warning": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M10.6667 1.33325H5.33272L1.33334 5.33325L1.33342 10.6917L5.3327 14.691L10.6667 14.6909L14.6911 10.6917V5.33255L10.6667 1.33325Z" stroke="currentColor"/><path d="M8 10.6667H8.00667" stroke="currentColor" stroke-linecap="square"/><path d="M8 7.99992V5.33325" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
},
|
||||
"outline-share": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M13.5554 10.4445V13.5556C13.5554 13.5556 12.7599 13.5556 11.7777 13.5556H4.22211C3.23989 13.5556 2.44434 13.5556 2.44434 13.5556V10.4445M4.88878 5.55557L7.99989 2.44446L11.111 5.55557M7.99989 2.44446L7.99989 9.11112" stroke="currentColor"/>`,
|
||||
|
|
@ -187,10 +195,18 @@ function getIcon(name: IconName) {
|
|||
return { body, viewBox: additionalIconViewBox(name as keyof typeof additionalIcons) }
|
||||
}
|
||||
|
||||
function spriteContent() {
|
||||
return Array.from(new Set<IconName>([...Object.keys(additionalIcons), ...Object.keys(icons)] as IconName[]))
|
||||
.map((name) => `<symbol id="${symbol(name)}" viewBox="${getIcon(name).viewBox}">${getIcon(name).body}</symbol>`)
|
||||
.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<IconName>([...Object.keys(additionalIcons), ...Object.keys(icons)] as IconName[]))
|
||||
.map((name) => `<symbol id="${symbol(name)}" viewBox="${getIcon(name).viewBox}">${getIcon(name).body}</symbol>`)
|
||||
.join("")
|
||||
svg.innerHTML = spriteContent()
|
||||
document.body.insertBefore(svg, document.body.firstChild)
|
||||
spriteInserted = true
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue