mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-09 08:04:10 +00:00
Compare commits
5 Commits
chore/remo
...
kit-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3704dbcee1 | ||
|
|
b47ab35ddf | ||
|
|
0ac8f06521 | ||
|
|
4e46d98156 | ||
|
|
4795806b13 |
3
bun.lock
3
bun.lock
@@ -506,6 +506,7 @@
|
||||
"virtua": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -2962,7 +2963,7 @@
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sessionItemSelector,
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
sessionHeaderSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
titlebarRightSelector,
|
||||
@@ -197,7 +198,6 @@ export async function createTestProject() {
|
||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||
|
||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||
cwd: root,
|
||||
@@ -208,10 +208,7 @@ export async function createTestProject() {
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
try {
|
||||
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||
} catch {}
|
||||
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
export function sessionIDFromUrl(url: string) {
|
||||
@@ -229,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
const header = page.locator(sessionHeaderSelector).first()
|
||||
await expect(header).toBeVisible()
|
||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -247,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
|
||||
|
||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||
|
||||
export const sessionHeaderSelector = "[data-session-title]"
|
||||
|
||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||
|
||||
export const workspaceItemSelector = (slug: string) =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
openSharePopover,
|
||||
withSession,
|
||||
} from "../actions"
|
||||
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||
|
||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||
|
||||
@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
|
||||
@@ -244,6 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
pendingAutoAccept: boolean
|
||||
}>({
|
||||
popover: null,
|
||||
historyIndex: -1,
|
||||
@@ -252,9 +253,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
pendingAutoAccept: false,
|
||||
})
|
||||
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const buttonsSpring = useSpring(
|
||||
() => (store.mode === "normal" ? 1 : 0),
|
||||
{ visualDuration: 0.2, bounce: 0 },
|
||||
)
|
||||
|
||||
const springFade = (t: number): Record<string, string> => ({
|
||||
opacity: `${t}`,
|
||||
transform: `scale(${0.95 + t * 0.05})`,
|
||||
filter: `blur(${(1 - t) * 2}px)`,
|
||||
"pointer-events": t > 0.5 ? "auto" : "none",
|
||||
})
|
||||
|
||||
const commentCount = createMemo(() => {
|
||||
if (store.mode === "shell") return 0
|
||||
@@ -304,6 +316,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(sessionKey, () => {
|
||||
setStore("pendingAutoAccept", false)
|
||||
}),
|
||||
)
|
||||
|
||||
const historyComments = () => {
|
||||
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||
return prompt.context.items().flatMap((item) => {
|
||||
@@ -953,7 +971,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||
const accepting = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
if (!id) return store.pendingAutoAccept
|
||||
return permission.isAutoAccepting(id, sdk.directory)
|
||||
})
|
||||
|
||||
@@ -1246,9 +1264,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div
|
||||
aria-hidden={store.mode !== "normal"}
|
||||
class="flex items-center gap-1"
|
||||
style={{
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }}
|
||||
>
|
||||
<TooltipKeybind
|
||||
placement="top"
|
||||
@@ -1260,11 +1276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={springFade(buttonsSpring())}
|
||||
onClick={pick}
|
||||
disabled={store.mode !== "normal"}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
@@ -1302,11 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
style={{
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
}}
|
||||
style={springFade(buttonsSpring())}
|
||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -1328,7 +1336,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (!params.id) {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
setStore("pendingAutoAccept", (value) => !value)
|
||||
return
|
||||
}
|
||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||
@@ -1362,13 +1370,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
|
||||
<div
|
||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||
style={{
|
||||
padding: "0 4px 0 8px",
|
||||
opacity: 1 - buttonsSpring(),
|
||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ padding: "0 4px 0 8px", ...springFade(1 - buttonsSpring()) }}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||
<div class="size-4 shrink-0" />
|
||||
@@ -1387,13 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={local.agent.set}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
@@ -1411,13 +1407,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
style={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
@@ -1446,13 +1436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
triggerProps={{
|
||||
variant: "ghost",
|
||||
size: "normal",
|
||||
style: {
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
},
|
||||
style: { height: "28px", ...springFade(buttonsSpring()) },
|
||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||
}}
|
||||
>
|
||||
@@ -1484,13 +1468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{
|
||||
height: "28px",
|
||||
opacity: buttonsSpring(),
|
||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { batch, type Accessor } from "solid-js"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const language = useLanguage()
|
||||
const params = useParams()
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
@@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.worktreeCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.sessionCreateFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
@@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("prompt.toast.shellSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
restoreInput()
|
||||
})
|
||||
@@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
messageID,
|
||||
})
|
||||
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
addOptimisticMessage()
|
||||
batch(() => {
|
||||
removeCommentItems(commentItems)
|
||||
clearInput()
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
sync.set("session_status", session.id, { type: "busy" })
|
||||
}
|
||||
addOptimisticMessage()
|
||||
})
|
||||
|
||||
const waitForWorktree = async () => {
|
||||
const worktree = WorktreeState.get(sessionDirectory)
|
||||
@@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
description: toastError(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
restoreCommentItems(commentItems)
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { findLast, same } from "@opencode-ai/util/array"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
|
||||
@@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.shellToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||
>
|
||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||
<Switch
|
||||
checked={settings.general.editToolPartsExpanded()}
|
||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
@@ -177,14 +178,6 @@ export function formatKeybind(config: string): string {
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
name: "Command",
|
||||
init: () => {
|
||||
|
||||
@@ -353,7 +353,6 @@ function createGlobalSync() {
|
||||
.update({ config })
|
||||
.then(bootstrap)
|
||||
.then(() => {
|
||||
queue.refresh()
|
||||
setGlobalStore("reload", undefined)
|
||||
queue.refresh()
|
||||
})
|
||||
|
||||
@@ -146,7 +146,6 @@ const DICT: Record<Locale, Dictionary> = {
|
||||
}
|
||||
|
||||
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
|
||||
{ locale: "en", match: (language) => language.startsWith("en") },
|
||||
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
|
||||
{ locale: "zh", match: (language) => language.startsWith("zh") },
|
||||
{ locale: "ko", match: (language) => language.startsWith("ko") },
|
||||
@@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
)
|
||||
|
||||
const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
|
||||
console.log("locale", locale())
|
||||
const intl = createMemo(() => INTL[locale()])
|
||||
|
||||
const dict = createMemo<Dictionary>(() => DICT[locale()])
|
||||
|
||||
@@ -8,7 +8,7 @@ import { usePlatform } from "./platform"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { same } from "@/utils/same"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond"
|
||||
import { autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => {
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("falls back to directory-level auto-accept", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("session-level override takes precedence over directory-level", () => {
|
||||
const directory = "/tmp/project"
|
||||
const sessions = [session({ id: "root" })]
|
||||
const autoAccept = {
|
||||
[`${base64Encode(directory)}/*`]: true,
|
||||
[`${base64Encode(directory)}/root`]: false,
|
||||
}
|
||||
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDirectoryAutoAccepting", () => {
|
||||
test("returns true when directory key is set", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: true }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when directory key is not set", () => {
|
||||
expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when directory key is explicitly false", () => {
|
||||
const directory = "/tmp/project"
|
||||
const autoAccept = { [`${base64Encode(directory)}/*`]: false }
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) {
|
||||
return `${base64Encode(directory)}/${sessionID}`
|
||||
}
|
||||
|
||||
export function directoryAcceptKey(directory: string) {
|
||||
return `${base64Encode(directory)}/*`
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const directoryKey = directory ? directoryAcceptKey(directory) : undefined
|
||||
return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined)
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
return autoAccept[key] ?? false
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createMemo, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
@@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
} from "./permission-auto-respond"
|
||||
import { acceptKey, autoRespondsPermission } from "./permission-auto-respond"
|
||||
|
||||
type PermissionRespondFn = (input: {
|
||||
sessionID: string
|
||||
@@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
}),
|
||||
)
|
||||
|
||||
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||
createEffect(() => {
|
||||
if (!ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
if (typeof perm === "string" && perm === "allow") {
|
||||
const key = directoryAcceptKey(directory)
|
||||
if (store.autoAccept[key] === undefined) {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const MAX_RESPONDED = 1000
|
||||
const RESPONDED_TTL_MS = 60 * 60 * 1000
|
||||
const responded = new Map<string, number>()
|
||||
@@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
return isDirectoryAutoAccepting(store.autoAccept, directory)
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
|
||||
return autoRespondsPermission(store.autoAccept, session, permission, directory)
|
||||
@@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
})
|
||||
onCleanup(unsubscribe)
|
||||
|
||||
function enableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = true
|
||||
}),
|
||||
)
|
||||
|
||||
globalSDK.client.permission
|
||||
.list({ directory })
|
||||
.then((x) => {
|
||||
if (!isAutoAcceptingDirectory(directory)) return
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id) continue
|
||||
if (!shouldAutoRespond(perm, directory)) continue
|
||||
respondOnce(perm, directory)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
function disableDirectory(directory: string) {
|
||||
const key = directoryAcceptKey(directory)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.autoAccept[key] = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function enable(sessionID: string, directory: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
const version = bumpEnableVersion(sessionID, directory)
|
||||
@@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
return shouldAutoRespond(permission, directory)
|
||||
},
|
||||
isAutoAccepting,
|
||||
isAutoAcceptingDirectory,
|
||||
toggleAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) {
|
||||
disable(sessionID, directory)
|
||||
@@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
|
||||
enable(sessionID, directory)
|
||||
},
|
||||
toggleAutoAcceptDirectory(directory: string) {
|
||||
if (isAutoAcceptingDirectory(directory)) {
|
||||
disableDirectory(directory)
|
||||
return
|
||||
}
|
||||
enableDirectory(directory)
|
||||
},
|
||||
enableAutoAccept(sessionID: string, directory: string) {
|
||||
if (isAutoAccepting(sessionID, directory)) return
|
||||
enable(sessionID, directory)
|
||||
@@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
|
||||
disable(sessionID, directory)
|
||||
},
|
||||
permissionsEnabled,
|
||||
isPermissionAllowAll(directory: string) {
|
||||
const [childStore] = globalSync.child(directory)
|
||||
const perm = childStore.config.permission
|
||||
return typeof perm === "string" && perm === "allow"
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface Settings {
|
||||
autoSave: boolean
|
||||
releaseNotes: boolean
|
||||
showReasoningSummaries: boolean
|
||||
shellToolPartsExpanded: boolean
|
||||
editToolPartsExpanded: boolean
|
||||
}
|
||||
updates: {
|
||||
startup: boolean
|
||||
@@ -46,8 +44,6 @@ const defaultSettings: Settings = {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
startup: true,
|
||||
@@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
setShowReasoningSummaries(value: boolean) {
|
||||
setStore("general", "showReasoningSummaries", value)
|
||||
},
|
||||
shellToolPartsExpanded: withFallback(
|
||||
() => store.general?.shellToolPartsExpanded,
|
||||
defaultSettings.general.shellToolPartsExpanded,
|
||||
),
|
||||
setShellToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "shellToolPartsExpanded", value)
|
||||
},
|
||||
editToolPartsExpanded: withFallback(
|
||||
() => store.general?.editToolPartsExpanded,
|
||||
defaultSettings.general.editToolPartsExpanded,
|
||||
),
|
||||
setEditToolPartsExpanded(value: boolean) {
|
||||
setStore("general", "editToolPartsExpanded", value)
|
||||
},
|
||||
},
|
||||
updates: {
|
||||
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
|
||||
|
||||
@@ -541,12 +541,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني",
|
||||
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
|
||||
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -547,12 +547,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -613,12 +613,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci",
|
||||
"settings.general.row.wayland.title": "Koristi nativni Wayland",
|
||||
"settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -608,11 +608,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Brug native Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -556,12 +556,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen",
|
||||
"settings.general.row.wayland.title": "Natives Wayland verwenden",
|
||||
"settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -636,13 +636,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
|
||||
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Show shell tool parts expanded by default in the timeline",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -616,12 +616,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo",
|
||||
"settings.general.row.wayland.title": "Usar Wayland nativo",
|
||||
"settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -553,12 +553,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Afficher les parties de l'outil shell développées par défaut dans la chronologie",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie",
|
||||
"settings.general.row.wayland.title": "Utiliser Wayland natif",
|
||||
"settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -545,12 +545,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"タイムラインで shell ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します",
|
||||
"settings.general.row.wayland.title": "ネイティブWaylandを使用",
|
||||
"settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -546,12 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다",
|
||||
"settings.general.row.wayland.title": "네이티브 Wayland 사용",
|
||||
"settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -616,11 +616,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen",
|
||||
"settings.general.row.wayland.title": "Bruk innebygd Wayland",
|
||||
"settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -546,12 +546,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu",
|
||||
"settings.general.row.wayland.title": "Użyj natywnego Wayland",
|
||||
"settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -614,12 +614,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Показывать элементы инструмента shell в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию",
|
||||
"settings.general.row.wayland.title": "Использовать нативный Wayland",
|
||||
"settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.",
|
||||
"settings.general.row.wayland.tooltip":
|
||||
|
||||
@@ -608,11 +608,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์",
|
||||
"settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ",
|
||||
"settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท",
|
||||
"settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า",
|
||||
|
||||
@@ -623,13 +623,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster",
|
||||
"settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet",
|
||||
"settings.general.row.shellToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
"settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet",
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster",
|
||||
|
||||
"settings.general.row.wayland.title": "Yerel Wayland kullan",
|
||||
"settings.general.row.wayland.description":
|
||||
"Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.",
|
||||
|
||||
@@ -607,10 +607,6 @@ export const dict = {
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分",
|
||||
"settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。",
|
||||
"settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。",
|
||||
|
||||
@@ -603,10 +603,6 @@ export const dict = {
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
|
||||
"settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊",
|
||||
"settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊",
|
||||
"settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊",
|
||||
"settings.general.row.wayland.title": "使用原生 Wayland",
|
||||
"settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。",
|
||||
"settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。",
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
ParentProps,
|
||||
Show,
|
||||
untrack,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout, LocalProject } from "@/context/layout"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import {
|
||||
childMapByParent,
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
@@ -1843,7 +1846,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
|
||||
>
|
||||
<Show when={panelProps.project}>
|
||||
<Show when={panelProps.project} keyed>
|
||||
{(p) => (
|
||||
<>
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
@@ -1852,7 +1855,7 @@ export default function Layout(props: ParentProps) {
|
||||
<InlineEditor
|
||||
id={`project:${projectId()}`}
|
||||
value={projectName}
|
||||
onSave={(next) => renameProject(p(), next)}
|
||||
onSave={(next) => renameProject(p, next)}
|
||||
class="text-14-medium text-text-strong truncate"
|
||||
displayClass="text-14-medium text-text-strong truncate"
|
||||
stopPropagation
|
||||
@@ -1861,7 +1864,7 @@ export default function Layout(props: ParentProps) {
|
||||
<Tooltip
|
||||
placement="bottom"
|
||||
gutter={2}
|
||||
value={p().worktree}
|
||||
value={p.worktree}
|
||||
class="shrink-0"
|
||||
contentStyle={{
|
||||
"max-width": "640px",
|
||||
@@ -1869,7 +1872,7 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{p().worktree.replace(homedir(), "~")}
|
||||
{p.worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -1880,7 +1883,7 @@ export default function Layout(props: ParentProps) {
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
@@ -1889,24 +1892,24 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
<DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Content class="mt-1">
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p())}>
|
||||
<DropdownMenu.Item onSelect={() => showEditProjectDialog(p)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-workspaces-toggle"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p())}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
|
||||
onSelect={() => toggleProjectWorkspaces(p)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{layout.sidebar.workspaces(p().worktree)()
|
||||
{layout.sidebar.workspaces(p.worktree)()
|
||||
? language.t("sidebar.workspaces.disable")
|
||||
: language.t("sidebar.workspaces.enable")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
data-action="project-clear-notifications"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
disabled={unseenCount() === 0}
|
||||
onSelect={clearNotifications}
|
||||
>
|
||||
@@ -1917,8 +1920,8 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
data-action="project-close-menu"
|
||||
data-project={base64Encode(p().worktree)}
|
||||
onSelect={() => closeProject(p().worktree)}
|
||||
data-project={base64Encode(p.worktree)}
|
||||
onSelect={() => closeProject(p.worktree)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -1934,19 +1937,25 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p().worktree)}/session`)}
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
project={p()}
|
||||
project={p}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
@@ -1956,9 +1965,15 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
@@ -1981,7 +1996,7 @@ export default function Layout(props: ParentProps) {
|
||||
<SortableWorkspace
|
||||
ctx={workspaceSidebarCtx}
|
||||
directory={directory}
|
||||
project={p()}
|
||||
project={p}
|
||||
sortNow={sortNow}
|
||||
mobile={panelProps.mobile}
|
||||
/>
|
||||
@@ -2093,9 +2108,11 @@ export default function Layout(props: ParentProps) {
|
||||
/>
|
||||
</div>
|
||||
<Show when={!layout.sidebar.opened() ? hoverProjectData()?.worktree : undefined} keyed>
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={hoverProjectData()} />
|
||||
</div>
|
||||
{(worktree) => (
|
||||
<div class="absolute inset-y-0 left-16 z-50 flex" onMouseEnter={aim.reset}>
|
||||
<SidebarPanel project={hoverProjectData()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={layout.sidebar.opened()}>
|
||||
<ResizeHandle
|
||||
|
||||
@@ -31,16 +31,17 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { isEditableTarget } from "@/utils/dom"
|
||||
import { createOpenReviewFile } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
|
||||
@@ -118,13 +119,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
// SolidJS updates the DOM synchronously. Force reflow so the browser
|
||||
// processes the new layout, then restore scrollTop before paint.
|
||||
// With column-reverse + overflow-anchor:none the same scrollTop value
|
||||
// keeps the same distance from the bottom — no delta math needed.
|
||||
void el.scrollHeight
|
||||
el.scrollTop = beforeTop
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
@@ -207,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
// With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
@@ -285,7 +287,6 @@ export default function Page() {
|
||||
bottom: true,
|
||||
},
|
||||
})
|
||||
|
||||
const composer = createSessionComposerState()
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
@@ -430,20 +431,8 @@ export default function Page() {
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
createComputed((prev) => {
|
||||
const key = sessionKey()
|
||||
if (key !== prev) {
|
||||
setStore("deferRender", true)
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => setStore("deferRender", false), 0)
|
||||
})
|
||||
}
|
||||
return key
|
||||
}, sessionKey())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
|
||||
@@ -454,11 +443,6 @@ export default function Page() {
|
||||
return "main"
|
||||
})
|
||||
|
||||
const activeMessage = createMemo(() => {
|
||||
if (!store.messageId) return lastUserMessage()
|
||||
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
|
||||
return found ?? lastUserMessage()
|
||||
})
|
||||
const setActiveMessage = (message: UserMessage | undefined) => {
|
||||
setStore("messageId", message?.id)
|
||||
}
|
||||
@@ -620,11 +604,6 @@ export default function Page() {
|
||||
saveLabel: language.t("common.save"),
|
||||
}))
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null | undefined) => {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
|
||||
}
|
||||
|
||||
const deepActiveElement = () => {
|
||||
let current: Element | null = document.activeElement
|
||||
while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
|
||||
@@ -755,12 +734,35 @@ export default function Page() {
|
||||
loadingClass: string
|
||||
emptyClass: string
|
||||
}) => (
|
||||
<Show when={!store.deferRender}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<Switch>
|
||||
<Match when={store.changes === "turn" && !!params.id}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={emptyTurn()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
@@ -777,64 +779,39 @@ export default function Page() {
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={hasReview()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
fallback={<div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>}
|
||||
>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewTab
|
||||
title={changesTitle()}
|
||||
empty={
|
||||
store.changes === "turn" ? (
|
||||
emptyTurn()
|
||||
) : (
|
||||
<div class={input.emptyClass}>
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
diffs={reviewDiffs}
|
||||
view={view}
|
||||
diffStyle={input.diffStyle}
|
||||
onDiffStyleChange={input.onDiffStyleChange}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
onViewFile={openReviewFile}
|
||||
classes={input.classes}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
|
||||
const reviewPanel = () => (
|
||||
@@ -1045,7 +1022,10 @@ export default function Page() {
|
||||
const updateScrollState = (el: HTMLDivElement) => {
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
const overflow = max > 1
|
||||
const bottom = !overflow || el.scrollTop >= max - 2
|
||||
// If auto-scroll is tracking the bottom, always report bottom: true
|
||||
// to prevent the scroll-down arrow from flashing during height animations
|
||||
// With column-reverse, scrollTop=0 is at the bottom
|
||||
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
|
||||
|
||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
||||
setUi("scroll", { overflow, bottom })
|
||||
@@ -1068,7 +1048,7 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.forceScrollToBottom()
|
||||
autoScroll.smoothScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -1136,9 +1116,8 @@ export default function Page() {
|
||||
|
||||
const el = scroller
|
||||
const delta = next - dockHeight
|
||||
const stick = el
|
||||
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
|
||||
: false
|
||||
// With column-reverse, near bottom = scrollTop near 0
|
||||
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
|
||||
|
||||
dockHeight = next
|
||||
|
||||
@@ -1204,50 +1183,49 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
diffStyle: "unified",
|
||||
classes: {
|
||||
root: "pb-8",
|
||||
header: "px-4",
|
||||
container: "px-4",
|
||||
},
|
||||
loadingClass: "px-4 py-4 text-text-weak",
|
||||
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
|
||||
})}
|
||||
scroll={ui.scroll}
|
||||
onResumeScroll={resumeScroll}
|
||||
setScrollRef={setScrollRef}
|
||||
onScheduleScrollState={scheduleScrollState}
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
onPreserveScrollAnchor={autoScroll.preserve}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
autoScroll.contentRef(el)
|
||||
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
const root = scroller
|
||||
if (root) scheduleScrollState(root)
|
||||
}}
|
||||
turnStart={historyWindow.turnStart()}
|
||||
historyMore={historyMore()}
|
||||
historyLoading={historyLoading()}
|
||||
onLoadEarlier={() => {
|
||||
void historyWindow.loadAndReveal()
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<NewSessionView
|
||||
@@ -1273,7 +1251,6 @@ export default function Page() {
|
||||
|
||||
<SessionComposerRegion
|
||||
state={composer}
|
||||
ready={!store.deferRender && messagesReady()}
|
||||
centered={centered()}
|
||||
inputRef={(el) => {
|
||||
inputRef = el
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
/**
|
||||
* Composer Island migration tracker
|
||||
*
|
||||
* Goal
|
||||
* - Replace the split composer stack (PromptInput + question/permission/todo docks)
|
||||
* with a single morphing ComposerIsland + app runtime adapter.
|
||||
*
|
||||
* Current status
|
||||
* - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`).
|
||||
* - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`).
|
||||
*
|
||||
* Feature parity checklist
|
||||
* - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error).
|
||||
* - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers).
|
||||
* - [x] Shell mode wiring parity (single mode source for tray + editor).
|
||||
* - [x] Cursor scroll-into-view behavior in island editor.
|
||||
* - [x] Attachment parity in island UI: image + PDF support and file picker wiring.
|
||||
* - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention.
|
||||
* - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation.
|
||||
* - [x] Auto-accept + stop/send state parity in island UI.
|
||||
* - [x] Async @mention/slash sourcing parity in island via runtime search hooks.
|
||||
* - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending).
|
||||
* - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending).
|
||||
* - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending).
|
||||
* - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item).
|
||||
*/
|
||||
export { SessionComposerRegion } from "./session-composer-region"
|
||||
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
|
||||
export type { SessionComposerState } from "./session-composer-state"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Show, createMemo, createSignal, createEffect } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -9,11 +9,12 @@ import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
|
||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||
import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock"
|
||||
|
||||
const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
|
||||
export function SessionComposerRegion(props: {
|
||||
state: SessionComposerState
|
||||
ready: boolean
|
||||
centered: boolean
|
||||
inputRef: (el: HTMLDivElement) => void
|
||||
newSessionWorktree: string
|
||||
@@ -21,23 +22,6 @@ export function SessionComposerRegion(props: {
|
||||
onSubmit: () => void
|
||||
onResponseSubmit: () => void
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
dockOpenVisualDuration?: number
|
||||
dockOpenBounce?: number
|
||||
dockCloseVisualDuration?: number
|
||||
dockCloseBounce?: number
|
||||
drawerExpandVisualDuration?: number
|
||||
drawerExpandBounce?: number
|
||||
drawerCollapseVisualDuration?: number
|
||||
drawerCollapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const params = useParams()
|
||||
const prompt = usePrompt()
|
||||
@@ -63,73 +47,15 @@ export function SessionComposerRegion(props: {
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
const [gate, setGate] = createStore({
|
||||
ready: false,
|
||||
})
|
||||
let timer: number | undefined
|
||||
let frame: number | undefined
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== undefined) {
|
||||
window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
}
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
sessionKey()
|
||||
const ready = props.ready
|
||||
const delay = 140
|
||||
|
||||
clear()
|
||||
setGate("ready", false)
|
||||
if (!ready) return
|
||||
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined
|
||||
timer = window.setTimeout(() => {
|
||||
setGate("ready", true)
|
||||
timer = undefined
|
||||
}, delay)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(clear)
|
||||
|
||||
const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing())
|
||||
const config = createMemo(() =>
|
||||
open()
|
||||
? {
|
||||
visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockOpenBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.dockCloseBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
const open = createMemo(() => props.state.dock() && !props.state.closing())
|
||||
const progress = useSpring(
|
||||
() => (open() ? 1 : 0),
|
||||
DOCK_SPRING,
|
||||
)
|
||||
const progress = useSpring(() => (open() ? 1 : 0), config)
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, progress())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const dock = createMemo(() => props.state.dock() || progress() > 0.001)
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef()
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
const height = useElementHeight(contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -179,10 +105,10 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"overflow-hidden": true,
|
||||
"pointer-events-none": value() < 0.98,
|
||||
"pointer-events-none": progress() < 0.98,
|
||||
}}
|
||||
style={{
|
||||
"max-height": `${full() * value()}px`,
|
||||
"max-height": `${full() * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={setContentRef}>
|
||||
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: {
|
||||
title={language.t("session.todo.title")}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
visualDuration={props.visualDuration}
|
||||
bounce={props.bounce}
|
||||
expandVisualDuration={props.drawerExpandVisualDuration}
|
||||
expandBounce={props.drawerExpandBounce}
|
||||
collapseVisualDuration={props.drawerCollapseVisualDuration}
|
||||
collapseBounce={props.drawerCollapseBounce}
|
||||
subtitleDuration={props.subtitleDuration}
|
||||
subtitleTravel={props.subtitleTravel}
|
||||
subtitleEdge={props.subtitleEdge}
|
||||
countDuration={props.countDuration}
|
||||
countMask={props.countMask}
|
||||
countMaskHeight={props.countMaskHeight}
|
||||
countWidthDuration={props.countWidthDuration}
|
||||
dockProgress={progress()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: {
|
||||
"relative z-10": true,
|
||||
}}
|
||||
style={{
|
||||
"margin-top": `${-36 * value()}px`,
|
||||
"margin-top": `${-36 * progress()}px`,
|
||||
}}
|
||||
>
|
||||
<PromptInput
|
||||
|
||||
@@ -29,7 +29,11 @@ export function createSessionComposerBlocked() {
|
||||
})
|
||||
}
|
||||
|
||||
export function createSessionComposerState(options?: { closeMs?: number | (() => number) }) {
|
||||
export function createSessionComposerState(
|
||||
options?: {
|
||||
closeMs?: number | (() => number)
|
||||
},
|
||||
) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
@@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 }
|
||||
export const COLLAPSED_HEIGHT = 78
|
||||
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
|
||||
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
|
||||
|
||||
function dot(status: Todo["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
@@ -40,19 +46,6 @@ export function SessionTodoDock(props: {
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress?: number
|
||||
visualDuration?: number
|
||||
bounce?: number
|
||||
expandVisualDuration?: number
|
||||
expandBounce?: number
|
||||
collapseVisualDuration?: number
|
||||
collapseBounce?: number
|
||||
subtitleDuration?: number
|
||||
subtitleTravel?: number
|
||||
subtitleEdge?: number
|
||||
countDuration?: number
|
||||
countMask?: number
|
||||
countMaskHeight?: number
|
||||
countWidthDuration?: number
|
||||
}) {
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
@@ -73,39 +66,12 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const config = createMemo(() =>
|
||||
store.collapsed
|
||||
? {
|
||||
visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.collapseBounce ?? props.bounce ?? 0,
|
||||
}
|
||||
: {
|
||||
visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3,
|
||||
bounce: props.expandBounce ?? props.bounce ?? 0,
|
||||
},
|
||||
)
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), config)
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
const hide = createMemo(() => Math.max(value(), shut()))
|
||||
const off = createMemo(() => hide() > 0.98)
|
||||
const turn = createMemo(() => Math.max(0, Math.min(1, value())))
|
||||
const [height, setHeight] = createSignal(320)
|
||||
const full = createMemo(() => Math.max(78, height()))
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING)
|
||||
const shut = createMemo(() => 1 - (props.dockProgress ?? 1))
|
||||
const hide = createMemo(() => Math.max(collapse(), shut()))
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const el = contentRef
|
||||
if (!el) return
|
||||
const update = () => {
|
||||
setHeight(el.getBoundingClientRect().height)
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(el)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
const height = useElementHeight(() => contentRef, 320)
|
||||
const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height()))
|
||||
|
||||
return (
|
||||
<DockTray
|
||||
@@ -113,7 +79,7 @@ export function SessionTodoDock(props: {
|
||||
style={{
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"max-height": `${Math.max(78, full() - value() * (full() - 78))}px`,
|
||||
"max-height": `${Math.max(COLLAPSED_HEIGHT, full() - collapse() * (full() - COLLAPSED_HEIGHT))}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef}>
|
||||
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: {
|
||||
class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible"
|
||||
aria-label={label()}
|
||||
style={{
|
||||
"--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`,
|
||||
"--tool-motion-mask": `${props.countMask ?? 18}%`,
|
||||
"--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`,
|
||||
"--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`,
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`,
|
||||
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
|
||||
"--tool-motion-mask": `${COUNT.mask}%`,
|
||||
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
|
||||
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
|
||||
opacity: `${1 - shut()}`,
|
||||
filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none",
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
@@ -157,9 +123,9 @@ export function SessionTodoDock(props: {
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
duration={props.subtitleDuration ?? 600}
|
||||
travel={props.subtitleTravel ?? 25}
|
||||
edge={props.subtitleEdge ?? 17}
|
||||
duration={SUBTITLE.duration}
|
||||
travel={SUBTITLE.travel}
|
||||
edge={SUBTITLE.edge}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
@@ -173,7 +139,7 @@ export function SessionTodoDock(props: {
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${turn() * 180}deg)` }}
|
||||
style={{ transform: `rotate(${collapse() * 180}deg)` }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={store.collapsed || off()}
|
||||
class="pb-2"
|
||||
aria-hidden={store.collapsed}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
style={{
|
||||
visibility: off() ? "hidden" : "visible",
|
||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||
filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`,
|
||||
opacity: `${1 - hide()}`,
|
||||
filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none",
|
||||
visibility: hide() > 0.98 ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
||||
@@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
opacity: todo().status === "pending" ? "0.94" : "1",
|
||||
opacity: todo().status === "pending" ? "0.5" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
@@ -292,12 +259,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition:
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
"color 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
opacity: todo().status === "pending" ? "0.92" : "1",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
|
||||
@@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("does not mark when nested scroller can consume movement", () => {
|
||||
test("does not mark when scroller can consume movement", () => {
|
||||
expect(
|
||||
shouldMarkBoundaryGesture({
|
||||
delta: 20,
|
||||
scrollTop: 200,
|
||||
scrollTop: 300,
|
||||
scrollHeight: 1000,
|
||||
clientHeight: 400,
|
||||
}),
|
||||
|
||||
@@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: {
|
||||
if (max <= 1) return true
|
||||
if (!input.delta) return false
|
||||
|
||||
if (input.delta < 0) return input.scrollTop + input.delta <= 0
|
||||
|
||||
const remaining = max - input.scrollTop
|
||||
return input.delta > remaining
|
||||
const top = Math.max(0, Math.min(max, input.scrollTop))
|
||||
if (input.delta < 0) return -input.delta > top
|
||||
const bottom = max - top
|
||||
return input.delta > bottom
|
||||
}
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import {
|
||||
For,
|
||||
Index,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
Show,
|
||||
startTransition,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
|
||||
|
||||
type MessageComment = {
|
||||
path: string
|
||||
@@ -33,7 +37,9 @@ type MessageComment = {
|
||||
}
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const isDefaultSessionTitle = (title?: string) =>
|
||||
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
|
||||
const messageComments = (parts: Part[]): MessageComment[] =>
|
||||
parts.flatMap((part) => {
|
||||
@@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
completedSession: "",
|
||||
count: 0,
|
||||
})
|
||||
const [readySession, setReadySession] = createSignal("")
|
||||
let active = ""
|
||||
|
||||
const stagedCount = createMemo(() => {
|
||||
const total = input.messages().length
|
||||
@@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = undefined
|
||||
}
|
||||
const scheduleReady = (sessionKey: string) => {
|
||||
if (input.sessionKey() !== sessionKey) return
|
||||
if (readySession() === sessionKey) return
|
||||
setReadySession(sessionKey)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
|
||||
([sessionKey, isWindowed, total]) => {
|
||||
const switched = active !== sessionKey
|
||||
if (switched) {
|
||||
active = sessionKey
|
||||
setReadySession("")
|
||||
}
|
||||
|
||||
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
|
||||
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
|
||||
|
||||
if (staging && !switched && shouldStage && frame !== undefined) return
|
||||
|
||||
cancel()
|
||||
const shouldStage =
|
||||
isWindowed &&
|
||||
total > input.config.init &&
|
||||
state.completedSession !== sessionKey &&
|
||||
state.activeSession !== sessionKey
|
||||
|
||||
if (shouldStage) setReadySession("")
|
||||
if (!shouldStage) {
|
||||
setState({ activeSession: "", count: total })
|
||||
setState({
|
||||
activeSession: "",
|
||||
completedSession: isWindowed ? sessionKey : state.completedSession,
|
||||
count: total,
|
||||
})
|
||||
if (total <= 0) {
|
||||
setReadySession("")
|
||||
return
|
||||
}
|
||||
if (readySession() !== sessionKey) scheduleReady(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
let count = Math.min(total, input.config.init)
|
||||
if (staging) count = Math.min(total, Math.max(count, state.count))
|
||||
setState({ activeSession: sessionKey, count })
|
||||
|
||||
const step = () => {
|
||||
@@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
}
|
||||
const currentTotal = input.messages().length
|
||||
count = Math.min(currentTotal, count + input.config.batch)
|
||||
setState("count", count)
|
||||
startTransition(() => setState("count", count))
|
||||
if (count >= currentTotal) {
|
||||
setState({ completedSession: sessionKey, activeSession: "" })
|
||||
frame = undefined
|
||||
scheduleReady(sessionKey)
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
@@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
const key = input.sessionKey()
|
||||
return state.activeSession === key && state.completedSession !== key
|
||||
})
|
||||
const ready = createMemo(() => readySession() === input.sessionKey())
|
||||
|
||||
onCleanup(cancel)
|
||||
return { messages: stagedUserMessages, isStaging }
|
||||
onCleanup(() => {
|
||||
cancel()
|
||||
})
|
||||
return { messages: stagedUserMessages, isStaging, ready }
|
||||
}
|
||||
|
||||
export function MessageTimeline(props: {
|
||||
@@ -196,6 +231,7 @@ export function MessageTimeline(props: {
|
||||
onScrollSpyScroll: () => void
|
||||
onTurnBackfillScroll: () => void
|
||||
onAutoScrollInteraction: (event: MouseEvent) => void
|
||||
onPreserveScrollAnchor: (target: HTMLElement) => void
|
||||
centered: boolean
|
||||
setContentRef: (el: HTMLDivElement) => void
|
||||
turnStart: number
|
||||
@@ -210,14 +246,19 @@ export function MessageTimeline(props: {
|
||||
let touchGesture: number | undefined
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const settings = useSettings()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
||||
const trigger = (target: EventTarget | null) => {
|
||||
const next =
|
||||
target instanceof Element
|
||||
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
|
||||
: undefined
|
||||
if (!(next instanceof HTMLElement)) return
|
||||
return next
|
||||
}
|
||||
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const sessionID = createMemo(() => params.id)
|
||||
const sessionMessages = createMemo(() => {
|
||||
@@ -230,28 +271,20 @@ export function MessageTimeline(props: {
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
),
|
||||
)
|
||||
const sessionStatus = createMemo(() => {
|
||||
const id = sessionID()
|
||||
if (!id) return idle
|
||||
return sync.data.session_status[id] ?? idle
|
||||
})
|
||||
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
|
||||
const activeMessageID = createMemo(() => {
|
||||
const parentID = pending()?.parentID
|
||||
if (parentID) {
|
||||
const messages = sessionMessages()
|
||||
const result = Binary.search(messages, parentID, (message) => message.id)
|
||||
const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID)
|
||||
if (message && message.role === "user") return message.id
|
||||
const messages = sessionMessages()
|
||||
const message = pending()
|
||||
if (message?.parentID) {
|
||||
const result = Binary.search(messages, message.parentID, (item) => item.id)
|
||||
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
|
||||
if (parent?.role === "user") return parent.id
|
||||
}
|
||||
|
||||
const status = sessionStatus()
|
||||
if (status.type !== "idle") {
|
||||
const messages = sessionMessages()
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
if (sessionStatus() === "idle") return undefined
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "user") return messages[i].id
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const info = createMemo(() => {
|
||||
@@ -259,9 +292,19 @@ export function MessageTimeline(props: {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
})
|
||||
const titleValue = createMemo(() => info()?.title)
|
||||
const titleValue = createMemo(() => {
|
||||
const title = info()?.title
|
||||
if (!title) return
|
||||
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
|
||||
return title
|
||||
})
|
||||
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
|
||||
const headerTitle = createMemo(
|
||||
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
|
||||
)
|
||||
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
|
||||
const stageCfg = { init: 1, batch: 3 }
|
||||
const staging = createTimelineStaging({
|
||||
sessionKey,
|
||||
@@ -269,212 +312,7 @@ export function MessageTimeline(props: {
|
||||
messages: () => props.renderedUserMessages,
|
||||
config: stageCfg,
|
||||
})
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
if (err && typeof err === "object" && "data" in err) {
|
||||
const data = (err as { data?: { message?: string } }).data
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return language.t("common.requestFailed")
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!sessionID()) return
|
||||
setTitle({ editing: true, draft: titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((s) => s.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
|
||||
const index = sessions.findIndex((s) => s.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
const rendered = createMemo(() => staging.messages().map((message) => message.id))
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -498,6 +336,16 @@ export function MessageTimeline(props: {
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<SessionTimelineHeader
|
||||
centered={props.centered}
|
||||
showHeader={showHeader}
|
||||
sessionKey={sessionKey}
|
||||
sessionID={sessionID}
|
||||
parentID={parentID}
|
||||
titleValue={titleValue}
|
||||
headerTitle={headerTitle}
|
||||
placeholderTitle={placeholderTitle}
|
||||
/>
|
||||
<ScrollView
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
@@ -532,9 +380,18 @@ export function MessageTimeline(props: {
|
||||
touchGesture = undefined
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const next = trigger(e.target)
|
||||
if (next) props.onPreserveScrollAnchor(next)
|
||||
|
||||
if (e.target !== e.currentTarget) return
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return
|
||||
const next = trigger(e.target)
|
||||
if (!next) return
|
||||
props.onPreserveScrollAnchor(next)
|
||||
}}
|
||||
onScroll={(e) => {
|
||||
props.onScheduleScrollState(e.currentTarget)
|
||||
props.onTurnBackfillScroll()
|
||||
@@ -543,131 +400,21 @@ export function MessageTimeline(props: {
|
||||
props.onMarkScrollGesture(e.currentTarget)
|
||||
if (props.isDesktop) props.onScrollSpyScroll()
|
||||
}}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
onClick={(e) => {
|
||||
props.onAutoScrollInteraction(e)
|
||||
}}
|
||||
class="relative min-w-0 w-full h-full"
|
||||
style={{
|
||||
"--session-title-height": showHeader() ? "40px" : "0px",
|
||||
"--session-title-height": showHeader() ? "72px" : "0px",
|
||||
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
|
||||
}}
|
||||
>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div>
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
||||
style={{ "padding-top": "var(--session-title-height)" }}
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
@@ -692,6 +439,15 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
// Capture at creation time: animate only messages added after the
|
||||
// timeline finishes its initial backfill staging, plus the first
|
||||
// turn while a brand new session is still using its default title.
|
||||
const isNew =
|
||||
staging.ready() ||
|
||||
(defaultTitle() &&
|
||||
sessionStatus() !== "idle" &&
|
||||
props.renderedUserMessages.length === 1 &&
|
||||
messageID === props.renderedUserMessages[0]?.id)
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
if (active()) return false
|
||||
@@ -700,7 +456,10 @@ export function MessageTimeline(props: {
|
||||
return false
|
||||
})
|
||||
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
|
||||
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
|
||||
equals: (a, b) => {
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
|
||||
},
|
||||
})
|
||||
const commentCount = createMemo(() => comments().length)
|
||||
return (
|
||||
@@ -757,10 +516,10 @@ export function MessageTimeline(props: {
|
||||
messageID={messageID}
|
||||
active={active()}
|
||||
queued={queued()}
|
||||
status={active() ? sessionStatus() : undefined}
|
||||
animate={isNew || active()}
|
||||
showReasoningSummaries={settings.general.showReasoningSummaries()}
|
||||
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
|
||||
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
|
||||
shellToolDefaultOpen={false}
|
||||
editToolDefaultOpen={false}
|
||||
classes={{
|
||||
root: "min-w-0 w-full relative",
|
||||
content: "flex flex-col justify-between !overflow-visible",
|
||||
|
||||
@@ -331,7 +331,9 @@ export function SessionSidePanel(props: {
|
||||
const path = createMemo(() => file.pathFromTab(tab))
|
||||
return (
|
||||
<div data-component="tabs-drag-preview">
|
||||
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
|
||||
<Show when={path()} keyed>
|
||||
{(p) => <FileVisual active path={p} />}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
522
packages/app/src/pages/session/session-timeline-header.tsx
Normal file
522
packages/app/src/pages/session/session-timeline-header.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function SessionTimelineHeader(props: {
|
||||
centered: boolean
|
||||
showHeader: () => boolean
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
parentID: () => string | undefined
|
||||
titleValue: () => string | undefined
|
||||
headerTitle: () => string | undefined
|
||||
placeholderTitle: () => boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const reduce = prefersReducedMotion
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const [headerText, setHeaderText] = createStore({
|
||||
session: props.sessionKey(),
|
||||
value: props.headerTitle(),
|
||||
prev: undefined as string | undefined,
|
||||
muted: props.placeholderTitle(),
|
||||
prevMuted: false,
|
||||
})
|
||||
let headerAnim: AnimationPlaybackControls | undefined
|
||||
let enterAnim: AnimationPlaybackControls | undefined
|
||||
let leaveAnim: AnimationPlaybackControls | undefined
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
let enterRef: HTMLSpanElement | undefined
|
||||
let leaveRef: HTMLSpanElement | undefined
|
||||
|
||||
const clearHeaderAnim = () => {
|
||||
headerAnim?.stop()
|
||||
headerAnim = undefined
|
||||
}
|
||||
|
||||
const animateHeader = () => {
|
||||
const el = headerRef
|
||||
if (!el) return
|
||||
|
||||
clearHeaderAnim()
|
||||
if (!headerText.muted || reduce()) {
|
||||
el.style.opacity = "1"
|
||||
return
|
||||
}
|
||||
|
||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
||||
headerAnim.finished.then(() => {
|
||||
if (headerRef !== el) return
|
||||
clearFadeStyles(el)
|
||||
})
|
||||
}
|
||||
|
||||
const clearTitleAnims = () => {
|
||||
enterAnim?.stop()
|
||||
enterAnim = undefined
|
||||
leaveAnim?.stop()
|
||||
leaveAnim = undefined
|
||||
}
|
||||
|
||||
const settleTitleEnter = () => {
|
||||
if (enterRef) clearFadeStyles(enterRef)
|
||||
}
|
||||
|
||||
const hideLeave = () => {
|
||||
if (!leaveRef) return
|
||||
leaveRef.style.opacity = "0"
|
||||
leaveRef.style.filter = ""
|
||||
leaveRef.style.transform = ""
|
||||
}
|
||||
|
||||
const animateEnterSpan = () => {
|
||||
if (!enterRef) return
|
||||
if (reduce()) {
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
enterAnim = animate(
|
||||
enterRef,
|
||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
enterAnim.finished.then(() => settleTitleEnter())
|
||||
}
|
||||
|
||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
||||
|
||||
if (reduce()) {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
|
||||
if (leaveRef) {
|
||||
leaveAnim = animate(
|
||||
leaveRef,
|
||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
leaveAnim.finished.then(() => {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
})
|
||||
}
|
||||
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
settleTitleEnter()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(props.showHeader, (show, prev) => {
|
||||
if (!show) {
|
||||
clearHeaderAnim()
|
||||
return
|
||||
}
|
||||
if (show === prev) return
|
||||
animateHeader()
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
|
||||
([nextSession, nextTitle, nextMuted]) => {
|
||||
if (nextSession !== headerText.session) {
|
||||
setHeaderText("session", nextSession)
|
||||
if (nextTitle && nextMuted) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
|
||||
if (!nextTitle) {
|
||||
snapTitle(undefined, false)
|
||||
return
|
||||
}
|
||||
if (!headerText.value) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (title.saving || title.editing) {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
crossfadeTitle(nextTitle, nextMuted)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
clearHeaderAnim()
|
||||
clearTitleAnims()
|
||||
})
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
props.sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!props.sessionID()) return
|
||||
setTitle({ editing: true, draft: props.titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (props.titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((item) => item.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: toastError(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((item) => !removed.has(item.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = props.parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(input: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(input.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
ref={(el) => {
|
||||
headerRef = el
|
||||
el.style.opacity = "0"
|
||||
}}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-10": true,
|
||||
"px-4 md:px-5": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={props.parentID()}>
|
||||
<div>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!!headerText.value || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
|
||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={leaveRef}
|
||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
||||
style={{ opacity: "0" }}
|
||||
>
|
||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -191,8 +191,8 @@ export function TerminalPanel() {
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={ids()}>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
{(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
|
||||
<Show when={byId().get(id)} keyed>
|
||||
{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
@@ -217,10 +217,10 @@ export function TerminalPanel() {
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={terminal.active()} keyed>
|
||||
{(id) => (
|
||||
<Show when={byId().get(id)}>
|
||||
<Show when={byId().get(id)} keyed>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -229,14 +229,14 @@ export function TerminalPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<DragOverlay>
|
||||
<Show when={store.activeDraggable}>
|
||||
<Show when={store.activeDraggable} keyed>
|
||||
{(draggedId) => (
|
||||
<Show when={byId().get(draggedId())}>
|
||||
<Show when={byId().get(draggedId)} keyed>
|
||||
{(t) => (
|
||||
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
|
||||
{terminalTabLabel({
|
||||
title: t().title,
|
||||
titleNumber: t().titleNumber,
|
||||
title: t.title,
|
||||
titleNumber: t.titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}),
|
||||
])
|
||||
|
||||
const isAutoAcceptActive = () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
}
|
||||
|
||||
const permissionCommands = createMemo(() => [
|
||||
permissionsCommand({
|
||||
id: "permissions.autoaccept",
|
||||
title: isAutoAcceptActive()
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
title:
|
||||
params.id && permission.isAutoAccepting(params.id, sdk.directory)
|
||||
? language.t("command.permissions.autoaccept.disable")
|
||||
: language.t("command.permissions.autoaccept.enable"),
|
||||
keybind: "mod+shift+a",
|
||||
disabled: false,
|
||||
disabled: !params.id || !permission.permissionsEnabled(),
|
||||
onSelect: () => {
|
||||
const sessionID = params.id
|
||||
if (sessionID) {
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
} else {
|
||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||
}
|
||||
const active = sessionID
|
||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||
if (!sessionID) return
|
||||
permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||
showToast({
|
||||
title: active
|
||||
title: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.title")
|
||||
: language.t("toast.permissions.autoaccept.off.title"),
|
||||
description: active
|
||||
description: permission.isAutoAccepting(sessionID, sdk.directory)
|
||||
? language.t("toast.permissions.autoaccept.on.description")
|
||||
: language.t("toast.permissions.autoaccept.off.description"),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useLocation, useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, onMount } from "solid-js"
|
||||
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { messageIdFromHash } from "./message-id-from-hash"
|
||||
|
||||
export { messageIdFromHash } from "./message-id-from-hash"
|
||||
@@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: {
|
||||
setPendingMessage: (value: string | undefined) => void
|
||||
setActiveMessage: (message: UserMessage | undefined) => void
|
||||
setTurnStart: (value: number) => void
|
||||
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
|
||||
autoScroll: { pause: () => void; snapToBottom: () => void }
|
||||
scroller: () => HTMLDivElement | undefined
|
||||
anchor: (id: string) => string
|
||||
scheduleScrollState: (el: HTMLDivElement) => void
|
||||
@@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: {
|
||||
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
|
||||
let pendingKey = ""
|
||||
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const clearMessageHash = () => {
|
||||
if (!location.hash) return
|
||||
navigate(location.pathname + location.search, { replace: true })
|
||||
if (!window.location.hash) return
|
||||
window.history.replaceState(null, "", window.location.pathname + window.location.search)
|
||||
}
|
||||
|
||||
const updateHash = (id: string) => {
|
||||
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
|
||||
replace: true,
|
||||
})
|
||||
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
|
||||
}
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
@@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: {
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const sticky = root.querySelector("[data-session-title]")
|
||||
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
|
||||
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const inset = Number.isNaN(title) ? 0 : title
|
||||
// With column-reverse, scrollTop is negative — don't clamp to 0
|
||||
const top = a.top - b.top + root.scrollTop - inset
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
console.log({ message, behavior })
|
||||
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
|
||||
|
||||
const index = messageIndex().get(message.id) ?? -1
|
||||
@@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = location.hash.slice(1)
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.autoScroll.snapToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
return
|
||||
@@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: {
|
||||
return
|
||||
}
|
||||
|
||||
input.autoScroll.forceScrollToBottom()
|
||||
input.autoScroll.snapToBottom()
|
||||
const el = input.scroller()
|
||||
if (el) input.scheduleScrollState(el)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
}
|
||||
|
||||
const handler = () => {
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
location.hash
|
||||
if (!input.sessionID() || !input.messagesReady()) return
|
||||
requestAnimationFrame(() => applyHash("auto"))
|
||||
})
|
||||
@@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetId) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (input.currentMessageId() === targetId) return
|
||||
|
||||
@@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: {
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
clearMessageHash,
|
||||
scrollToMessage,
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export function isEditableTarget(target: EventTarget | null | undefined) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true
|
||||
if (target.isContentEditable) return true
|
||||
if (target.closest("[contenteditable='true']")) return true
|
||||
if (target.closest("input, textarea, select")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number {
|
||||
const r = document.createRange()
|
||||
r.selectNodeContents(lineElement)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
@@ -4,8 +4,8 @@ const keybinds: Record<string, string> = {
|
||||
"prompt.mode.normal": "mod+shift+e",
|
||||
"permissions.autoaccept": "mod+shift+a",
|
||||
"agent.cycle": "mod+.",
|
||||
"model.choose": "mod+m",
|
||||
"model.variant.cycle": "mod+shift+m",
|
||||
"model.choose": "mod+'",
|
||||
"model.variant.cycle": "shift+mod+d",
|
||||
}
|
||||
|
||||
export function useCommand() {
|
||||
|
||||
3
packages/ui/happydom.ts
Normal file
3
packages/ui/happydom.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator"
|
||||
|
||||
GlobalRegistrator.register()
|
||||
@@ -25,10 +25,14 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun run test:unit",
|
||||
"test:unit": "bun test --preload ./happydom.ts ./src/components",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src/components",
|
||||
"dev": "vite",
|
||||
"generate:tailwind": "bun run script/tailwind.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
|
||||
@@ -9,19 +9,20 @@
|
||||
display: inline-flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
line-height: inherit;
|
||||
width: var(--animated-number-width, 1ch);
|
||||
overflow: hidden;
|
||||
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
overflow: clip;
|
||||
transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-slot="animated-number-digit"] {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
line-height: 1em;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
vertical-align: baseline;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
@@ -46,7 +47,7 @@
|
||||
flex-direction: column;
|
||||
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
|
||||
transition-property: transform;
|
||||
transition-duration: var(--animated-number-duration, 560ms);
|
||||
transition-duration: var(--animated-number-duration, 600ms);
|
||||
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
|
||||
|
||||
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
|
||||
const DURATION = 600
|
||||
const DURATION = 800
|
||||
|
||||
function normalize(value: number) {
|
||||
return ((value % 10) + 10) % 10
|
||||
@@ -90,10 +90,35 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
|
||||
)
|
||||
const width = createMemo(() => `${digits().length}ch`)
|
||||
|
||||
const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
|
||||
let exitTimer: number | undefined
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
digits,
|
||||
(current, prev) => {
|
||||
if (prev && current.length < prev.length) {
|
||||
setExitingDigits(prev.slice(current.length))
|
||||
clearTimeout(exitTimer)
|
||||
exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
|
||||
} else {
|
||||
clearTimeout(exitTimer)
|
||||
setExitingDigits([])
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const displayDigits = createMemo(() => {
|
||||
const exiting = exitingDigits()
|
||||
return exiting.length ? [...digits(), ...exiting] : digits()
|
||||
})
|
||||
|
||||
return (
|
||||
<span data-component="animated-number" class={props.class} aria-label={label()}>
|
||||
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
|
||||
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
||||
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
122
packages/ui/src/components/animation-debug-panel.tsx
Normal file
122
packages/ui/src/components/animation-debug-panel.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import {
|
||||
getGrowDuration,
|
||||
getCollapsibleDuration,
|
||||
setGrowDuration,
|
||||
setCollapsibleDuration,
|
||||
} from "./motion"
|
||||
|
||||
export function AnimationDebugPanel() {
|
||||
const [grow, setGrow] = createSignal(getGrowDuration())
|
||||
const [collapsible, setCollapsible] = createSignal(getCollapsibleDuration())
|
||||
const [collapsed, setCollapsed] = createSignal(true)
|
||||
const [dragging, setDragging] = createSignal(false)
|
||||
const [pos, setPos] = createSignal({ x: 16, y: 16 })
|
||||
let dragOffset = { x: 0, y: 0 }
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button, input")) return
|
||||
setDragging(true)
|
||||
dragOffset = { x: e.clientX + pos().x, y: e.clientY + pos().y }
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!dragging()) return
|
||||
setPos({ x: dragOffset.x - e.clientX, y: dragOffset.y - e.clientY })
|
||||
}
|
||||
|
||||
const onPointerUp = () => setDragging(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: `${pos().y}px`,
|
||||
right: `${pos().x}px`,
|
||||
"z-index": "99999",
|
||||
background: "rgba(0, 0, 0, 0.85)",
|
||||
color: "#e0e0e0",
|
||||
"border-radius": "8px",
|
||||
"font-family": "monospace",
|
||||
"font-size": "12px",
|
||||
"box-shadow": "0 4px 20px rgba(0,0,0,0.4)",
|
||||
"user-select": "none",
|
||||
cursor: dragging() ? "grabbing" : "grab",
|
||||
border: "1px solid rgba(255,255,255,0.1)",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
padding: "6px 10px",
|
||||
"border-bottom": collapsed() ? "none" : "1px solid rgba(255,255,255,0.1)",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<span style={{ "font-weight": "bold", "font-size": "11px", opacity: "0.7" }}>
|
||||
springs
|
||||
</span>
|
||||
<button
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "#e0e0e0",
|
||||
cursor: "pointer",
|
||||
padding: "0 2px",
|
||||
"font-size": "14px",
|
||||
"line-height": "1",
|
||||
}}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{collapsed() ? "+" : "\u2013"}
|
||||
</button>
|
||||
</div>
|
||||
{!collapsed() && (
|
||||
<div style={{ padding: "8px 10px", display: "flex", "flex-direction": "column", gap: "6px" }}>
|
||||
<SliderRow
|
||||
label="Grow"
|
||||
value={grow()}
|
||||
onChange={(v) => {
|
||||
setGrow(v)
|
||||
setGrowDuration(v)
|
||||
}}
|
||||
/>
|
||||
<SliderRow
|
||||
label="Collapsible"
|
||||
value={collapsible()}
|
||||
onChange={(v) => {
|
||||
setCollapsible(v)
|
||||
setCollapsibleDuration(v)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SliderRow(props: { label: string; value: number; onChange: (v: number) => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<span style={{ width: "72px", "font-size": "11px" }}>{props.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0.05"
|
||||
max="2.0"
|
||||
step="0.05"
|
||||
value={props.value}
|
||||
onInput={(e) => props.onChange(parseFloat(e.currentTarget.value))}
|
||||
style={{ width: "100px", cursor: "pointer" }}
|
||||
/>
|
||||
<span style={{ width: "32px", "text-align": "right", "font-size": "11px" }}>
|
||||
{props.value.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,54 +8,28 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
width: auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
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"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-structured"] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -63,11 +37,12 @@
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
@@ -80,21 +55,14 @@
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
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"] {
|
||||
flex-shrink: 1;
|
||||
display: inline-block;
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
@@ -138,8 +106,7 @@
|
||||
[data-slot="basic-tool-tool-arg"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal } from "solid-js"
|
||||
import * as mod from "./basic-tool"
|
||||
import { create } from "../storybook/scaffold"
|
||||
|
||||
const docs = `### Overview
|
||||
Expandable tool panel with a structured trigger and optional details.
|
||||
|
||||
Use structured triggers for consistent layout; custom triggers allowed.
|
||||
|
||||
### API
|
||||
- Required: \`icon\` and \`trigger\` (structured or custom JSX).
|
||||
- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`.
|
||||
|
||||
### Variants and states
|
||||
- Pending/running status animates the title via TextShimmer.
|
||||
|
||||
### Behavior
|
||||
- Uses Collapsible; can defer content rendering until open.
|
||||
- Locked state prevents closing.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm trigger semantics and aria labeling.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="tool-trigger"\` and related slots.
|
||||
|
||||
`
|
||||
|
||||
const story = create({
|
||||
title: "UI/Basic Tool",
|
||||
mod,
|
||||
args: {
|
||||
icon: "mcp",
|
||||
defaultOpen: true,
|
||||
trigger: {
|
||||
title: "Basic Tool",
|
||||
subtitle: "Example subtitle",
|
||||
args: ["--flag", "value"],
|
||||
},
|
||||
children: "Details content",
|
||||
},
|
||||
})
|
||||
|
||||
export default {
|
||||
title: "UI/Basic Tool",
|
||||
id: "components-basic-tool",
|
||||
component: story.meta.component,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const Basic = story.Basic
|
||||
|
||||
export const Pending = {
|
||||
args: {
|
||||
status: "pending",
|
||||
trigger: {
|
||||
title: "Running tool",
|
||||
subtitle: "Working...",
|
||||
},
|
||||
children: "Progress details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Locked = {
|
||||
args: {
|
||||
locked: true,
|
||||
trigger: {
|
||||
title: "Locked tool",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Locked details",
|
||||
},
|
||||
}
|
||||
|
||||
export const Deferred = {
|
||||
args: {
|
||||
defer: true,
|
||||
defaultOpen: false,
|
||||
trigger: {
|
||||
title: "Deferred tool",
|
||||
subtitle: "Content mounts on open",
|
||||
},
|
||||
children: "Deferred content",
|
||||
},
|
||||
}
|
||||
|
||||
export const ForceOpen = {
|
||||
args: {
|
||||
forceOpen: true,
|
||||
trigger: {
|
||||
title: "Forced open",
|
||||
subtitle: "Cannot close",
|
||||
},
|
||||
children: "Forced content",
|
||||
},
|
||||
}
|
||||
|
||||
export const HideDetails = {
|
||||
args: {
|
||||
hideDetails: true,
|
||||
trigger: {
|
||||
title: "Summary only",
|
||||
subtitle: "Details hidden",
|
||||
},
|
||||
children: "Hidden content",
|
||||
},
|
||||
}
|
||||
|
||||
export const SubtitleAction = {
|
||||
render: () => {
|
||||
const [message, setMessage] = createSignal("Subtitle not clicked")
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "8px" }}>
|
||||
<div style={{ "font-size": "12px", color: "var(--text-weak)" }}>{message()}</div>
|
||||
<mod.BasicTool
|
||||
icon="mcp"
|
||||
trigger={{ title: "Clickable subtitle", subtitle: "Click me" }}
|
||||
onSubtitleClick={() => setMessage("Subtitle clicked")}
|
||||
>
|
||||
Subtitle action details
|
||||
</mod.BasicTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import type { IconProps } from "./icon"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { hold } from "./tool-utils"
|
||||
|
||||
export type TriggerTitle = {
|
||||
title: string
|
||||
@@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
|
||||
)
|
||||
}
|
||||
|
||||
export interface BasicToolProps {
|
||||
icon: IconProps["name"]
|
||||
interface ToolCallPanelBaseProps {
|
||||
icon: string
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
children?: JSX.Element
|
||||
status?: string
|
||||
animate?: boolean
|
||||
hideDetails?: boolean
|
||||
defaultOpen?: boolean
|
||||
forceOpen?: boolean
|
||||
defer?: boolean
|
||||
locked?: boolean
|
||||
animated?: boolean
|
||||
watchDetails?: boolean
|
||||
springContent?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
}
|
||||
|
||||
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
|
||||
function ToolCallTriggerBody(props: {
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
pending: boolean
|
||||
onSubtitleClick?: () => void
|
||||
arrow?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={trigger().title} active={props.pending} />
|
||||
</span>
|
||||
<Show when={!props.pending}>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!props.onSubtitleClick) return
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.arrow}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
function ToolCallPanel(props: ToolCallPanelBaseProps) {
|
||||
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
|
||||
const [ready, setReady] = createSignal(open())
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
const pendingRaw = () => props.status === "pending" || props.status === "running"
|
||||
const pending = hold(pendingRaw, 1000)
|
||||
const watchDetails = () => props.watchDetails !== false
|
||||
|
||||
let frame: number | undefined
|
||||
|
||||
@@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) {
|
||||
on(
|
||||
open,
|
||||
(value) => {
|
||||
if (!props.defer) return
|
||||
if (!props.defer || props.springContent) return
|
||||
if (!value) {
|
||||
cancel()
|
||||
setReady(false)
|
||||
@@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) {
|
||||
),
|
||||
)
|
||||
|
||||
// Animated height for collapsible open/close
|
||||
// Animated content height — single springValue drives all height changes
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let heightAnim: AnimationPlaybackControls | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
let observer: ResizeObserver | undefined
|
||||
let resizeFrame: number | undefined
|
||||
const initialOpen = open()
|
||||
const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
|
||||
|
||||
const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
|
||||
|
||||
const doOpen = () => {
|
||||
if (!contentRef || !bodyRef) return
|
||||
contentRef.style.display = ""
|
||||
// Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
|
||||
if (bodyRef.style.opacity === "") {
|
||||
bodyRef.style.opacity = "0"
|
||||
bodyRef.style.filter = "blur(2px)"
|
||||
}
|
||||
const next = read()
|
||||
fadeAnim?.stop()
|
||||
fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim.finished.then(() => {
|
||||
if (!bodyRef) return
|
||||
bodyRef.style.opacity = ""
|
||||
bodyRef.style.filter = ""
|
||||
})
|
||||
heightSpring.set(next)
|
||||
}
|
||||
|
||||
const doClose = () => {
|
||||
if (!contentRef || !bodyRef) return
|
||||
fadeAnim?.stop()
|
||||
fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
|
||||
fadeAnim.finished.then(() => {
|
||||
if (!contentRef || open()) return
|
||||
contentRef.style.display = "none"
|
||||
})
|
||||
heightSpring.set(0)
|
||||
}
|
||||
|
||||
const grow = () => {
|
||||
if (!contentRef || !open()) return
|
||||
const next = read()
|
||||
if (Math.abs(next - heightSpring.get()) < 1) return
|
||||
heightSpring.set(next)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
|
||||
|
||||
const offChange = heightSpring.on("change", (v) => {
|
||||
if (!contentRef) return
|
||||
contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
|
||||
})
|
||||
onCleanup(() => {
|
||||
offChange()
|
||||
})
|
||||
|
||||
if (watchDetails()) {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
grow()
|
||||
})
|
||||
})
|
||||
observer.observe(bodyRef)
|
||||
}
|
||||
|
||||
if (!open()) return
|
||||
if (contentRef.style.display !== "none") {
|
||||
const next = read()
|
||||
heightSpring.jump(next)
|
||||
contentRef.style.height = `${next}px`
|
||||
return
|
||||
}
|
||||
let mountFrame: number | undefined = requestAnimationFrame(() => {
|
||||
mountFrame = undefined
|
||||
if (!open()) return
|
||||
doOpen()
|
||||
})
|
||||
onCleanup(() => {
|
||||
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!props.animated || !contentRef) return
|
||||
heightAnim?.stop()
|
||||
if (isOpen) {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
|
||||
heightAnim.finished.then(() => {
|
||||
if (!contentRef || !open()) return
|
||||
contentRef.style.overflow = "visible"
|
||||
contentRef.style.height = "auto"
|
||||
})
|
||||
} else {
|
||||
contentRef.style.overflow = "hidden"
|
||||
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
|
||||
}
|
||||
if (!props.springContent || props.animate === false || !contentRef) return
|
||||
if (isOpen) doOpen()
|
||||
else doClose()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
heightAnim?.stop()
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
observer?.disconnect()
|
||||
fadeAnim?.stop()
|
||||
heightSpring.destroy()
|
||||
})
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
@@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) {
|
||||
return (
|
||||
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="tool-trigger">
|
||||
<div data-slot="basic-tool-tool-trigger-content">
|
||||
<div data-slot="basic-tool-tool-info">
|
||||
<Switch>
|
||||
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
|
||||
{(trigger) => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span
|
||||
data-slot="basic-tool-tool-title"
|
||||
classList={{
|
||||
[trigger().titleClass ?? ""]: !!trigger().titleClass,
|
||||
}}
|
||||
>
|
||||
<TextShimmer text={trigger().title} active={pending()} />
|
||||
</span>
|
||||
<Show when={!pending()}>
|
||||
<Show when={trigger().subtitle}>
|
||||
<span
|
||||
data-slot="basic-tool-tool-subtitle"
|
||||
classList={{
|
||||
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
|
||||
clickable: !!props.onSubtitleClick,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (props.onSubtitleClick) {
|
||||
e.stopPropagation()
|
||||
props.onSubtitleClick()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{trigger().subtitle}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={trigger().args?.length}>
|
||||
<For each={trigger().args}>
|
||||
{(arg) => (
|
||||
<span
|
||||
data-slot="basic-tool-tool-arg"
|
||||
classList={{
|
||||
[trigger().argsClass ?? ""]: !!trigger().argsClass,
|
||||
}}
|
||||
>
|
||||
{arg}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
|
||||
</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{props.trigger as JSX.Element}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
|
||||
<Collapsible.Arrow />
|
||||
</Show>
|
||||
</div>
|
||||
<ToolCallTriggerBody
|
||||
trigger={props.trigger}
|
||||
pending={pending()}
|
||||
onSubtitleClick={props.onSubtitleClick}
|
||||
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={props.animated && props.children && !props.hideDetails}>
|
||||
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
data-slot="collapsible-content"
|
||||
data-animated
|
||||
data-spring-content
|
||||
style={{
|
||||
height: initialOpen ? "auto" : "0px",
|
||||
overflow: initialOpen ? "visible" : "hidden",
|
||||
overflow: "hidden",
|
||||
display: initialOpen ? undefined : "none",
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<div ref={bodyRef} data-slot="basic-tool-content-inner">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!props.animated && props.children && !props.hideDetails}>
|
||||
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
|
||||
<Collapsible.Content>
|
||||
<Show when={!props.defer || ready()}>{props.children}</Show>
|
||||
<Show when={!props.defer || ready()}>
|
||||
<div data-slot="basic-tool-content-inner">{props.children}</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
@@ -222,6 +330,60 @@ function args(input: Record<string, unknown> | undefined) {
|
||||
.slice(0, 3)
|
||||
}
|
||||
|
||||
export interface ToolCallRowProps {
|
||||
variant: "row"
|
||||
icon: string
|
||||
trigger: TriggerTitle | JSX.Element
|
||||
status?: string
|
||||
animate?: boolean
|
||||
onSubtitleClick?: () => void
|
||||
open?: boolean
|
||||
showArrow?: boolean
|
||||
onOpenChange?: (value: boolean) => void
|
||||
}
|
||||
export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
|
||||
variant: "panel"
|
||||
}
|
||||
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
|
||||
function ToolCallRoot(props: ToolCallProps) {
|
||||
const pending = () => props.status === "pending" || props.status === "running"
|
||||
if (props.variant === "row") {
|
||||
return (
|
||||
<Show
|
||||
when={props.onOpenChange}
|
||||
fallback={
|
||||
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
|
||||
<div data-slot="collapsible-trigger">
|
||||
<ToolCallTriggerBody
|
||||
trigger={props.trigger}
|
||||
pending={pending()}
|
||||
onSubtitleClick={props.onSubtitleClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(onOpenChange) => (
|
||||
<Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
|
||||
<Collapsible.Trigger>
|
||||
<ToolCallTriggerBody
|
||||
trigger={props.trigger}
|
||||
pending={pending()}
|
||||
onSubtitleClick={props.onSubtitleClick}
|
||||
arrow={!!props.showArrow}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
</Collapsible>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const [, rest] = splitProps(props, ["variant"])
|
||||
return <ToolCallPanel {...rest} />
|
||||
}
|
||||
export const ToolCall = ToolCallRoot
|
||||
|
||||
export function GenericTool(props: {
|
||||
tool: string
|
||||
status?: string
|
||||
@@ -229,7 +391,8 @@ export function GenericTool(props: {
|
||||
input?: Record<string, unknown>
|
||||
}) {
|
||||
return (
|
||||
<BasicTool
|
||||
<ToolCall
|
||||
variant={props.hideDetails ? "row" : "panel"}
|
||||
icon="mcp"
|
||||
status={props.status}
|
||||
trigger={{
|
||||
@@ -237,7 +400,6 @@ export function GenericTool(props: {
|
||||
subtitle: label(props.input),
|
||||
args: args(props.input),
|
||||
}}
|
||||
hideDetails={props.hideDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@
|
||||
border-radius: var(--radius-md);
|
||||
overflow: visible;
|
||||
|
||||
&.tool-collapsible {
|
||||
gap: 8px;
|
||||
&.tool-collapsible [data-slot="collapsible-trigger"] {
|
||||
height: 37px;
|
||||
}
|
||||
|
||||
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
@@ -23,6 +27,17 @@
|
||||
user-select: none;
|
||||
color: var(--text-base);
|
||||
|
||||
> [data-component="tool-trigger"][data-arrow] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
@@ -50,9 +65,6 @@
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
/* &:hover { */
|
||||
/* background-color: var(--surface-base); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
@@ -82,16 +94,16 @@
|
||||
}
|
||||
|
||||
[data-slot="collapsible-content"] {
|
||||
overflow: hidden;
|
||||
/* animation: slideUp 250ms ease-out; */
|
||||
overflow: clip;
|
||||
|
||||
&[data-expanded] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms ease-out; */
|
||||
/* } */
|
||||
/* JS-animated content: overflow managed by animate() */
|
||||
&[data-spring-content] {
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
@@ -103,9 +115,6 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
/* &:hover { */
|
||||
/* color: var(--text-strong); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
@@ -122,21 +131,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
311
packages/ui/src/components/composer-island.stories.tsx
Normal file
311
packages/ui/src/components/composer-island.stories.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
// @ts-nocheck
|
||||
import { createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { ComposerIsland } from "./composer-island"
|
||||
|
||||
const docs = `### Overview
|
||||
Composer island with a runtime/service API.
|
||||
|
||||
Use the same component in Storybook and app code by swapping the \`runtime\` object:
|
||||
- Storybook: mocked async handlers
|
||||
- App: SDK-backed handlers
|
||||
|
||||
### Runtime API
|
||||
- \`submit\` / \`abort\`
|
||||
- \`searchAt\` / \`searchSlash\`
|
||||
- \`toggleAccept\`
|
||||
- \`submitQuestion\` / \`rejectQuestion\`
|
||||
- \`decidePermission\`
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/ComposerIsland",
|
||||
id: "components-composer-island",
|
||||
component: ComposerIsland,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const questions = [
|
||||
{
|
||||
text: "Which editor do you use most often?",
|
||||
options: [
|
||||
{ label: "Neovim (Recommended)", description: "Fast keyboard-driven workflow" },
|
||||
{ label: "VS Code", description: "Feature-rich and extensible" },
|
||||
{ label: "Zed", description: "Lightweight modern editor" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
text: "Which testing frameworks should we add?",
|
||||
options: [
|
||||
{ label: "Vitest", description: "Fast unit testing" },
|
||||
{ label: "Playwright", description: "E2E browser testing" },
|
||||
{ label: "Testing Library", description: "Component testing" },
|
||||
],
|
||||
multiple: true,
|
||||
},
|
||||
]
|
||||
|
||||
const at = [
|
||||
{ type: "file", path: "src/auth.ts", display: "src/auth.ts", recent: true },
|
||||
{ type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
|
||||
{ type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
|
||||
{ type: "agent", name: "coder", display: "coder" },
|
||||
{ type: "agent", name: "reviewer", display: "reviewer" },
|
||||
]
|
||||
|
||||
const slash = [
|
||||
{
|
||||
id: "help",
|
||||
trigger: "help",
|
||||
title: "Help",
|
||||
description: "Show available commands",
|
||||
type: "builtin",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
trigger: "clear",
|
||||
title: "Clear",
|
||||
description: "Clear conversation",
|
||||
type: "builtin",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
trigger: "review",
|
||||
title: "Review",
|
||||
description: "Review code changes",
|
||||
type: "custom",
|
||||
source: "skill",
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
trigger: "test",
|
||||
title: "Test",
|
||||
description: "Run tests",
|
||||
type: "custom",
|
||||
source: "mcp",
|
||||
keybind: "mod+t",
|
||||
},
|
||||
]
|
||||
|
||||
const todos = [
|
||||
{ content: "Read auth module", status: "completed" as const },
|
||||
{ content: "Refactor token logic", status: "in_progress" as const },
|
||||
{ content: "Add tests", status: "pending" as const },
|
||||
{ content: "Ship changes", status: "pending" as const },
|
||||
]
|
||||
|
||||
const contexts = [
|
||||
{ id: "c1", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "JWT expiry looks brittle" },
|
||||
{ id: "c2", path: "src/routes/login.ts", comment: "Rate limit should be stricter" },
|
||||
]
|
||||
|
||||
const dragCycle = [null, "image", "@mention"] as const
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
export const Interactive = () => {
|
||||
const [mode, setMode] = createSignal<"input" | "question" | "permission">("input")
|
||||
const [tab, setTab] = createSignal(0)
|
||||
const [showTodos, setShowTodos] = createSignal(true)
|
||||
const [todoCollapsed, setTodoCollapsed] = createSignal(false)
|
||||
const [showContext, setShowContext] = createSignal(true)
|
||||
const [dragIndex, setDragIndex] = createSignal(0)
|
||||
const [working, setWorking] = createSignal(false)
|
||||
const [accepting, setAccepting] = createSignal(false)
|
||||
const [agent, setAgent] = createSignal("ask")
|
||||
const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
|
||||
const [variant, setVariant] = createSignal("default")
|
||||
const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
|
||||
|
||||
const q = createMemo(() => questions[tab()] ?? questions[0])
|
||||
const drag = createMemo(() => dragCycle[dragIndex()] ?? null)
|
||||
|
||||
const runtime = {
|
||||
submit: async (input) => {
|
||||
setWorking(true)
|
||||
await wait(600)
|
||||
setWorking(false)
|
||||
console.log("submit", input)
|
||||
},
|
||||
abort: () => {
|
||||
setWorking(false)
|
||||
console.log("abort")
|
||||
},
|
||||
toggleAccept: () => {
|
||||
setAccepting((value) => !value)
|
||||
},
|
||||
runSlash: (cmd) => {
|
||||
if (cmd.type !== "builtin") return false
|
||||
console.log("slash:run", cmd.id)
|
||||
return true
|
||||
},
|
||||
historyRead: (mode) => history()[mode],
|
||||
historyWrite: (mode, list) => {
|
||||
setHistory((prev) => ({ ...prev, [mode]: list }))
|
||||
},
|
||||
searchAt: async (filter: string) => {
|
||||
await wait(120)
|
||||
return at.filter((item) => {
|
||||
const value = item.type === "agent" ? item.name : item.path
|
||||
return value.toLowerCase().includes(filter.toLowerCase())
|
||||
})
|
||||
},
|
||||
searchSlash: async (filter: string) => {
|
||||
await wait(120)
|
||||
return slash.filter((item) => item.trigger.toLowerCase().includes(filter.toLowerCase()))
|
||||
},
|
||||
decidePermission: async (response) => {
|
||||
console.log("permission", response)
|
||||
await wait(300)
|
||||
setMode("input")
|
||||
},
|
||||
submitQuestion: async (answers) => {
|
||||
console.log("question answers", answers)
|
||||
await wait(300)
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
},
|
||||
rejectQuestion: async () => {
|
||||
await wait(150)
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
},
|
||||
openContext: (item) => {
|
||||
console.log("open context", item)
|
||||
},
|
||||
removeContext: (item) => {
|
||||
console.log("remove context", item)
|
||||
},
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return
|
||||
if (target instanceof HTMLElement && target.isContentEditable) return
|
||||
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
|
||||
|
||||
let hit = false
|
||||
if (event.key === "1") {
|
||||
setMode("input")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "2") {
|
||||
setMode("question")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "3") {
|
||||
setMode("permission")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "4") {
|
||||
setTodoCollapsed((value) => !value)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "5") {
|
||||
setShowTodos((value) => !value)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "6") {
|
||||
setShowContext((value) => !value)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "7") {
|
||||
setDragIndex((value) => (value + 1) % dragCycle.length)
|
||||
hit = true
|
||||
}
|
||||
|
||||
if (!hit) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey)
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey))
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "16px",
|
||||
left: "16px",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
"font-family": "monospace",
|
||||
"font-size": "12px",
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
}}
|
||||
>
|
||||
<button onClick={() => setMode("input")}>Ctrl+1 Input</button>
|
||||
<button onClick={() => setMode("question")}>Ctrl+2 Question</button>
|
||||
<button onClick={() => setMode("permission")}>Ctrl+3 Permission</button>
|
||||
<button onClick={() => setTodoCollapsed((value) => !value)}>Ctrl+4 Collapse</button>
|
||||
<button onClick={() => setShowTodos((value) => !value)}>Ctrl+5 Todos</button>
|
||||
<button onClick={() => setShowContext((value) => !value)}>Ctrl+6 Context</button>
|
||||
<button onClick={() => setDragIndex((value) => (value + 1) % dragCycle.length)}>Ctrl+7 Drag</button>
|
||||
</div>
|
||||
|
||||
<ComposerIsland
|
||||
mode={mode()}
|
||||
runtime={runtime}
|
||||
agentOptions={["ask", "coder", "reviewer"]}
|
||||
modelOptions={[
|
||||
"OpenAI/GPT-5.2",
|
||||
"OpenAI/GPT-5.3 Codex",
|
||||
"OpenAI/GPT-5.3 Codex Spark",
|
||||
"Anthropic/Claude Sonnet 4",
|
||||
"Anthropic/Claude Haiku 4.5",
|
||||
"Google/Gemini 2.5 Pro",
|
||||
]}
|
||||
variantOptions={["default", "fast", "quality"]}
|
||||
agentCurrent={agent()}
|
||||
modelCurrent={model()}
|
||||
variantCurrent={variant()}
|
||||
onAgentSelect={setAgent}
|
||||
onModelSelect={setModel}
|
||||
onVariantSelect={setVariant}
|
||||
agentKeybind="mod+."
|
||||
modelKeybind="mod+'"
|
||||
variantKeybind="shift+mod+d"
|
||||
working={working()}
|
||||
accepting={accepting()}
|
||||
placeholder="Ask anything..."
|
||||
questionText={q().text}
|
||||
questionOptions={q().options}
|
||||
questionMultiple={q().multiple}
|
||||
questionIndex={tab()}
|
||||
questionTotal={questions.length}
|
||||
onQuestionBack={() => setTab((value) => Math.max(0, value - 1))}
|
||||
onQuestionNext={() => setTab((value) => Math.min(questions.length - 1, value + 1))}
|
||||
onQuestionJump={setTab}
|
||||
onQuestionDismiss={() => {
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
}}
|
||||
agentName="Ask"
|
||||
modelName="GPT-4"
|
||||
variant="default"
|
||||
todos={todos}
|
||||
showTodos={showTodos()}
|
||||
todoCollapsed={todoCollapsed()}
|
||||
onTodoCollapseChange={setTodoCollapsed}
|
||||
contextItems={showContext() ? contexts : []}
|
||||
forceDragType={drag()}
|
||||
permissionDescription="Modify files, including edits, writes, patches, and multi-edits"
|
||||
permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
28
packages/ui/src/components/composer-island.tsx
Normal file
28
packages/ui/src/components/composer-island.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { NewComposer } from "./new-composer"
|
||||
import type { NewComposerProps } from "./new-composer"
|
||||
|
||||
/**
|
||||
* Composer Island is now backed by `NewComposer`.
|
||||
*
|
||||
* This keeps the existing import path (`@opencode-ai/ui/composer-island`) stable
|
||||
* while using the split/runtime-ready architecture under the hood.
|
||||
*/
|
||||
export type ComposerIslandProps = NewComposerProps
|
||||
|
||||
export const ComposerIsland: Component<ComposerIslandProps> = (props) => {
|
||||
return <NewComposer {...props} />
|
||||
}
|
||||
|
||||
export type {
|
||||
AtOption,
|
||||
ComposerMode,
|
||||
ComposerPart,
|
||||
ComposerRuntime,
|
||||
ComposerSource,
|
||||
ComposerSubmit,
|
||||
ContextItem,
|
||||
ImageAttachment,
|
||||
SlashCommand,
|
||||
TodoItem,
|
||||
} from "./new-composer/types"
|
||||
199
packages/ui/src/components/context-tool-results.tsx
Normal file
199
packages/ui/src/components/context-tool-results.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { createMemo, createSignal, For, onMount } from "solid-js"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
import { ToolCall } from "./basic-tool"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { RollingResults } from "./rolling-results"
|
||||
import { GROW_SPRING } from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
|
||||
|
||||
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
|
||||
const state = part.state
|
||||
const title = "title" in state ? (state.title as string | undefined) : undefined
|
||||
const input = state.input
|
||||
if (part.tool === "read") {
|
||||
const path = input?.filePath as string | undefined
|
||||
return { action: "Read", detail: title || (path ? getFilename(path) : "") }
|
||||
}
|
||||
if (part.tool === "grep") {
|
||||
const pattern = input?.pattern as string | undefined
|
||||
return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
|
||||
}
|
||||
if (part.tool === "glob") {
|
||||
const pattern = input?.pattern as string | undefined
|
||||
return { action: "Find", detail: title || (pattern ?? "") }
|
||||
}
|
||||
if (part.tool === "list") {
|
||||
const path = input?.path as string | undefined
|
||||
return { action: "List", detail: title || (path ? getFilename(path) : "") }
|
||||
}
|
||||
return { action: part.tool, detail: title || "" }
|
||||
}
|
||||
|
||||
function contextToolSummary(parts: ToolPart[]) {
|
||||
let read = 0
|
||||
let search = 0
|
||||
let list = 0
|
||||
for (const part of parts) {
|
||||
if (part.tool === "read") read++
|
||||
else if (part.tool === "glob" || part.tool === "grep") search++
|
||||
else if (part.tool === "list") list++
|
||||
}
|
||||
return { read, search, list }
|
||||
}
|
||||
|
||||
export function ContextToolGroupHeader(props: {
|
||||
parts: ToolPart[]
|
||||
pending: boolean
|
||||
open: boolean
|
||||
onOpenChange: (value: boolean) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
const summary = createMemo(() => contextToolSummary(props.parts))
|
||||
return (
|
||||
<ToolCall
|
||||
variant="row"
|
||||
icon="magnifying-glass-menu"
|
||||
open={!props.pending && props.open}
|
||||
showArrow={!props.pending}
|
||||
onOpenChange={(v) => {
|
||||
if (!props.pending) props.onOpenChange(v)
|
||||
}}
|
||||
trigger={
|
||||
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
|
||||
<span
|
||||
data-slot="context-tool-group-title"
|
||||
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
|
||||
>
|
||||
<span data-slot="context-tool-group-label" class="shrink-0">
|
||||
<ToolStatusTitle
|
||||
active={props.pending}
|
||||
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
|
||||
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
|
||||
split={false}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
data-slot="context-tool-group-summary"
|
||||
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
|
||||
>
|
||||
<AnimatedCountList
|
||||
items={[
|
||||
{
|
||||
key: "read",
|
||||
count: summary().read,
|
||||
one: i18n.t("ui.messagePart.context.read.one"),
|
||||
other: i18n.t("ui.messagePart.context.read.other"),
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
count: summary().search,
|
||||
one: i18n.t("ui.messagePart.context.search.one"),
|
||||
other: i18n.t("ui.messagePart.context.search.other"),
|
||||
},
|
||||
{
|
||||
key: "list",
|
||||
count: summary().list,
|
||||
one: i18n.t("ui.messagePart.context.list.one"),
|
||||
other: i18n.t("ui.messagePart.context.list.other"),
|
||||
},
|
||||
]}
|
||||
fallback=""
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
const updateMask = () => {
|
||||
if (scrollRef) updateScrollMask(scrollRef)
|
||||
}
|
||||
|
||||
useCollapsible({
|
||||
content: () => contentRef,
|
||||
body: () => bodyRef,
|
||||
open: () => props.expanded,
|
||||
onOpen: updateMask,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
|
||||
<div ref={bodyRef}>
|
||||
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
|
||||
<For each={props.parts}>
|
||||
{(part) => {
|
||||
const label = createMemo(() => contextToolLabel(part))
|
||||
return (
|
||||
<div data-component="context-tool-expanded-row">
|
||||
<span data-slot="context-tool-expanded-action">{label().action}</span>
|
||||
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
|
||||
const wiped = new Set<string>()
|
||||
const [mounted, setMounted] = createSignal(false)
|
||||
onMount(() => setMounted(true))
|
||||
const reduce = prefersReducedMotion
|
||||
const show = () => mounted() && props.pending
|
||||
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
|
||||
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
|
||||
return (
|
||||
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
|
||||
<RollingResults
|
||||
items={props.parts}
|
||||
rows={5}
|
||||
rowHeight={22}
|
||||
rowGap={0}
|
||||
open={props.pending}
|
||||
animate
|
||||
getKey={(part) => part.callID || part.id}
|
||||
render={(part) => {
|
||||
const label = createMemo(() => contextToolLabel(part))
|
||||
const k = part.callID || part.id
|
||||
return (
|
||||
<div data-component="context-tool-rolling-row">
|
||||
<span data-slot="context-tool-rolling-action">{label().action}</span>
|
||||
{(() => {
|
||||
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
|
||||
useRowWipe({
|
||||
id: () => k,
|
||||
text: () => label().detail,
|
||||
ref: detailRef,
|
||||
seen: wiped,
|
||||
})
|
||||
return (
|
||||
<span
|
||||
ref={setDetailRef}
|
||||
data-slot="context-tool-rolling-detail"
|
||||
style={{ display: label().detail ? undefined : "none" }}
|
||||
>
|
||||
{label().detail}
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
426
packages/ui/src/components/grow-box.tsx
Normal file
426
packages/ui/src/components/grow-box.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
|
||||
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
||||
export interface GrowBoxProps {
|
||||
children: JSX.Element
|
||||
/** Enable animation. When false, content shows immediately at full height. */
|
||||
animate?: boolean
|
||||
/** Animate height from 0 to content height. Default: true. */
|
||||
grow?: boolean
|
||||
/** Keep watching body size and animate subsequent height changes. Default: false. */
|
||||
watch?: boolean
|
||||
/** Fade in body content (opacity + blur). Default: true. */
|
||||
fade?: boolean
|
||||
/** Top padding in px on the body wrapper. Default: 0. */
|
||||
gap?: number
|
||||
/** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
|
||||
autoHeight?: boolean
|
||||
/** Controlled visibility for animating open/close without unmounting children. */
|
||||
open?: boolean
|
||||
/** Animate controlled open/close changes after mount. Default: true. */
|
||||
animateToggle?: boolean
|
||||
/** data-slot attribute on the root div. */
|
||||
slot?: string
|
||||
/** CSS class on the root div. */
|
||||
class?: string
|
||||
/** Override mount and resize spring config. Default: GROW_SPRING. */
|
||||
spring?: SpringConfig
|
||||
/** Override controlled open/close spring config. Default: spring. */
|
||||
toggleSpring?: SpringConfig
|
||||
/** Show a temporary bottom edge fade while height animation is running. */
|
||||
edge?: boolean
|
||||
/** Edge fade height in px. Default: 20. */
|
||||
edgeHeight?: number
|
||||
/** Edge fade opacity (0-1). Default: 1. */
|
||||
edgeOpacity?: number
|
||||
/** Delay before edge fades out after height settles. Default: 320. */
|
||||
edgeIdle?: number
|
||||
/** Edge fade-out duration in seconds. Default: 0.24. */
|
||||
edgeFade?: number
|
||||
/** Edge fade-in duration in seconds. Default: 0.2. */
|
||||
edgeRise?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children in a container that animates from zero height on mount.
|
||||
*
|
||||
* Includes a ResizeObserver so content changes after mount are also spring-animated.
|
||||
* Used for timeline turns, assistant part groups, and user messages.
|
||||
*/
|
||||
export function GrowBox(props: GrowBoxProps) {
|
||||
const reduce = prefersReducedMotion
|
||||
const spring = () => props.spring ?? GROW_SPRING
|
||||
const toggleSpring = () => props.toggleSpring ?? spring()
|
||||
let mode: "mount" | "toggle" = "mount"
|
||||
let root: HTMLDivElement | undefined
|
||||
let body: HTMLDivElement | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
let edgeRef: HTMLDivElement | undefined
|
||||
let edgeAnim: AnimationPlaybackControls | undefined
|
||||
let edgeTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let edgeOn = false
|
||||
let mountFrame: number | undefined
|
||||
let resizeFrame: number | undefined
|
||||
let observer: ResizeObserver | undefined
|
||||
let springTarget = -1
|
||||
const height = tunableSpringValue<number>(0, {
|
||||
type: "spring",
|
||||
get visualDuration() {
|
||||
return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
|
||||
},
|
||||
get bounce() {
|
||||
return (mode === "toggle" ? toggleSpring() : spring()).bounce
|
||||
},
|
||||
})
|
||||
|
||||
const gap = () => Math.max(0, props.gap ?? 0)
|
||||
const grow = () => props.grow !== false
|
||||
const watch = () => props.watch === true
|
||||
const open = () => props.open !== false
|
||||
const animateToggle = () => props.animateToggle !== false
|
||||
const edge = () => props.edge === true
|
||||
const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
|
||||
const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
|
||||
const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
|
||||
const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
|
||||
const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
|
||||
const animated = () => props.animate !== false && !reduce()
|
||||
const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
|
||||
|
||||
const stopEdgeTimer = () => {
|
||||
if (edgeTimer === undefined) return
|
||||
clearTimeout(edgeTimer)
|
||||
edgeTimer = undefined
|
||||
}
|
||||
|
||||
const hideEdge = (instant = false) => {
|
||||
stopEdgeTimer()
|
||||
if (!edgeRef) {
|
||||
edgeOn = false
|
||||
return
|
||||
}
|
||||
edgeAnim?.stop()
|
||||
edgeAnim = undefined
|
||||
if (instant || reduce()) {
|
||||
edgeRef.style.opacity = "0"
|
||||
edgeOn = false
|
||||
return
|
||||
}
|
||||
if (!edgeOn) {
|
||||
edgeRef.style.opacity = "0"
|
||||
return
|
||||
}
|
||||
const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
|
||||
edgeAnim = current
|
||||
current.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (edgeAnim !== current) return
|
||||
edgeAnim = undefined
|
||||
if (!edgeRef) return
|
||||
edgeRef.style.opacity = "0"
|
||||
edgeOn = false
|
||||
})
|
||||
}
|
||||
|
||||
const showEdge = () => {
|
||||
stopEdgeTimer()
|
||||
if (!edgeRef) return
|
||||
if (reduce()) {
|
||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
||||
edgeOn = true
|
||||
return
|
||||
}
|
||||
if (edgeOn && edgeAnim === undefined) {
|
||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
||||
return
|
||||
}
|
||||
edgeAnim?.stop()
|
||||
edgeAnim = undefined
|
||||
if (!edgeOn) edgeRef.style.opacity = "0"
|
||||
const current = animate(
|
||||
edgeRef,
|
||||
{ opacity: edgeOpacity() },
|
||||
{ type: "spring", visualDuration: edgeRise(), bounce: 0 },
|
||||
)
|
||||
edgeAnim = current
|
||||
edgeOn = true
|
||||
current.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (edgeAnim !== current) return
|
||||
edgeAnim = undefined
|
||||
if (!edgeRef) return
|
||||
edgeRef.style.opacity = `${edgeOpacity()}`
|
||||
})
|
||||
}
|
||||
|
||||
const queueEdgeHide = () => {
|
||||
stopEdgeTimer()
|
||||
if (!edgeOn) return
|
||||
if (edgeIdle() <= 0) {
|
||||
hideEdge()
|
||||
return
|
||||
}
|
||||
edgeTimer = setTimeout(() => {
|
||||
edgeTimer = undefined
|
||||
hideEdge()
|
||||
}, edgeIdle())
|
||||
}
|
||||
|
||||
const hideBody = () => {
|
||||
if (!body) return
|
||||
body.style.opacity = "0"
|
||||
body.style.filter = "blur(2px)"
|
||||
}
|
||||
|
||||
const clearBody = () => {
|
||||
if (!body) return
|
||||
body.style.opacity = ""
|
||||
body.style.filter = ""
|
||||
}
|
||||
|
||||
const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
|
||||
if (props.fade === false || !body) return
|
||||
if (reduce()) {
|
||||
clearBody()
|
||||
return
|
||||
}
|
||||
hideBody()
|
||||
fadeAnim?.stop()
|
||||
fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
|
||||
fadeAnim.finished.then(() => {
|
||||
if (!body || !open()) return
|
||||
clearBody()
|
||||
})
|
||||
}
|
||||
|
||||
const setInstant = (visible: boolean) => {
|
||||
const next = visible ? targetHeight() : 0
|
||||
springTarget = next
|
||||
height.jump(next)
|
||||
root!.style.height = visible ? "" : "0px"
|
||||
root!.style.overflow = visible ? "" : "clip"
|
||||
hideEdge(true)
|
||||
if (visible || props.fade === false) clearBody()
|
||||
else hideBody()
|
||||
}
|
||||
|
||||
const currentHeight = () => {
|
||||
if (!root) return 0
|
||||
const v = root.style.height
|
||||
if (v && v !== "auto") {
|
||||
const n = Number.parseFloat(v)
|
||||
if (!Number.isNaN(n)) return n
|
||||
}
|
||||
return Math.max(0, root.getBoundingClientRect().height)
|
||||
}
|
||||
|
||||
const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
|
||||
|
||||
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
|
||||
if (!root || !open()) return
|
||||
const next = targetHeight()
|
||||
if (reduce()) {
|
||||
springTarget = next
|
||||
height.jump(next)
|
||||
if (props.autoHeight === false || watch()) {
|
||||
root.style.height = `${next}px`
|
||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
||||
return
|
||||
}
|
||||
root.style.height = "auto"
|
||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
||||
return
|
||||
}
|
||||
if (next === springTarget) return
|
||||
const prev = currentHeight()
|
||||
if (Math.abs(next - prev) < 1) {
|
||||
springTarget = next
|
||||
if (props.autoHeight === false || watch()) {
|
||||
root.style.height = `${next}px`
|
||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
||||
}
|
||||
return
|
||||
}
|
||||
root.style.overflow = "clip"
|
||||
springTarget = next
|
||||
mode = nextMode
|
||||
height.set(next)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!root || !body) return
|
||||
|
||||
const offChange = height.on("change", (next) => {
|
||||
if (!root) return
|
||||
root.style.height = `${Math.max(0, next)}px`
|
||||
})
|
||||
const offStart = height.on("animationStart", () => {
|
||||
if (!root) return
|
||||
root.style.overflow = "clip"
|
||||
root.style.willChange = "height"
|
||||
root.style.contain = "layout style"
|
||||
if (edgeReady()) showEdge()
|
||||
})
|
||||
const offComplete = height.on("animationComplete", () => {
|
||||
if (!root) return
|
||||
root.style.willChange = ""
|
||||
root.style.contain = ""
|
||||
if (!open()) {
|
||||
springTarget = 0
|
||||
root.style.height = "0px"
|
||||
root.style.overflow = "clip"
|
||||
return
|
||||
}
|
||||
const next = targetHeight()
|
||||
springTarget = next
|
||||
if (props.autoHeight === false || watch()) {
|
||||
root.style.height = `${next}px`
|
||||
root.style.overflow = next > 0 ? "visible" : "clip"
|
||||
if (edgeReady()) queueEdgeHide()
|
||||
return
|
||||
}
|
||||
root.style.height = "auto"
|
||||
root.style.overflow = "visible"
|
||||
if (edgeReady()) queueEdgeHide()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
offComplete()
|
||||
offStart()
|
||||
offChange()
|
||||
})
|
||||
|
||||
if (!animated()) {
|
||||
setInstant(open())
|
||||
return
|
||||
}
|
||||
|
||||
if (props.fade !== false) hideBody()
|
||||
hideEdge(true)
|
||||
|
||||
if (!open()) {
|
||||
root.style.height = "0px"
|
||||
root.style.overflow = "clip"
|
||||
} else {
|
||||
if (grow()) {
|
||||
root.style.height = "0px"
|
||||
root.style.overflow = "clip"
|
||||
} else {
|
||||
root.style.height = "auto"
|
||||
root.style.overflow = "visible"
|
||||
}
|
||||
mountFrame = requestAnimationFrame(() => {
|
||||
mountFrame = undefined
|
||||
fadeBodyIn("mount")
|
||||
if (grow()) setHeight("mount")
|
||||
})
|
||||
}
|
||||
if (watch()) {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (!open()) return
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
setHeight("mount")
|
||||
})
|
||||
})
|
||||
observer.observe(body)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.open,
|
||||
(value) => {
|
||||
if (value === undefined) return
|
||||
if (!root || !body) return
|
||||
if (!animateToggle() || reduce()) {
|
||||
setInstant(value)
|
||||
return
|
||||
}
|
||||
fadeAnim?.stop()
|
||||
if (!value) hideEdge(true)
|
||||
if (!value) {
|
||||
const next = currentHeight()
|
||||
if (Math.abs(next - height.get()) >= 1) {
|
||||
springTarget = next
|
||||
height.jump(next)
|
||||
root.style.height = `${next}px`
|
||||
}
|
||||
if (props.fade !== false) {
|
||||
fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
|
||||
}
|
||||
root.style.overflow = "clip"
|
||||
springTarget = 0
|
||||
mode = "toggle"
|
||||
height.set(0)
|
||||
return
|
||||
}
|
||||
fadeBodyIn("toggle")
|
||||
setHeight("toggle")
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!edgeRef) return
|
||||
edgeRef.style.height = `${edgeHeight()}px`
|
||||
if (!animated() || !open() || edgeHeight() <= 0) {
|
||||
hideEdge(true)
|
||||
return
|
||||
}
|
||||
if (edge()) return
|
||||
hideEdge()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!root || !body) return
|
||||
if (!reduce()) return
|
||||
fadeAnim?.stop()
|
||||
edgeAnim?.stop()
|
||||
setInstant(open())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stopEdgeTimer()
|
||||
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
observer?.disconnect()
|
||||
height.destroy()
|
||||
fadeAnim?.stop()
|
||||
edgeAnim?.stop()
|
||||
edgeAnim = undefined
|
||||
edgeOn = false
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
data-slot={props.slot}
|
||||
class={props.class}
|
||||
style={{ transform: "translateZ(0)", position: "relative" }}
|
||||
>
|
||||
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
ref={edgeRef}
|
||||
data-slot="grow-box-edge"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
height: `${edgeHeight()}px`,
|
||||
opacity: 0,
|
||||
"pointer-events": "none",
|
||||
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -44,19 +44,6 @@ function sanitize(html: string) {
|
||||
return DOMPurify.sanitize(html, config)
|
||||
}
|
||||
|
||||
function escape(text: string) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
}
|
||||
|
||||
function fallback(markdown: string) {
|
||||
return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
|
||||
}
|
||||
|
||||
type CopyLabels = {
|
||||
copy: string
|
||||
copied: string
|
||||
@@ -250,7 +237,7 @@ export function Markdown(
|
||||
const [html] = createResource(
|
||||
() => local.text,
|
||||
async (markdown) => {
|
||||
if (isServer) return fallback(markdown)
|
||||
if (isServer) return ""
|
||||
|
||||
const hash = checksum(markdown)
|
||||
const key = local.cacheKey ?? hash
|
||||
@@ -268,7 +255,7 @@ export function Markdown(
|
||||
if (key && hash) touch(key, { hash, html: safe })
|
||||
return safe
|
||||
},
|
||||
{ initialValue: isServer ? fallback(local.text) : "" },
|
||||
{ initialValue: "" },
|
||||
)
|
||||
|
||||
let copySetupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
[data-component="assistant-message"] {
|
||||
content-visibility: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="assistant-parts"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
[data-component="assistant-part-item"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="user-message"] {
|
||||
@@ -27,6 +37,14 @@
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="user-message-inner"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
}
|
||||
[data-slot="user-message-attachments"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -35,6 +53,7 @@
|
||||
width: fit-content;
|
||||
max-width: min(82%, 64ch);
|
||||
margin-left: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
[data-slot="user-message-attachment"] {
|
||||
@@ -134,7 +153,7 @@
|
||||
|
||||
[data-slot="user-message-copy-wrapper"] {
|
||||
min-height: 24px;
|
||||
margin-top: 4px;
|
||||
margin-top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
@@ -144,7 +163,6 @@
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
will-change: opacity;
|
||||
|
||||
[data-component="tooltip-trigger"] {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
@@ -187,56 +205,21 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.text-text-strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="text-part"] {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
margin-top: 0;
|
||||
padding-block: 4px;
|
||||
position: relative;
|
||||
|
||||
[data-slot="text-part-body"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-slot="text-part-copy-wrapper"] {
|
||||
min-height: 24px;
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
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="text-part-meta"] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-slot="text-part-copy-wrapper"][data-interrupted] {
|
||||
[data-slot="text-part-turn-summary"] {
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="markdown"] {
|
||||
@@ -245,6 +228,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
[data-component="compaction-part"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -278,7 +265,6 @@
|
||||
line-height: var(--line-height-normal);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: normal;
|
||||
font-size: inherit;
|
||||
color: var(--text-weak);
|
||||
@@ -372,13 +358,16 @@
|
||||
height: auto;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-component="markdown"] {
|
||||
overflow: visible;
|
||||
}
|
||||
@@ -448,7 +437,7 @@
|
||||
[data-component="write-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
@@ -461,7 +450,8 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-title"] {
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -493,40 +483,45 @@
|
||||
[data-slot="message-part-title-text"] {
|
||||
text-transform: capitalize;
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-meta-line"],
|
||||
.message-part-meta-line {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
[data-component="diff-changes"] {
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-part-meta-line.soft {
|
||||
[data-slot="message-part-title-filename"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
/* No text-transform - preserve original filename casing */
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory"] {
|
||||
[data-slot="message-part-directory-inline"] {
|
||||
color: var(--text-weak);
|
||||
min-width: 0;
|
||||
max-width: min(48vw, 36ch);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="message-part-filename"] {
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-content"] {
|
||||
@@ -617,6 +612,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="webfetch-meta"] {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
[data-component="tool-action"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="todos"] {
|
||||
padding: 10px 0 24px 0;
|
||||
display: flex;
|
||||
@@ -639,7 +645,6 @@
|
||||
}
|
||||
|
||||
[data-component="context-tool-group-trigger"] {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -647,28 +652,352 @@
|
||||
gap: 0px;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-pending] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-title"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
color: var(--icon-weaker);
|
||||
/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
|
||||
[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
|
||||
width: auto;
|
||||
flex: 0 1 auto;
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-group-list"] {
|
||||
padding: 6px 0 4px 0;
|
||||
[data-component="context-tool-step"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-component="context-tool-expanded-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 4px 0 4px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
[data-slot="context-tool-group-item"] {
|
||||
min-width: 0;
|
||||
padding: 6px 0;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-expanded-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot="context-tool-expanded-action"] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-expanded-detail"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-base);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-rolling-row"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
|
||||
[data-slot="context-tool-rolling-action"] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-rolling-detail"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-results"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
[data-slot="shell-rolling-header-clip"] {
|
||||
&:hover [data-slot="shell-rolling-actions"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-header"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 37px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
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-strong);
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-subtitle"] {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-actions"] {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.shell-rolling-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-weaker);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-arrow"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--icon-weaker);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-arrow"][data-open="true"] {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-output"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-preview"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-output"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-shell"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-body"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-top"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 9px 44px 9px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-command"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-prompt"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-input"] {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-actions"] {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.shell-expanded-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-weaker);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-divider"] {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-pre"] {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"],
|
||||
[data-component="shell-rolling-row"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-text"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-component="diagnostics"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -729,6 +1058,30 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="assistant-part-grow"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-component="tool-part-wrapper"][data-tool="bash"] {
|
||||
[data-component="tool-trigger"] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-info-main"] {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"],
|
||||
[data-slot="basic-tool-tool-subtitle"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="dock-prompt"][data-kind="permission"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1187,8 +1540,7 @@
|
||||
position: sticky;
|
||||
top: var(--sticky-accordion-top, 0px);
|
||||
z-index: 20;
|
||||
height: 40px;
|
||||
padding-bottom: 8px;
|
||||
height: 37px;
|
||||
background-color: var(--background-stronger);
|
||||
}
|
||||
}
|
||||
@@ -1199,11 +1551,12 @@
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-trigger-content"] {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 20px;
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-file-info"] {
|
||||
@@ -1237,9 +1590,9 @@
|
||||
[data-slot="apply-patch-trigger-actions"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
[data-slot="apply-patch-change"] {
|
||||
@@ -1279,10 +1632,11 @@
|
||||
}
|
||||
|
||||
[data-component="tool-loaded-file"] {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0 4px 28px;
|
||||
padding: 4px 0 4px 12px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-regular);
|
||||
@@ -1293,4 +1647,11 @@
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,9 @@
|
||||
import { attachSpring, motionValue } from "motion"
|
||||
import type { SpringOptions } from "motion"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
||||
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
|
||||
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
|
||||
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||
a?.visualDuration === b?.visualDuration &&
|
||||
a?.bounce === b?.bounce &&
|
||||
@@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||
|
||||
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
||||
const read = () => (typeof options === "function" ? options() : options)
|
||||
const reduce = prefersReducedMotion
|
||||
const [value, setValue] = createSignal(target())
|
||||
const source = motionValue(value())
|
||||
const spring = motionValue(value())
|
||||
let config = read()
|
||||
let stop = attachSpring(spring, source, config)
|
||||
let off = spring.on("change", (next: number) => setValue(next))
|
||||
let reduced = reduce()
|
||||
let stop = reduced ? () => {} : attachSpring(spring, source, config)
|
||||
let off = spring.on("change", (next) => setValue(next))
|
||||
|
||||
createEffect(() => {
|
||||
source.set(target())
|
||||
const next = target()
|
||||
if (reduced) {
|
||||
source.set(next)
|
||||
spring.set(next)
|
||||
setValue(next)
|
||||
return
|
||||
}
|
||||
source.set(next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!options) return
|
||||
const next = read()
|
||||
if (eq(config, next)) return
|
||||
const skip = reduce()
|
||||
if (eq(config, next) && reduced === skip) return
|
||||
config = next
|
||||
reduced = skip
|
||||
stop()
|
||||
stop = attachSpring(spring, source, next)
|
||||
stop = skip ? () => {} : attachSpring(spring, source, next)
|
||||
if (skip) {
|
||||
const value = target()
|
||||
source.set(value)
|
||||
spring.set(value)
|
||||
setValue(value)
|
||||
return
|
||||
}
|
||||
setValue(spring.get())
|
||||
})
|
||||
|
||||
|
||||
77
packages/ui/src/components/motion.tsx
Normal file
77
packages/ui/src/components/motion.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { followValue } from "motion"
|
||||
import type { MotionValue } from "motion"
|
||||
|
||||
export { animate, springValue } from "motion"
|
||||
export type { AnimationPlaybackControls } from "motion"
|
||||
|
||||
/**
|
||||
* Like `springValue` but preserves getters on the config object.
|
||||
* `springValue` spreads config at creation, snapshotting getter values.
|
||||
* This passes the config through to `followValue` intact, so getters
|
||||
* on `visualDuration` etc. fire on every `.set()` call.
|
||||
*/
|
||||
export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
|
||||
return followValue(initial, config as any)
|
||||
}
|
||||
|
||||
let _growDuration = 0.5
|
||||
let _collapsibleDuration = 0.3
|
||||
|
||||
export const GROW_SPRING = {
|
||||
type: "spring" as const,
|
||||
get visualDuration() {
|
||||
return _growDuration
|
||||
},
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const COLLAPSIBLE_SPRING = {
|
||||
type: "spring" as const,
|
||||
get visualDuration() {
|
||||
return _collapsibleDuration
|
||||
},
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const setGrowDuration = (v: number) => {
|
||||
_growDuration = v
|
||||
}
|
||||
export const setCollapsibleDuration = (v: number) => {
|
||||
_collapsibleDuration = v
|
||||
}
|
||||
export const getGrowDuration = () => _growDuration
|
||||
export const getCollapsibleDuration = () => _collapsibleDuration
|
||||
|
||||
export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
|
||||
|
||||
export const FAST_SPRING = {
|
||||
type: "spring" as const,
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const GLOW_SPRING = {
|
||||
type: "spring" as const,
|
||||
visualDuration: 0.4,
|
||||
bounce: 0.15,
|
||||
}
|
||||
|
||||
export const WIPE_MASK =
|
||||
"linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
|
||||
|
||||
export const clearMaskStyles = (el: HTMLElement) => {
|
||||
el.style.maskImage = ""
|
||||
el.style.webkitMaskImage = ""
|
||||
el.style.maskSize = ""
|
||||
el.style.webkitMaskSize = ""
|
||||
el.style.maskRepeat = ""
|
||||
el.style.webkitMaskRepeat = ""
|
||||
el.style.maskPosition = ""
|
||||
el.style.webkitMaskPosition = ""
|
||||
}
|
||||
|
||||
export const clearFadeStyles = (el: HTMLElement) => {
|
||||
el.style.opacity = ""
|
||||
el.style.filter = ""
|
||||
el.style.transform = ""
|
||||
}
|
||||
451
packages/ui/src/components/new-composer.stories.tsx
Normal file
451
packages/ui/src/components/new-composer.stories.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { NewComposer } from "./new-composer"
|
||||
|
||||
const docs = `### Overview
|
||||
Runtime-ready composer story with visible canvas controls + Storybook controls.
|
||||
|
||||
### Canvas controls
|
||||
- Input / Question / Permission mode
|
||||
- Todos, collapse, context, drag overlay
|
||||
- Keyboard: Ctrl+1..7 toggles, Ctrl+8/9 answer count (Q1), Ctrl+[/] answer count (Q2), Ctrl+;/' answer count (Q3), Ctrl+0/- todo count
|
||||
|
||||
### Storybook controls
|
||||
- mode
|
||||
- working
|
||||
- accepting
|
||||
- showTodos
|
||||
- todoCollapsed
|
||||
- forceDragType
|
||||
- answerCount1
|
||||
- answerCount2
|
||||
- answerCount3
|
||||
- todoCount
|
||||
`
|
||||
|
||||
export default {
|
||||
title: "UI/NewComposer",
|
||||
id: "components-new-composer",
|
||||
component: NewComposer,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: docs,
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
mode: {
|
||||
control: "select",
|
||||
options: ["input", "question", "permission"],
|
||||
},
|
||||
working: {
|
||||
control: "boolean",
|
||||
},
|
||||
accepting: {
|
||||
control: "boolean",
|
||||
},
|
||||
showTodos: {
|
||||
control: "boolean",
|
||||
},
|
||||
todoCollapsed: {
|
||||
control: "boolean",
|
||||
},
|
||||
forceDragType: {
|
||||
control: "select",
|
||||
options: [null, "image", "@mention"],
|
||||
},
|
||||
answerCount1: {
|
||||
control: { type: "range", min: 1, max: 6, step: 1 },
|
||||
},
|
||||
answerCount2: {
|
||||
control: { type: "range", min: 1, max: 6, step: 1 },
|
||||
},
|
||||
answerCount3: {
|
||||
control: { type: "range", min: 1, max: 6, step: 1 },
|
||||
},
|
||||
todoCount: {
|
||||
control: { type: "range", min: 0, max: 8, step: 1 },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const questions = [
|
||||
{
|
||||
text: "Which editor do you use most often?",
|
||||
options: [
|
||||
{ label: "Neovim", description: "Fast keyboard-driven workflow" },
|
||||
{ label: "VS Code", description: "Feature-rich and extensible" },
|
||||
{ label: "Zed", description: "Lightweight modern editor" },
|
||||
{ label: "JetBrains IDE", description: "Deep language tooling" },
|
||||
{ label: "Sublime Text", description: "Fast, lightweight setup" },
|
||||
{ label: "Helix", description: "Modal editor with tree-sitter" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
{
|
||||
text: "Which testing frameworks should we add?",
|
||||
options: [
|
||||
{ label: "Vitest", description: "Fast unit testing" },
|
||||
{ label: "Playwright", description: "E2E browser testing" },
|
||||
{ label: "Testing Library", description: "Component testing" },
|
||||
{ label: "Cypress", description: "Browser integration tests" },
|
||||
{ label: "Jest", description: "Legacy compatibility" },
|
||||
{ label: "Storybook Tests", description: "Interaction smoke tests" },
|
||||
],
|
||||
multiple: true,
|
||||
},
|
||||
{
|
||||
text: "How strict should linting be?",
|
||||
options: [
|
||||
{ label: "Minimal", description: "Keep only important rules" },
|
||||
{ label: "Balanced", description: "Recommended defaults" },
|
||||
{ label: "Strict", description: "Catch everything possible" },
|
||||
{ label: "Very strict", description: "Treat warnings as errors" },
|
||||
{ label: "Preset by package", description: "Different rules per surface" },
|
||||
{ label: "Experimental", description: "Try strict mode for one sprint" },
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
]
|
||||
|
||||
const todos = [
|
||||
{ content: "Read auth module", status: "completed" },
|
||||
{ content: "Refactor token logic", status: "in_progress" },
|
||||
{ content: "Add tests", status: "pending" },
|
||||
{ content: "Ship changes", status: "pending" },
|
||||
{ content: "Check telemetry events", status: "pending" },
|
||||
{ content: "Validate markdown rendering", status: "pending" },
|
||||
{ content: "Run regression suite", status: "pending" },
|
||||
{ content: "Update release notes", status: "pending" },
|
||||
]
|
||||
|
||||
const ctx = [
|
||||
{ id: "a", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "Check token refresh" },
|
||||
{ id: "b", path: "src/routes/login.ts", comment: "Review edge cases" },
|
||||
]
|
||||
|
||||
const dragModes = [null, "image", "@mention"] as const
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
|
||||
const panel = {
|
||||
position: "fixed",
|
||||
top: "14px",
|
||||
left: "14px",
|
||||
display: "flex",
|
||||
"flex-wrap": "wrap",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
"border-radius": "10px",
|
||||
background: "rgba(0, 0, 0, 0.55)",
|
||||
border: "1px solid rgba(255, 255, 255, 0.12)",
|
||||
color: "#fff",
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
"z-index": 50,
|
||||
} as const
|
||||
|
||||
const btn = (on: boolean) =>
|
||||
({
|
||||
padding: "4px 8px",
|
||||
"border-radius": "8px",
|
||||
border: "1px solid rgba(255, 255, 255, 0.2)",
|
||||
background: on ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.07)",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
}) as const
|
||||
|
||||
export const Interactive = {
|
||||
args: {
|
||||
mode: "input",
|
||||
working: false,
|
||||
accepting: false,
|
||||
showTodos: true,
|
||||
todoCollapsed: false,
|
||||
forceDragType: null,
|
||||
answerCount1: 4,
|
||||
answerCount2: 3,
|
||||
answerCount3: 5,
|
||||
todoCount: 4,
|
||||
},
|
||||
render: (args) => {
|
||||
const limit = (value: number, i: number) => Math.max(1, Math.min(questions[i].options.length, value))
|
||||
|
||||
const [mode, setMode] = createSignal(args.mode ?? "input")
|
||||
const [tab, setTab] = createSignal(0)
|
||||
const [work, setWork] = createSignal(!!args.working)
|
||||
const [accept, setAccept] = createSignal(!!args.accepting)
|
||||
const [showTodos, setShowTodos] = createSignal(args.showTodos ?? true)
|
||||
const [todoCollapsed, setTodoCollapsed] = createSignal(args.todoCollapsed ?? false)
|
||||
const [drag, setDrag] = createSignal(args.forceDragType ?? null)
|
||||
const [a1, setA1] = createSignal(limit(args.answerCount1 ?? 4, 0))
|
||||
const [a2, setA2] = createSignal(limit(args.answerCount2 ?? 3, 1))
|
||||
const [a3, setA3] = createSignal(limit(args.answerCount3 ?? 5, 2))
|
||||
const [tCount, setTCount] = createSignal(Math.max(0, Math.min(todos.length, args.todoCount ?? 4)))
|
||||
const [showCtx, setShowCtx] = createSignal(true)
|
||||
const [agent, setAgent] = createSignal("ask")
|
||||
const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex")
|
||||
const [variant, setVariant] = createSignal("default")
|
||||
const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] })
|
||||
|
||||
createEffect(() => setMode(args.mode ?? "input"))
|
||||
createEffect(() => setWork(!!args.working))
|
||||
createEffect(() => setAccept(!!args.accepting))
|
||||
createEffect(() => setShowTodos(args.showTodos ?? true))
|
||||
createEffect(() => setTodoCollapsed(args.todoCollapsed ?? false))
|
||||
createEffect(() => setDrag(args.forceDragType ?? null))
|
||||
createEffect(() => setA1(limit(args.answerCount1 ?? 4, 0)))
|
||||
createEffect(() => setA2(limit(args.answerCount2 ?? 3, 1)))
|
||||
createEffect(() => setA3(limit(args.answerCount3 ?? 5, 2)))
|
||||
createEffect(() => setTCount(Math.max(0, Math.min(todos.length, args.todoCount ?? 4))))
|
||||
|
||||
const qList = createMemo(() => [
|
||||
{ ...questions[0], options: questions[0].options.slice(0, a1()) },
|
||||
{ ...questions[1], options: questions[1].options.slice(0, a2()) },
|
||||
{ ...questions[2], options: questions[2].options.slice(0, a3()) },
|
||||
])
|
||||
const tList = createMemo(() => todos.slice(0, tCount()))
|
||||
|
||||
createEffect(() => {
|
||||
if (tab() <= qList().length - 1) return
|
||||
setTab(qList().length - 1)
|
||||
})
|
||||
|
||||
const q = createMemo(() => qList()[tab()] ?? qList()[0])
|
||||
|
||||
const runtime = {
|
||||
submit: async (input) => {
|
||||
setWork(true)
|
||||
await wait(550)
|
||||
setWork(false)
|
||||
console.log("submit", input)
|
||||
},
|
||||
abort: () => {
|
||||
setWork(false)
|
||||
},
|
||||
toggleAccept: () => setAccept((v) => !v),
|
||||
runSlash: (cmd) => {
|
||||
if (cmd.type !== "builtin") return false
|
||||
console.log("slash:run", cmd.id)
|
||||
return true
|
||||
},
|
||||
historyRead: (mode) => history()[mode],
|
||||
historyWrite: (mode, list) => {
|
||||
setHistory((prev) => ({ ...prev, [mode]: list }))
|
||||
},
|
||||
decidePermission: async (response) => {
|
||||
console.log("permission", response)
|
||||
await wait(200)
|
||||
setMode("input")
|
||||
},
|
||||
submitQuestion: async (answers) => {
|
||||
console.log("question", answers)
|
||||
await wait(250)
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
},
|
||||
rejectQuestion: async () => {
|
||||
await wait(150)
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
},
|
||||
openContext: (item) => console.log("open context", item),
|
||||
removeContext: (item) => console.log("remove context", item),
|
||||
}
|
||||
|
||||
const cycle = () => {
|
||||
const i = dragModes.findIndex((v) => v === drag())
|
||||
const n = (i + 1) % dragModes.length
|
||||
setDrag(dragModes[n])
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return
|
||||
if (target instanceof HTMLElement && target.isContentEditable) return
|
||||
|
||||
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
|
||||
|
||||
let hit = false
|
||||
if (event.key === "1") {
|
||||
setMode("input")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "2") {
|
||||
setMode("question")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "3") {
|
||||
setMode("permission")
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "4") {
|
||||
setTodoCollapsed((v) => !v)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "5") {
|
||||
setShowTodos((v) => !v)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "6") {
|
||||
setShowCtx((v) => !v)
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "7") {
|
||||
cycle()
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "8") {
|
||||
setA1((v) => limit(v - 1, 0))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "9") {
|
||||
setA1((v) => limit(v + 1, 0))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "[") {
|
||||
setA2((v) => limit(v - 1, 1))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "]") {
|
||||
setA2((v) => limit(v + 1, 1))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === ";") {
|
||||
setA3((v) => limit(v - 1, 2))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "'") {
|
||||
setA3((v) => limit(v + 1, 2))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "0") {
|
||||
setTCount((v) => Math.max(0, v - 1))
|
||||
hit = true
|
||||
}
|
||||
if (event.key === "-") {
|
||||
setTCount((v) => Math.min(todos.length, v + 1))
|
||||
hit = true
|
||||
}
|
||||
|
||||
if (!hit) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey)
|
||||
onCleanup(() => window.removeEventListener("keydown", onKey))
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ position: "fixed", left: 0, right: 0, bottom: 0, padding: "20px" }}>
|
||||
<div style={panel}>
|
||||
<button style={btn(mode() === "input")} onClick={() => setMode("input")}>
|
||||
Ctrl+1 Input
|
||||
</button>
|
||||
<button style={btn(mode() === "question")} onClick={() => setMode("question")}>
|
||||
Ctrl+2 Question
|
||||
</button>
|
||||
<button style={btn(mode() === "permission")} onClick={() => setMode("permission")}>
|
||||
Ctrl+3 Permission
|
||||
</button>
|
||||
<button style={btn(showTodos())} onClick={() => setShowTodos((v) => !v)}>
|
||||
Ctrl+5 Todos
|
||||
</button>
|
||||
<button style={btn(todoCollapsed())} onClick={() => setTodoCollapsed((v) => !v)}>
|
||||
Ctrl+4 Collapse
|
||||
</button>
|
||||
<button style={btn(showCtx())} onClick={() => setShowCtx((v) => !v)}>
|
||||
Ctrl+6 Context
|
||||
</button>
|
||||
<button style={btn(!!drag())} onClick={cycle}>
|
||||
Ctrl+7 Drag: {drag() ?? "off"}
|
||||
</button>
|
||||
<button style={btn(work())} onClick={() => setWork((v) => !v)}>
|
||||
Working
|
||||
</button>
|
||||
<button style={btn(accept())} onClick={() => setAccept((v) => !v)}>
|
||||
Auto-accept
|
||||
</button>
|
||||
<button style={btn(false)} onClick={() => setA1((v) => limit(v - 1, 0))}>
|
||||
Ctrl+8 Q1-
|
||||
</button>
|
||||
<span>Q1:{a1()}</span>
|
||||
<button style={btn(false)} onClick={() => setA1((v) => limit(v + 1, 0))}>
|
||||
Ctrl+9 Q1+
|
||||
</button>
|
||||
<button style={btn(false)} onClick={() => setA2((v) => limit(v - 1, 1))}>
|
||||
Ctrl+[ Q2-
|
||||
</button>
|
||||
<span>Q2:{a2()}</span>
|
||||
<button style={btn(false)} onClick={() => setA2((v) => limit(v + 1, 1))}>
|
||||
Ctrl+] Q2+
|
||||
</button>
|
||||
<button style={btn(false)} onClick={() => setA3((v) => limit(v - 1, 2))}>
|
||||
Ctrl+; Q3-
|
||||
</button>
|
||||
<span>Q3:{a3()}</span>
|
||||
<button style={btn(false)} onClick={() => setA3((v) => limit(v + 1, 2))}>
|
||||
Ctrl+' Q3+
|
||||
</button>
|
||||
<button style={btn(false)} onClick={() => setTCount((v) => Math.max(0, v - 1))}>
|
||||
Ctrl+0 T-
|
||||
</button>
|
||||
<span>T:{tCount()}</span>
|
||||
<button style={btn(false)} onClick={() => setTCount((v) => Math.min(todos.length, v + 1))}>
|
||||
Ctrl+- T+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NewComposer
|
||||
mode={mode()}
|
||||
runtime={runtime}
|
||||
agentOptions={["ask", "coder", "reviewer"]}
|
||||
modelOptions={[
|
||||
"OpenAI/GPT-5.2",
|
||||
"OpenAI/GPT-5.3 Codex",
|
||||
"OpenAI/GPT-5.3 Codex Spark",
|
||||
"Anthropic/Claude Sonnet 4",
|
||||
"Anthropic/Claude Haiku 4.5",
|
||||
"Google/Gemini 2.5 Pro",
|
||||
]}
|
||||
variantOptions={["default", "fast", "quality"]}
|
||||
agentCurrent={agent()}
|
||||
modelCurrent={model()}
|
||||
variantCurrent={variant()}
|
||||
onAgentSelect={setAgent}
|
||||
onModelSelect={setModel}
|
||||
onVariantSelect={setVariant}
|
||||
agentKeybind="mod+."
|
||||
modelKeybind="mod+'"
|
||||
variantKeybind="shift+mod+d"
|
||||
working={work()}
|
||||
accepting={accept()}
|
||||
questionText={q()?.text ?? "Which editor do you use most often?"}
|
||||
questionOptions={q()?.options ?? []}
|
||||
questionMultiple={q()?.multiple ?? false}
|
||||
questionIndex={tab()}
|
||||
questionTotal={qList().length}
|
||||
onQuestionBack={() => setTab((v) => Math.max(0, v - 1))}
|
||||
onQuestionNext={() => setTab((v) => Math.min(qList().length - 1, v + 1))}
|
||||
onQuestionJump={setTab}
|
||||
onQuestionDismiss={() => {
|
||||
setMode("input")
|
||||
setTab(0)
|
||||
}}
|
||||
showTodos={showTodos()}
|
||||
todoCollapsed={todoCollapsed()}
|
||||
onTodoCollapseChange={setTodoCollapsed}
|
||||
todos={tList()}
|
||||
contextItems={showCtx() ? ctx : []}
|
||||
forceDragType={drag()}
|
||||
permissionDescription="This action needs write access to project files."
|
||||
permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
1097
packages/ui/src/components/new-composer.tsx
Normal file
1097
packages/ui/src/components/new-composer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
64
packages/ui/src/components/new-composer/defaults.ts
Normal file
64
packages/ui/src/components/new-composer/defaults.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { AtOption, SlashCommand } from "./types"
|
||||
|
||||
export const DEFAULT_AT_OPTIONS: AtOption[] = [
|
||||
{ type: "file", path: "src/auth.ts", display: "src/auth.ts" },
|
||||
{ type: "file", path: "src/middleware.ts", display: "src/middleware.ts" },
|
||||
{ type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" },
|
||||
{ type: "file", path: "src/utils/token.ts", display: "src/utils/token.ts" },
|
||||
{ type: "file", path: "src/config/database.ts", display: "src/config/database.ts" },
|
||||
{ type: "agent", name: "coder", display: "coder" },
|
||||
{ type: "agent", name: "reviewer", display: "reviewer" },
|
||||
{ type: "agent", name: "planner", display: "planner" },
|
||||
]
|
||||
|
||||
export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
id: "help",
|
||||
trigger: "help",
|
||||
title: "Help",
|
||||
description: "Show available commands",
|
||||
type: "builtin",
|
||||
keybind: "?",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "clear",
|
||||
trigger: "clear",
|
||||
title: "Clear",
|
||||
description: "Clear conversation",
|
||||
type: "builtin",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "compact",
|
||||
trigger: "compact",
|
||||
title: "Compact",
|
||||
description: "Compact conversation history",
|
||||
type: "builtin",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "init",
|
||||
trigger: "init",
|
||||
title: "Init",
|
||||
description: "Initialize CLAUDE.md",
|
||||
type: "builtin",
|
||||
source: "command",
|
||||
},
|
||||
{
|
||||
id: "review",
|
||||
trigger: "review",
|
||||
title: "Review",
|
||||
description: "Review code changes",
|
||||
type: "custom",
|
||||
source: "skill",
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
trigger: "test",
|
||||
title: "Test",
|
||||
description: "Run tests",
|
||||
type: "custom",
|
||||
source: "mcp",
|
||||
},
|
||||
]
|
||||
23
packages/ui/src/components/new-composer/drag-overlay.tsx
Normal file
23
packages/ui/src/components/new-composer/drag-overlay.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Show } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Icon } from "../icon"
|
||||
|
||||
interface Props {
|
||||
type: "image" | "@mention" | null
|
||||
}
|
||||
|
||||
export function ComposerDragOverlay(props: Props) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<Show when={props.type}>
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
|
||||
<div class="flex flex-col items-center gap-2 text-text-weak">
|
||||
<Icon name={props.type === "image" ? "photo" : "link"} class="size-8" />
|
||||
<span class="text-14-regular">
|
||||
{props.type === "image" ? i18n.t("ui.prompt.dropzone.attach") : i18n.t("ui.prompt.dropzone.mention")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
100
packages/ui/src/components/new-composer/editor-utils.test.ts
Normal file
100
packages/ui/src/components/new-composer/editor-utils.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-utils"
|
||||
|
||||
describe("new-composer editor utils", () => {
|
||||
test("createTextFragment preserves newlines with consecutive br nodes", () => {
|
||||
const fragment = createTextFragment("foo\n\nbar")
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(fragment)
|
||||
|
||||
expect(box.childNodes.length).toBe(4)
|
||||
expect(box.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
expect((box.childNodes[2] as HTMLElement).tagName).toBe("BR")
|
||||
expect(box.childNodes[3]?.textContent).toBe("bar")
|
||||
})
|
||||
|
||||
test("createTextFragment keeps trailing newline as terminal break", () => {
|
||||
const fragment = createTextFragment("foo\n")
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(fragment)
|
||||
|
||||
expect(box.childNodes.length).toBe(2)
|
||||
expect(box.childNodes[0]?.textContent).toBe("foo")
|
||||
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("createTextFragment avoids break-node explosion for large multiline content", () => {
|
||||
const value = Array.from({ length: 220 }, () => "line").join("\n")
|
||||
const fragment = createTextFragment(value)
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(fragment)
|
||||
|
||||
expect(box.childNodes.length).toBe(1)
|
||||
expect(box.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE)
|
||||
expect(box.textContent).toBe(value)
|
||||
})
|
||||
|
||||
test("createTextFragment keeps terminal break in large multiline fallback", () => {
|
||||
const value = `${Array.from({ length: 220 }, () => "line").join("\n")}\n`
|
||||
const fragment = createTextFragment(value)
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(fragment)
|
||||
|
||||
expect(box.childNodes.length).toBe(2)
|
||||
expect(box.childNodes[0]?.textContent).toBe(value.slice(0, -1))
|
||||
expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR")
|
||||
})
|
||||
|
||||
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(document.createTextNode("ab\u200B"))
|
||||
box.appendChild(document.createElement("br"))
|
||||
box.appendChild(document.createTextNode("cd"))
|
||||
|
||||
expect(getNodeLength(box.childNodes[0]!)).toBe(2)
|
||||
expect(getNodeLength(box.childNodes[1]!)).toBe(1)
|
||||
expect(getTextLength(box)).toBe(5)
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
|
||||
const box = document.createElement("div")
|
||||
const pill = document.createElement("span")
|
||||
pill.dataset.type = "file"
|
||||
pill.textContent = "@file"
|
||||
|
||||
box.appendChild(document.createTextNode("ab"))
|
||||
box.appendChild(pill)
|
||||
box.appendChild(document.createElement("br"))
|
||||
box.appendChild(document.createTextNode("cd"))
|
||||
document.body.appendChild(box)
|
||||
|
||||
setCursorPosition(box, 2)
|
||||
expect(getCursorPosition(box)).toBe(2)
|
||||
|
||||
setCursorPosition(box, 7)
|
||||
expect(getCursorPosition(box)).toBe(7)
|
||||
|
||||
setCursorPosition(box, 8)
|
||||
expect(getCursorPosition(box)).toBe(8)
|
||||
|
||||
box.remove()
|
||||
})
|
||||
|
||||
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
|
||||
const box = document.createElement("div")
|
||||
box.appendChild(document.createTextNode("a"))
|
||||
box.appendChild(document.createElement("br"))
|
||||
box.appendChild(document.createElement("br"))
|
||||
box.appendChild(document.createTextNode("b"))
|
||||
document.body.appendChild(box)
|
||||
|
||||
setCursorPosition(box, 2)
|
||||
expect(getCursorPosition(box)).toBe(2)
|
||||
|
||||
setCursorPosition(box, 3)
|
||||
expect(getCursorPosition(box)).toBe(3)
|
||||
|
||||
box.remove()
|
||||
})
|
||||
})
|
||||
206
packages/ui/src/components/new-composer/editor-utils.ts
Normal file
206
packages/ui/src/components/new-composer/editor-utils.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { ComposerPart } from "./types"
|
||||
|
||||
const MAX_BREAKS = 200
|
||||
|
||||
export function createTextFragment(content: string): DocumentFragment {
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
let breaks = 0
|
||||
for (const char of content) {
|
||||
if (char !== "\n") continue
|
||||
breaks += 1
|
||||
if (breaks > MAX_BREAKS) {
|
||||
const tail = content.endsWith("\n")
|
||||
const text = tail ? content.slice(0, -1) : content
|
||||
if (text) fragment.appendChild(document.createTextNode(text))
|
||||
if (tail) fragment.appendChild(document.createElement("br"))
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
|
||||
const parts = content.split("\n")
|
||||
parts.forEach((part, i) => {
|
||||
if (part) fragment.appendChild(document.createTextNode(part))
|
||||
if (i < parts.length - 1) fragment.appendChild(document.createElement("br"))
|
||||
})
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function getNodeLength(node: Node): number {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||
return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||
}
|
||||
|
||||
export function getTextLength(node: Node): number {
|
||||
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
|
||||
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
|
||||
let len = 0
|
||||
for (const child of Array.from(node.childNodes)) len += getTextLength(child)
|
||||
return len
|
||||
}
|
||||
|
||||
export function getCursorPosition(parent: HTMLElement): number {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return 0
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!parent.contains(range.startContainer)) return 0
|
||||
const copy = range.cloneRange()
|
||||
copy.selectNodeContents(parent)
|
||||
copy.setEnd(range.startContainer, range.startOffset)
|
||||
return getTextLength(copy.cloneContents())
|
||||
}
|
||||
|
||||
export function setCursorPosition(parent: HTMLElement, position: number) {
|
||||
let rest = position
|
||||
let node = parent.firstChild
|
||||
while (node) {
|
||||
const len = getNodeLength(node)
|
||||
const text = node.nodeType === Node.TEXT_NODE
|
||||
const pill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (text && rest <= len) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
range.setStart(node, rest)
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
if ((pill || br) && rest <= len) {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()
|
||||
if (rest === 0) range.setStartBefore(node)
|
||||
else if (pill) range.setStartAfter(node)
|
||||
else {
|
||||
const next = node.nextSibling
|
||||
if (next && next.nodeType === Node.TEXT_NODE) range.setStart(next, 0)
|
||||
else range.setStartAfter(node)
|
||||
}
|
||||
range.collapse(true)
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
return
|
||||
}
|
||||
rest -= len
|
||||
node = node.nextSibling
|
||||
}
|
||||
|
||||
const end = document.createRange()
|
||||
end.selectNodeContents(parent)
|
||||
end.collapse(false)
|
||||
window.getSelection()?.removeAllRanges()
|
||||
window.getSelection()?.addRange(end)
|
||||
}
|
||||
|
||||
export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
|
||||
let rest = offset
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
const len = getNodeLength(node)
|
||||
const text = node.nodeType === Node.TEXT_NODE
|
||||
const pill =
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
|
||||
const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
|
||||
|
||||
if (text && rest <= len) {
|
||||
if (edge === "start") range.setStart(node, rest)
|
||||
else range.setEnd(node, rest)
|
||||
return
|
||||
}
|
||||
if ((pill || br) && rest <= len) {
|
||||
if (edge === "start") rest === 0 ? range.setStartBefore(node) : range.setStartAfter(node)
|
||||
else rest === 0 ? range.setEndBefore(node) : range.setEndAfter(node)
|
||||
return
|
||||
}
|
||||
rest -= len
|
||||
}
|
||||
}
|
||||
|
||||
export function createPill(type: "file" | "agent", content: string, path?: string) {
|
||||
const el = document.createElement("span")
|
||||
el.textContent = content
|
||||
el.setAttribute("data-type", type)
|
||||
if (type === "file" && path) el.setAttribute("data-path", path)
|
||||
if (type === "agent") el.setAttribute("data-name", content.replace("@", ""))
|
||||
el.setAttribute("contenteditable", "false")
|
||||
el.style.userSelect = "text"
|
||||
el.style.cursor = "default"
|
||||
return el
|
||||
}
|
||||
|
||||
export function parseEditorText(editor: HTMLElement): string {
|
||||
return parseEditorParts(editor)
|
||||
.map((part) => part.content)
|
||||
.join("")
|
||||
}
|
||||
|
||||
function pushText(parts: ComposerPart[], text: string) {
|
||||
if (!text) return
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.type === "text") {
|
||||
last.content += text
|
||||
return
|
||||
}
|
||||
parts.push({ type: "text", content: text })
|
||||
}
|
||||
|
||||
export function parseEditorParts(editor: HTMLElement): ComposerPart[] {
|
||||
const parts: ComposerPart[] = []
|
||||
let text = ""
|
||||
|
||||
const flush = () => {
|
||||
if (!text) return
|
||||
pushText(parts, text)
|
||||
text = ""
|
||||
}
|
||||
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
text += (node.textContent ?? "").replace(/\u200B/g, "")
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
const el = node as HTMLElement
|
||||
if (el.dataset.type === "file" || el.dataset.type === "agent") {
|
||||
flush()
|
||||
const content = el.textContent ?? ""
|
||||
if (el.dataset.type === "file") {
|
||||
parts.push({
|
||||
type: "file",
|
||||
path: el.dataset.path ?? content,
|
||||
content,
|
||||
})
|
||||
}
|
||||
if (el.dataset.type === "agent") {
|
||||
parts.push({
|
||||
type: "agent",
|
||||
name: el.dataset.name ?? content.replace(/^@/, ""),
|
||||
content,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (el.tagName === "BR") {
|
||||
text += "\n"
|
||||
return
|
||||
}
|
||||
for (const child of Array.from(el.childNodes)) visit(child)
|
||||
}
|
||||
|
||||
const nodes = Array.from(editor.childNodes)
|
||||
nodes.forEach((node, i) => {
|
||||
const block = node.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((node as HTMLElement).tagName)
|
||||
visit(node)
|
||||
if (block && i < nodes.length - 1) text += "\n"
|
||||
})
|
||||
flush()
|
||||
return parts
|
||||
}
|
||||
|
||||
export function isImeComposing(event: KeyboardEvent): boolean {
|
||||
return event.isComposing || (event as unknown as { keyCode?: number }).keyCode === 229
|
||||
}
|
||||
128
packages/ui/src/components/new-composer/history.test.ts
Normal file
128
packages/ui/src/components/new-composer/history.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
canNavigateAtCursor,
|
||||
cloneParts,
|
||||
navigateEntry,
|
||||
normalizeEntry,
|
||||
partText,
|
||||
prependEntry,
|
||||
type HistoryEntry,
|
||||
} from "./history"
|
||||
|
||||
const text = (value: string): HistoryEntry => ({
|
||||
text: value,
|
||||
parts: [{ type: "text", content: value }],
|
||||
})
|
||||
|
||||
describe("new-composer history", () => {
|
||||
test("prependEntry skips empty entries and deduplicates consecutive entries", () => {
|
||||
const blank: HistoryEntry[] = []
|
||||
const first = prependEntry(blank, text(""))
|
||||
expect(first).toBe(blank)
|
||||
|
||||
const one = prependEntry([], text("hello"))
|
||||
expect(one).toHaveLength(1)
|
||||
|
||||
const dup = prependEntry(one, text("hello"))
|
||||
expect(dup).toBe(one)
|
||||
})
|
||||
|
||||
test("navigateEntry restores saved draft when moving down from newest", () => {
|
||||
const entries = [text("third"), text("second"), text("first")]
|
||||
|
||||
const up = navigateEntry({
|
||||
direction: "up",
|
||||
entries,
|
||||
historyIndex: -1,
|
||||
current: text("draft"),
|
||||
saved: null,
|
||||
})
|
||||
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.historyIndex).toBe(0)
|
||||
expect(up.cursor).toBe("start")
|
||||
expect(up.entry.text).toBe("third")
|
||||
|
||||
const down = navigateEntry({
|
||||
direction: "down",
|
||||
entries,
|
||||
historyIndex: up.historyIndex,
|
||||
current: text("ignored"),
|
||||
saved: up.saved,
|
||||
})
|
||||
|
||||
expect(down.handled).toBe(true)
|
||||
if (!down.handled) throw new Error("expected handled")
|
||||
expect(down.historyIndex).toBe(-1)
|
||||
expect(down.entry.text).toBe("draft")
|
||||
expect(down.entry.parts).toEqual([{ type: "text", content: "draft" }])
|
||||
})
|
||||
|
||||
test("navigateEntry keeps structured parts when navigating history", () => {
|
||||
const entry: HistoryEntry = {
|
||||
text: "@src/auth.ts",
|
||||
parts: [{ type: "file", path: "src/auth.ts", content: "@src/auth.ts" }],
|
||||
}
|
||||
|
||||
const up = navigateEntry({
|
||||
direction: "up",
|
||||
entries: [entry],
|
||||
historyIndex: -1,
|
||||
current: text("draft"),
|
||||
saved: null,
|
||||
})
|
||||
|
||||
expect(up.handled).toBe(true)
|
||||
if (!up.handled) throw new Error("expected handled")
|
||||
expect(up.entry.parts).toEqual(entry.parts)
|
||||
})
|
||||
|
||||
test("normalizeEntry supports legacy string entries", () => {
|
||||
const entry = normalizeEntry("legacy")
|
||||
expect(entry.text).toBe("legacy")
|
||||
expect(entry.parts).toEqual([{ type: "text", content: "legacy" }])
|
||||
})
|
||||
|
||||
test("helpers clone parts and count combined content", () => {
|
||||
const src = [
|
||||
{ type: "text", content: "one" },
|
||||
{ type: "file", path: "src/a.ts", content: "@src/a.ts" },
|
||||
{ type: "agent", name: "coder", content: "@coder" },
|
||||
] as const
|
||||
|
||||
const copy = cloneParts([...src])
|
||||
expect(copy).not.toBe(src)
|
||||
expect(partText(copy)).toBe("one@src/a.ts@coder")
|
||||
|
||||
if (copy[1]?.type !== "file") throw new Error("expected file")
|
||||
copy[1].path = "src/b.ts"
|
||||
if (src[1].type !== "file") throw new Error("expected file")
|
||||
expect(src[1].path).toBe("src/a.ts")
|
||||
})
|
||||
|
||||
test("canNavigateAtCursor only allows history navigation at boundaries", () => {
|
||||
const value = "a\nb\nc"
|
||||
|
||||
expect(canNavigateAtCursor("up", value, 0)).toBe(true)
|
||||
expect(canNavigateAtCursor("down", value, 0)).toBe(false)
|
||||
|
||||
expect(canNavigateAtCursor("up", value, 2)).toBe(false)
|
||||
expect(canNavigateAtCursor("down", value, 2)).toBe(false)
|
||||
|
||||
expect(canNavigateAtCursor("up", value, 5)).toBe(false)
|
||||
expect(canNavigateAtCursor("down", value, 5)).toBe(true)
|
||||
|
||||
expect(canNavigateAtCursor("up", "abc", 0)).toBe(true)
|
||||
expect(canNavigateAtCursor("down", "abc", 3)).toBe(true)
|
||||
expect(canNavigateAtCursor("up", "abc", 1)).toBe(false)
|
||||
expect(canNavigateAtCursor("down", "abc", 1)).toBe(false)
|
||||
|
||||
expect(canNavigateAtCursor("up", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateAtCursor("up", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateAtCursor("down", "abc", 0, true)).toBe(true)
|
||||
expect(canNavigateAtCursor("down", "abc", 3, true)).toBe(true)
|
||||
expect(canNavigateAtCursor("up", "abc", 1, true)).toBe(false)
|
||||
expect(canNavigateAtCursor("down", "abc", 1, true)).toBe(false)
|
||||
})
|
||||
})
|
||||
189
packages/ui/src/components/new-composer/history.ts
Normal file
189
packages/ui/src/components/new-composer/history.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { ComposerHistoryItem, ComposerPart } from "./types"
|
||||
|
||||
export type HistoryEntry = {
|
||||
text: string
|
||||
parts: ComposerPart[]
|
||||
}
|
||||
|
||||
export type HistoryStored = string | ComposerHistoryItem
|
||||
|
||||
export type NavInput = {
|
||||
direction: "up" | "down"
|
||||
entries: HistoryStored[]
|
||||
historyIndex: number
|
||||
current: HistoryEntry
|
||||
saved: HistoryEntry | null
|
||||
}
|
||||
|
||||
export type NavResult =
|
||||
| {
|
||||
handled: false
|
||||
historyIndex: number
|
||||
saved: HistoryEntry | null
|
||||
}
|
||||
| {
|
||||
handled: true
|
||||
historyIndex: number
|
||||
saved: HistoryEntry | null
|
||||
entry: HistoryEntry
|
||||
cursor: "start" | "end"
|
||||
}
|
||||
|
||||
export const MAX_HISTORY = 50
|
||||
|
||||
export const EMPTY_ENTRY: HistoryEntry = {
|
||||
text: "",
|
||||
parts: [{ type: "text", content: "" }],
|
||||
}
|
||||
|
||||
const clonePart = (part: ComposerPart): ComposerPart => {
|
||||
if (part.type === "text") return { ...part }
|
||||
if (part.type === "file") return { ...part }
|
||||
return { ...part }
|
||||
}
|
||||
|
||||
export const cloneParts = (parts: ComposerPart[]) => parts.map(clonePart)
|
||||
|
||||
const cloneEntry = (entry: HistoryEntry): HistoryEntry => ({
|
||||
text: entry.text,
|
||||
parts: cloneParts(entry.parts),
|
||||
})
|
||||
|
||||
export const partText = (parts: ComposerPart[]) => parts.map((part) => part.content).join("")
|
||||
|
||||
export const normalizeEntry = (item: HistoryStored): HistoryEntry => {
|
||||
if (typeof item === "string") {
|
||||
return {
|
||||
text: item,
|
||||
parts: [{ type: "text", content: item }],
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.parts || item.parts.length === 0) {
|
||||
return {
|
||||
text: item.text,
|
||||
parts: [{ type: "text", content: item.text }],
|
||||
}
|
||||
}
|
||||
|
||||
const parts = cloneParts(item.parts)
|
||||
return {
|
||||
text: partText(parts),
|
||||
parts,
|
||||
}
|
||||
}
|
||||
|
||||
const samePart = (a: ComposerPart, b: ComposerPart) => {
|
||||
if (a.type !== b.type) return false
|
||||
if (a.type === "text" && b.type === "text") return a.content === b.content
|
||||
if (a.type === "file" && b.type === "file") return a.path === b.path && a.content === b.content
|
||||
if (a.type === "agent" && b.type === "agent") return a.name === b.name && a.content === b.content
|
||||
return false
|
||||
}
|
||||
|
||||
export const sameEntry = (a: HistoryEntry | undefined, b: HistoryEntry) => {
|
||||
if (!a) return false
|
||||
if (a.text !== b.text) return false
|
||||
if (a.parts.length !== b.parts.length) return false
|
||||
|
||||
for (let i = 0; i < a.parts.length; i++) {
|
||||
const x = a.parts[i]
|
||||
const y = b.parts[i]
|
||||
if (!x || !y || !samePart(x, y)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const prependEntry = (entries: HistoryEntry[], item: HistoryEntry, max = MAX_HISTORY) => {
|
||||
if (!item.text.trim()) return entries
|
||||
|
||||
const next = normalizeEntry(item)
|
||||
if (sameEntry(entries[0], next)) return entries
|
||||
return [next, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
export const canNavigateAtCursor = (direction: "up" | "down", text: string, cursor: number, inHistory = false) => {
|
||||
const pos = Math.max(0, Math.min(cursor, text.length))
|
||||
const start = pos === 0
|
||||
const end = pos === text.length
|
||||
if (inHistory) return start || end
|
||||
if (direction === "up") return start
|
||||
return end
|
||||
}
|
||||
|
||||
export const navigateEntry = (input: NavInput): NavResult => {
|
||||
if (input.direction === "up") {
|
||||
if (input.entries.length === 0) {
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
saved: input.saved,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === -1) {
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: 0,
|
||||
saved: cloneEntry(input.current),
|
||||
entry: normalizeEntry(input.entries[0] ?? EMPTY_ENTRY),
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex < input.entries.length - 1) {
|
||||
const next = input.historyIndex + 1
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
saved: input.saved,
|
||||
entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
|
||||
cursor: "start",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
saved: input.saved,
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex > 0) {
|
||||
const next = input.historyIndex - 1
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: next,
|
||||
saved: input.saved,
|
||||
entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY),
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
if (input.historyIndex === 0) {
|
||||
if (input.saved) {
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
saved: null,
|
||||
entry: cloneEntry(input.saved),
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: true,
|
||||
historyIndex: -1,
|
||||
saved: null,
|
||||
entry: normalizeEntry(EMPTY_ENTRY),
|
||||
cursor: "end",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
historyIndex: input.historyIndex,
|
||||
saved: input.saved,
|
||||
}
|
||||
}
|
||||
223
packages/ui/src/components/new-composer/input-layer.tsx
Normal file
223
packages/ui/src/components/new-composer/input-layer.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Button } from "../button"
|
||||
import { FileIcon } from "../file-icon"
|
||||
import { Icon } from "../icon"
|
||||
import { IconButton } from "../icon-button"
|
||||
import { Tooltip } from "../tooltip"
|
||||
import type { ContextItem, ImageAttachment } from "./types"
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
mode: "normal" | "shell"
|
||||
images: ImageAttachment[]
|
||||
contexts: ContextItem[]
|
||||
working: boolean
|
||||
accepting: boolean
|
||||
placeholder?: string
|
||||
onImageDrop: (id: string) => void
|
||||
contextActive?: (item: ContextItem) => boolean
|
||||
onContextOpen?: (item: ContextItem) => void
|
||||
onContextDrop: (item: ContextItem) => void
|
||||
onAccept: () => void
|
||||
onPick: () => void
|
||||
onSend: () => void
|
||||
onEditorRef: (el: HTMLDivElement) => void
|
||||
onInput: () => void
|
||||
onKeyDown: (event: KeyboardEvent) => void
|
||||
onPaste: (event: ClipboardEvent) => void
|
||||
onCompStart: () => void
|
||||
onCompEnd: () => void
|
||||
}
|
||||
|
||||
export function ComposerInputLayer(props: Props) {
|
||||
const i18n = useI18n()
|
||||
const isShell = () => props.mode === "shell"
|
||||
const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
|
||||
const buttonsOpacity = () => 1 - shell()
|
||||
const buttonsBlur = () => shell()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.images.length > 0}>
|
||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||
<For each={props.images}>
|
||||
{(item) => (
|
||||
<div class="relative group">
|
||||
<Show
|
||||
when={item.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
|
||||
<Icon name="folder" class="size-6 text-text-weak" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={item.dataUrl}
|
||||
alt={item.filename}
|
||||
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
|
||||
/>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onImageDrop(item.id)}
|
||||
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
|
||||
aria-label={i18n.t("ui.prompt.attachment.remove")}
|
||||
>
|
||||
<Icon name="close" class="size-3 text-text-weak" />
|
||||
</button>
|
||||
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
|
||||
<span class="text-10-regular text-white truncate block">{item.filename}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.contexts.length > 0}>
|
||||
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
|
||||
<For each={props.contexts}>
|
||||
{(item) => {
|
||||
const active = () => props.contextActive?.(item) ?? false
|
||||
const dir = () => getDirectory(item.path)
|
||||
const file = () => getFilename(item.path)
|
||||
const filename = () => getFilenameTruncated(item.path, 14)
|
||||
const label = () => {
|
||||
if (!item.selection) return null
|
||||
if (item.selection.startLine === item.selection.endLine) return `:${item.selection.startLine}`
|
||||
return `:${item.selection.startLine}-${item.selection.endLine}`
|
||||
}
|
||||
return (
|
||||
<Tooltip
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">{dir()}</span>
|
||||
<span class="shrink-0">{file()}</span>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={2000}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all shadow-xs-border": true,
|
||||
"hover:bg-surface-interactive-weak": !!item.commentID && !active(),
|
||||
"bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
|
||||
active(),
|
||||
"bg-background-stronger": !active(),
|
||||
}}
|
||||
onClick={() => props.onContextOpen?.(item)}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
|
||||
<div class="flex items-center text-11-regular min-w-0 font-medium">
|
||||
<span class="text-text-strong whitespace-nowrap">{filename()}</span>
|
||||
<Show when={label()}>
|
||||
<span class="text-text-weak whitespace-nowrap shrink-0">{label()}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onContextDrop(item)
|
||||
}}
|
||||
aria-label={i18n.t("ui.prompt.context.removeFile")}
|
||||
/>
|
||||
</div>
|
||||
<Show when={item.comment}>
|
||||
{(note) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{note()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="relative max-h-[200px] overflow-y-auto no-scrollbar" style={{ flex: 1 }}>
|
||||
<div
|
||||
data-component="prompt-input"
|
||||
ref={props.onEditorRef}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={props.placeholder ?? i18n.t("ui.prompt.placeholder.simple")}
|
||||
contentEditable={true}
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={props.onInput}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onPaste={props.onPaste}
|
||||
onCompositionStart={props.onCompStart}
|
||||
onCompositionEnd={props.onCompEnd}
|
||||
class="select-text w-full pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap"
|
||||
classList={{
|
||||
"font-mono!": props.mode === "shell",
|
||||
"[&_[data-type=file]]:text-syntax-property": true,
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
}}
|
||||
/>
|
||||
<Show when={!props.value.trim()}>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
classList={{ "font-mono!": props.mode === "shell" }}
|
||||
>
|
||||
{props.mode === "shell"
|
||||
? i18n.t("ui.prompt.placeholder.shell")
|
||||
: (props.placeholder ?? i18n.t("ui.prompt.placeholder.simple"))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between px-2 pb-2 shrink-0"
|
||||
style={{
|
||||
opacity: `${buttonsOpacity()}`,
|
||||
filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
|
||||
"pointer-events": shell() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={props.onAccept}
|
||||
class="size-6"
|
||||
classList={{
|
||||
"text-text-base": !props.accepting,
|
||||
"hover:bg-surface-success-base": props.accepting,
|
||||
}}
|
||||
style={{ display: "flex", "align-items": "center", "justify-content": "center" }}
|
||||
aria-label={i18n.t("ui.prompt.autoAccept")}
|
||||
aria-pressed={props.accepting}
|
||||
>
|
||||
<Icon name="chevron-double-right" size="small" classList={{ "text-icon-success-base": props.accepting }} />
|
||||
</Button>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="size-8 p-0"
|
||||
aria-label={i18n.t("ui.prompt.attachment.add")}
|
||||
onClick={props.onPick}
|
||||
>
|
||||
<Icon name="plus" class="size-4.5" />
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={props.working ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
onClick={props.onSend}
|
||||
disabled={!props.working && props.value.trim().length === 0 && props.images.length === 0}
|
||||
aria-label={props.working ? i18n.t("ui.prompt.stop") : i18n.t("ui.prompt.send")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
242
packages/ui/src/components/new-composer/model-picker.tsx
Normal file
242
packages/ui/src/components/new-composer/model-picker.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||
import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Button } from "../button"
|
||||
import { Icon } from "../icon"
|
||||
import { IconButton } from "../icon-button"
|
||||
import { ProviderIcon } from "../provider-icon"
|
||||
|
||||
interface Props {
|
||||
options: string[]
|
||||
current?: string
|
||||
onSelect?: (value: string) => void
|
||||
openTick?: number
|
||||
}
|
||||
|
||||
type Item = {
|
||||
value: string
|
||||
provider: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function parse(value: string, fallback: string): Item {
|
||||
const i = value.indexOf("/")
|
||||
if (i <= 0 || i >= value.length - 1) {
|
||||
return {
|
||||
value,
|
||||
provider: fallback,
|
||||
label: value,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
provider: value.slice(0, i),
|
||||
label: value.slice(i + 1),
|
||||
}
|
||||
}
|
||||
|
||||
export function ModelPicker(props: Props) {
|
||||
const i18n = useI18n()
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [full, setFull] = createSignal(false)
|
||||
const [query, setQuery] = createSignal("")
|
||||
let input: HTMLInputElement | undefined
|
||||
|
||||
const focus = () => {
|
||||
requestAnimationFrame(() => {
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.openTick,
|
||||
(next, prev) => {
|
||||
if (next === undefined) return
|
||||
if (next === prev) return
|
||||
setFull(true)
|
||||
setOpen(true)
|
||||
focus()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const list = createMemo(() => {
|
||||
const q = query().trim().toLowerCase()
|
||||
const fallback = i18n.t("ui.prompt.model.group.default")
|
||||
const items = props.options.map((item) => parse(item, fallback))
|
||||
if (!q) return items
|
||||
return items.filter((item) => {
|
||||
return item.label.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const groups = createMemo(() => {
|
||||
const map = new Map<string, Item[]>()
|
||||
for (const item of list()) {
|
||||
const group = map.get(item.provider)
|
||||
if (group) {
|
||||
group.push(item)
|
||||
continue
|
||||
}
|
||||
map.set(item.provider, [item])
|
||||
}
|
||||
return Array.from(map.entries()).map(([provider, items]) => ({ provider, items }))
|
||||
})
|
||||
|
||||
const label = createMemo(() => {
|
||||
const fallback = i18n.t("ui.prompt.model.group.default")
|
||||
const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
|
||||
if (!hit) return props.current ?? i18n.t("ui.prompt.control.model")
|
||||
return hit.label
|
||||
})
|
||||
|
||||
const provider = createMemo(() => {
|
||||
const fallback = i18n.t("ui.prompt.model.group.default")
|
||||
const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current)
|
||||
if (!hit) return undefined
|
||||
if (hit.provider === fallback) return undefined
|
||||
return hit.provider.toLowerCase()
|
||||
})
|
||||
|
||||
return (
|
||||
<Kobalte
|
||||
open={open()}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next)
|
||||
if (!next) {
|
||||
setQuery("")
|
||||
setFull(false)
|
||||
}
|
||||
if (next) focus()
|
||||
}}
|
||||
modal={false}
|
||||
placement="top-start"
|
||||
gutter={4}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||
style={{ height: "28px" }}
|
||||
onPointerDown={() => setFull(false)}
|
||||
>
|
||||
<Show when={provider()} fallback={<Icon name="brain" size="small" class="shrink-0" />}>
|
||||
{(id) => (
|
||||
<ProviderIcon
|
||||
id={id()}
|
||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<span class="truncate">{label()}</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
</Kobalte.Trigger>
|
||||
|
||||
<Kobalte.Portal>
|
||||
<Show when={open() && full()}>
|
||||
<div class="fixed inset-0 bg-black/35 z-40" onPointerDown={() => setOpen(false)} />
|
||||
</Show>
|
||||
<Kobalte.Content
|
||||
class="flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||
classList={{
|
||||
"w-72 h-80 p-2": !full(),
|
||||
"w-[min(92vw,1130px)] h-[min(86vh,860px)] p-5": full(),
|
||||
}}
|
||||
style={
|
||||
full()
|
||||
? {
|
||||
position: "fixed",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onEscapeKeyDown={(event) => {
|
||||
setOpen(false)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Show when={full()}>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="text-35-medium text-text-strong">{i18n.t("ui.prompt.model.select")}</div>
|
||||
<Button icon="plus-small" variant="secondary" size="normal" class="text-32-medium h-10 px-4">
|
||||
{i18n.t("ui.prompt.model.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex items-center gap-2 px-2 py-1.5 rounded-md bg-surface-base mb-2">
|
||||
<Icon name="magnifying-glass" size="small" class="text-icon-weak shrink-0" />
|
||||
<input
|
||||
ref={input}
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
placeholder={i18n.t("ui.prompt.model.search")}
|
||||
class="min-w-0 flex-1 bg-transparent border-none outline-none text-14-regular text-text-strong placeholder:text-text-weak"
|
||||
/>
|
||||
<Show when={!full()}>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="normal"
|
||||
class="size-6"
|
||||
aria-label={i18n.t("ui.prompt.model.connect")}
|
||||
/>
|
||||
<IconButton
|
||||
icon="sliders"
|
||||
variant="ghost"
|
||||
iconSize="normal"
|
||||
class="size-6"
|
||||
aria-label={i18n.t("ui.prompt.model.manage")}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-auto no-scrollbar px-1 pt-1">
|
||||
<Show
|
||||
when={groups().length > 0}
|
||||
fallback={<div class="px-2 py-1 text-13-regular text-text-weak">{i18n.t("ui.prompt.model.none")}</div>}
|
||||
>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<div class="pb-2.5">
|
||||
<div class="px-2 py-1.5 text-35-medium text-text-weak">{group.provider}</div>
|
||||
<For each={group.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
class="w-full h-10 px-2 rounded-md flex items-center justify-between text-left"
|
||||
classList={{ "bg-surface-raised-base-hover": props.current === item.value }}
|
||||
onClick={() => {
|
||||
props.onSelect?.(item.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<span class="text-32-medium text-text-strong truncate">{item.label}</span>
|
||||
<Show when={props.current === item.value}>
|
||||
<Icon name="check-small" size="small" class="text-icon-strong-base shrink-0" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={full()}>
|
||||
<div class="pt-2 px-2 text-32-medium text-text-weak">{i18n.t("ui.prompt.model.manage")}</div>
|
||||
</Show>
|
||||
</Kobalte.Content>
|
||||
</Kobalte.Portal>
|
||||
</Kobalte>
|
||||
)
|
||||
}
|
||||
41
packages/ui/src/components/new-composer/permission-body.tsx
Normal file
41
packages/ui/src/components/new-composer/permission-body.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Icon } from "../icon"
|
||||
|
||||
interface Props {
|
||||
tool?: string
|
||||
description?: string
|
||||
patterns?: string[]
|
||||
}
|
||||
|
||||
export function PermissionBody(props: Props) {
|
||||
const i18n = useI18n()
|
||||
const hint = () => props.description ?? props.tool ?? ""
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-slot="permission-row" data-variant="header">
|
||||
<span data-slot="permission-icon">
|
||||
<Icon name="warning" size="normal" />
|
||||
</span>
|
||||
<div data-slot="permission-header-title">{i18n.t("ui.permission.title")}</div>
|
||||
</div>
|
||||
<Show when={hint()}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-hint">{hint()}</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={(props.patterns?.length ?? 0) > 0}>
|
||||
<div data-slot="permission-row">
|
||||
<span data-slot="permission-spacer" aria-hidden="true" />
|
||||
<div data-slot="permission-patterns">
|
||||
<For each={props.patterns}>
|
||||
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
111
packages/ui/src/components/new-composer/popover.tsx
Normal file
111
packages/ui/src/components/new-composer/popover.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { For, Match, Show, Switch } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Icon } from "../icon"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import type { AtOption, SlashCommand } from "./types"
|
||||
|
||||
interface Props {
|
||||
kind: "at" | "slash" | null
|
||||
bottom: number
|
||||
atFlat: AtOption[]
|
||||
atActive: string | null
|
||||
atKey: (item: AtOption) => string
|
||||
onAtHover: (key: string) => void
|
||||
onAtPick: (item: AtOption) => void
|
||||
slashFlat: SlashCommand[]
|
||||
slashActive: string | null
|
||||
onSlashHover: (key: string) => void
|
||||
onSlashPick: (item: SlashCommand) => void
|
||||
}
|
||||
|
||||
export function ComposerPopover(props: Props) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<Show when={props.kind}>
|
||||
<div
|
||||
class="absolute left-0 right-0 max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||
style={{
|
||||
bottom: `${props.bottom}px`,
|
||||
"z-index": 100,
|
||||
}}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.kind === "at"}>
|
||||
<Show
|
||||
when={props.atFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.list.empty")}</div>}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => {
|
||||
const key = props.atKey(item)
|
||||
const isDir = item.type === "file" && item.path.endsWith("/")
|
||||
const dir = item.type === "file" ? (isDir ? item.path : getDirectory(item.path)) : ""
|
||||
const file = item.type === "file" && !isDir ? getFilename(item.path) : ""
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
onClick={() => props.onAtPick(item)}
|
||||
onMouseEnter={() => props.onAtHover(key)}
|
||||
>
|
||||
<Icon
|
||||
name={item.type === "agent" ? "brain" : "file-tree"}
|
||||
size="small"
|
||||
class={item.type === "agent" ? "text-icon-info-active shrink-0" : "text-icon-base shrink-0"}
|
||||
/>
|
||||
{item.type === "agent" ? (
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||
) : (
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{dir}</span>
|
||||
<Show when={!isDir}>
|
||||
<span class="text-text-strong whitespace-nowrap">{file}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.kind === "slash"}>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{i18n.t("ui.prompt.list.noCommands")}</div>}
|
||||
>
|
||||
<For each={props.slashFlat}>
|
||||
{(cmd) => (
|
||||
<button
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashPick(cmd)}
|
||||
onMouseEnter={() => props.onSlashHover(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{cmd.source === "skill" ? "skill" : cmd.source === "mcp" ? "mcp" : "custom"}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle">{cmd.keybind}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
218
packages/ui/src/components/new-composer/question-body.tsx
Normal file
218
packages/ui/src/components/new-composer/question-body.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { For, Index, Show } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Icon } from "../icon"
|
||||
|
||||
interface Option {
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface Base {
|
||||
text?: string
|
||||
options?: Option[]
|
||||
index?: number
|
||||
total?: number
|
||||
multi?: boolean
|
||||
answered?: boolean[]
|
||||
}
|
||||
|
||||
interface BodyProps extends Base {
|
||||
selected: string[]
|
||||
custom: string
|
||||
customOn: boolean
|
||||
busy?: boolean
|
||||
onToggle: (label: string) => void
|
||||
onCustomOn: (value: boolean) => void
|
||||
onCustom: (value: string) => void
|
||||
onJump?: (index: number) => void
|
||||
}
|
||||
|
||||
function Header(props: Base & { onJump?: (index: number) => void; click?: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const tab = () => props.index ?? 0
|
||||
const done = (i: number) => {
|
||||
if (props.answered) return props.answered[i] === true
|
||||
return i < tab()
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={(props.total ?? 0) > 0}>
|
||||
<div data-slot="question-header">
|
||||
<div data-slot="question-header-title">
|
||||
{i18n.t("ui.question.progress", { index: tab() + 1, total: props.total ?? 0 })}
|
||||
</div>
|
||||
<div data-slot="question-progress">
|
||||
<Index each={Array.from({ length: props.total ?? 0 })}>
|
||||
{(_, i) => (
|
||||
<Show
|
||||
when={props.click}
|
||||
fallback={
|
||||
<span data-slot="question-progress-segment" data-active={i === tab()} data-answered={done(i)} />
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="question-progress-segment"
|
||||
data-active={i === tab()}
|
||||
data-answered={done(i)}
|
||||
onClick={() => props.onJump?.(i)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuestionBody(props: BodyProps) {
|
||||
const i18n = useI18n()
|
||||
const multi = () => props.multi ?? false
|
||||
const selected = (label: string) => props.selected.includes(label)
|
||||
const customPicked = () => {
|
||||
if (props.customOn) return true
|
||||
if (!multi()) return false
|
||||
const text = props.custom.trim()
|
||||
if (!text) return false
|
||||
return props.selected.some((item) => item.trim() === text)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header index={props.index} total={props.total} answered={props.answered} onJump={props.onJump} click />
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">{props.text}</div>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{i18n.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{i18n.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
<div data-slot="question-options" style={{ "padding-bottom": "8px" }}>
|
||||
<For each={props.options}>
|
||||
{(opt) => {
|
||||
const picked = () => selected(opt.label)
|
||||
return (
|
||||
<button
|
||||
data-slot="question-option"
|
||||
data-picked={picked()}
|
||||
onClick={() => props.onToggle(opt.label)}
|
||||
disabled={props.busy}
|
||||
>
|
||||
<span data-slot="question-option-check" aria-hidden="true">
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={picked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={customPicked()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target instanceof HTMLTextAreaElement) return
|
||||
const input = event.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(event) => event.preventDefault()}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-check"
|
||||
aria-hidden="true"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const next = multi() ? !props.customOn : true
|
||||
props.onCustomOn(next)
|
||||
|
||||
const form = event.currentTarget.closest("form")
|
||||
const input = form?.querySelector('[data-slot="question-custom-input"]')
|
||||
if (!(input instanceof HTMLTextAreaElement)) return
|
||||
if (!next) {
|
||||
input.blur()
|
||||
return
|
||||
}
|
||||
input.focus()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
data-slot="question-option-box"
|
||||
data-type={multi() ? "checkbox" : "radio"}
|
||||
data-picked={customPicked()}
|
||||
>
|
||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</span>
|
||||
</span>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<textarea
|
||||
data-slot="question-custom-input"
|
||||
placeholder={i18n.t("ui.question.custom.placeholder")}
|
||||
value={props.custom}
|
||||
disabled={props.busy}
|
||||
onFocus={() => props.onCustomOn(true)}
|
||||
onInput={(event) => {
|
||||
const value = event.currentTarget.value
|
||||
props.onCustom(value)
|
||||
props.onCustomOn(value.trim().length > 0)
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function QuestionSizer(props: Base) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<>
|
||||
<Header index={props.index} total={props.total} answered={props.answered} />
|
||||
<div data-slot="question-content">
|
||||
<div data-slot="question-text">{props.text}</div>
|
||||
<div data-slot="question-hint">
|
||||
{props.multi ? i18n.t("ui.question.multiHint") : i18n.t("ui.question.singleHint")}
|
||||
</div>
|
||||
<div data-slot="question-options" style={{ overflow: "hidden", "padding-bottom": "8px" }}>
|
||||
<For each={props.options}>
|
||||
{(opt) => (
|
||||
<div data-slot="question-option" style={{ "pointer-events": "none" }}>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{opt.label}</span>
|
||||
<Show when={opt.description}>
|
||||
<span data-slot="option-description">{opt.description}</span>
|
||||
</Show>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div data-slot="question-option" style={{ "pointer-events": "none" }}>
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{i18n.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
||||
<span data-slot="option-description">{i18n.t("ui.question.custom.placeholder")}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
302
packages/ui/src/components/new-composer/todo-tray.tsx
Normal file
302
packages/ui/src/components/new-composer/todo-tray.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { AnimatedNumber } from "../animated-number"
|
||||
import { Checkbox } from "../checkbox"
|
||||
import { IconButton } from "../icon-button"
|
||||
import { TextReveal } from "../text-reveal"
|
||||
import { TextStrikethrough } from "../text-strikethrough"
|
||||
import type { TodoItem } from "./types"
|
||||
|
||||
const SUBTITLE = { duration: 600, travel: 25, edge: 17 }
|
||||
const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 }
|
||||
|
||||
interface Props {
|
||||
todos: TodoItem[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
progress: number
|
||||
collapse: number
|
||||
visibleHeight: number
|
||||
hide: number
|
||||
shut: number
|
||||
bottom: number
|
||||
onContentRef: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
function dot(status: TodoItem["status"]) {
|
||||
if (status !== "in_progress") return undefined
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width="12"
|
||||
height="12"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="block"
|
||||
>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="6"
|
||||
r="3"
|
||||
style={{
|
||||
animation: "var(--animate-pulse-scale)",
|
||||
"transform-origin": "center",
|
||||
"transform-box": "fill-box",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComposerTodoTray(props: Props) {
|
||||
const i18n = useI18n()
|
||||
const [stuck, setStuck] = createSignal(false)
|
||||
const [scrolling, setScrolling] = createSignal(false)
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let timer: number | undefined
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((item) => item.status === "completed").length)
|
||||
const activeIndex = createMemo(() => props.todos.findIndex((item) => item.status === "in_progress"))
|
||||
const active = createMemo(
|
||||
() =>
|
||||
props.todos.find((item) => item.status === "in_progress") ??
|
||||
props.todos.find((item) => item.status === "pending") ??
|
||||
props.todos.filter((item) => item.status === "completed").at(-1) ??
|
||||
props.todos[0],
|
||||
)
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
|
||||
const ensure = () => {
|
||||
if (props.collapsed) return
|
||||
if (scrolling()) return
|
||||
if (!scroll || scroll.offsetParent === null) return
|
||||
|
||||
const el = scroll.querySelector("[data-in-progress]")
|
||||
if (!(el instanceof HTMLElement)) return
|
||||
|
||||
const topFade = 16
|
||||
const bottomFade = 44
|
||||
const box = scroll.getBoundingClientRect()
|
||||
const rect = el.getBoundingClientRect()
|
||||
const top = rect.top - box.top + scroll.scrollTop
|
||||
const bottom = rect.bottom - box.top + scroll.scrollTop
|
||||
const viewTop = scroll.scrollTop + topFade
|
||||
const viewBottom = scroll.scrollTop + scroll.clientHeight - bottomFade
|
||||
|
||||
if (top < viewTop) {
|
||||
scroll.scrollTop = Math.max(0, top - topFade)
|
||||
} else if (bottom > viewBottom) {
|
||||
scroll.scrollTop = bottom - (scroll.clientHeight - bottomFade)
|
||||
}
|
||||
|
||||
setStuck(scroll.scrollTop > 0)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on([() => props.collapsed, activeIndex], () => {
|
||||
if (props.collapsed || activeIndex() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (!timer) return
|
||||
window.clearTimeout(timer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-dock-surface="tray"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: `${props.bottom}px`,
|
||||
left: 0,
|
||||
right: 0,
|
||||
"z-index": 5,
|
||||
"max-height": `${props.visibleHeight}px`,
|
||||
"overflow-x": "visible",
|
||||
"overflow-y": "hidden",
|
||||
"border-color": "light-dark(#dcd9d9, #3e3a3a)",
|
||||
"pointer-events": props.progress < 0.98 ? "none" : "auto",
|
||||
transform: `translateY(${(1 - props.progress) * 12}px)`,
|
||||
}}
|
||||
>
|
||||
<div ref={props.onContentRef}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
padding: "8px 8px 8px 12px",
|
||||
height: "40px",
|
||||
cursor: "pointer",
|
||||
overflow: "visible",
|
||||
}}
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
"font-size": "14px",
|
||||
color: "var(--text-strong)",
|
||||
"white-space": "nowrap",
|
||||
display: "inline-flex",
|
||||
"align-items": "baseline",
|
||||
"flex-shrink": 0,
|
||||
overflow: "visible",
|
||||
cursor: "default",
|
||||
"--tool-motion-odometer-ms": `${COUNT.duration}ms`,
|
||||
"--tool-motion-mask": `${COUNT.mask}%`,
|
||||
"--tool-motion-mask-height": `${COUNT.maskHeight}px`,
|
||||
"--tool-motion-spring-ms": `${COUNT.widthDuration}ms`,
|
||||
opacity: `${1 - props.shut}`,
|
||||
filter: props.shut > 0.01 ? `blur(${props.shut * 2}px)` : "none",
|
||||
}}
|
||||
>
|
||||
<AnimatedNumber value={done()} />
|
||||
<span style={{ margin: "0 4px" }}>{i18n.t("ui.todo.word.of")}</span>
|
||||
<AnimatedNumber value={total()} />
|
||||
<span> {i18n.t("ui.todo.word.completed")}</span>
|
||||
</span>
|
||||
|
||||
<div
|
||||
style={{
|
||||
"margin-left": "4px",
|
||||
"min-width": 0,
|
||||
overflow: "hidden",
|
||||
flex: "1 1 auto",
|
||||
"max-width": "100%",
|
||||
}}
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={props.collapsed ? preview() : undefined}
|
||||
duration={SUBTITLE.duration}
|
||||
travel={SUBTITLE.travel}
|
||||
edge={SUBTITLE.edge}
|
||||
spring="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
springSoft="cubic-bezier(0.34, 1, 0.64, 1)"
|
||||
growOnly
|
||||
truncate
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ "margin-left": "auto" }}>
|
||||
<IconButton
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
style={{ transform: `rotate(${props.collapse * 180}deg)` }}
|
||||
onMouseDown={(event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={props.collapsed ? i18n.t("ui.todo.expand") : i18n.t("ui.todo.collapse")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
opacity: `${1 - props.hide}`,
|
||||
filter: props.hide > 0.01 ? `blur(${props.hide * 2}px)` : "none",
|
||||
visibility: props.hide > 0.98 ? "hidden" : "visible",
|
||||
"pointer-events": props.hide > 0.1 ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div
|
||||
ref={(el) => {
|
||||
scroll = el
|
||||
}}
|
||||
class="no-scrollbar"
|
||||
style={{
|
||||
padding: "0 12px 44px",
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "6px",
|
||||
"max-height": "200px",
|
||||
"overflow-y": "auto",
|
||||
"overflow-anchor": "none",
|
||||
}}
|
||||
onScroll={(event) => {
|
||||
setStuck(event.currentTarget.scrollTop > 0)
|
||||
setScrolling(true)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = window.setTimeout(() => {
|
||||
setScrolling(false)
|
||||
if (activeIndex() < 0) return
|
||||
requestAnimationFrame(ensure)
|
||||
}, 250)
|
||||
}}
|
||||
>
|
||||
<Index each={props.todos}>
|
||||
{(todo) => (
|
||||
<Checkbox
|
||||
readOnly
|
||||
checked={todo().status === "completed"}
|
||||
indeterminate={todo().status === "in_progress"}
|
||||
data-in-progress={todo().status === "in_progress" ? "" : undefined}
|
||||
data-state={todo().status}
|
||||
icon={dot(todo().status)}
|
||||
style={{
|
||||
"--checkbox-align": "flex-start",
|
||||
"--checkbox-offset": "1px",
|
||||
transition: "opacity 220ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
opacity: todo().status === "pending" ? "0.5" : "1",
|
||||
}}
|
||||
>
|
||||
<TextStrikethrough
|
||||
active={todo().status === "completed" || todo().status === "cancelled"}
|
||||
text={todo().content}
|
||||
class="text-14-regular min-w-0 break-words"
|
||||
style={{
|
||||
"line-height": "var(--line-height-normal)",
|
||||
transition: "color 220ms cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
color:
|
||||
todo().status === "completed" || todo().status === "cancelled"
|
||||
? "var(--text-weak)"
|
||||
: "var(--text-strong)",
|
||||
}}
|
||||
/>
|
||||
</Checkbox>
|
||||
)}
|
||||
</Index>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "16px",
|
||||
background: "linear-gradient(to bottom, var(--background-base), transparent)",
|
||||
"pointer-events": "none",
|
||||
opacity: stuck() ? 1 : 0,
|
||||
transition: "opacity 150ms ease",
|
||||
"z-index": 2,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "56px",
|
||||
background: "linear-gradient(to bottom, transparent, var(--background-base) 85%)",
|
||||
"pointer-events": "none",
|
||||
"z-index": 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
410
packages/ui/src/components/new-composer/tray.tsx
Normal file
410
packages/ui/src/components/new-composer/tray.tsx
Normal file
@@ -0,0 +1,410 @@
|
||||
import { Show, type JSXElement } from "solid-js"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useI18n } from "../../context/i18n"
|
||||
import { Button } from "../button"
|
||||
import { Icon } from "../icon"
|
||||
import { RadioGroup } from "../radio-group"
|
||||
import { Select } from "../select"
|
||||
import { Tooltip, TooltipKeybind } from "../tooltip"
|
||||
import { ModelPicker } from "./model-picker"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const pretty = (config: string | undefined) => {
|
||||
if (!config) return ""
|
||||
const parts = config.split(",")[0]?.trim().toLowerCase().split("+")
|
||||
if (!parts) return ""
|
||||
|
||||
const kb = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "ctrl" || part === "control") {
|
||||
kb.ctrl = true
|
||||
continue
|
||||
}
|
||||
if (part === "meta" || part === "cmd" || part === "command") {
|
||||
kb.meta = true
|
||||
continue
|
||||
}
|
||||
if (part === "mod") {
|
||||
if (IS_MAC) kb.meta = true
|
||||
else kb.ctrl = true
|
||||
continue
|
||||
}
|
||||
if (part === "alt" || part === "option") {
|
||||
kb.alt = true
|
||||
continue
|
||||
}
|
||||
if (part === "shift") {
|
||||
kb.shift = true
|
||||
continue
|
||||
}
|
||||
kb.key = part
|
||||
}
|
||||
|
||||
const out: string[] = []
|
||||
if (kb.ctrl) out.push(IS_MAC ? "⌃" : "Ctrl")
|
||||
if (kb.alt) out.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) out.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) out.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
const map: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
space: "Space",
|
||||
}
|
||||
|
||||
if (kb.key) {
|
||||
const hit = map[kb.key]
|
||||
if (hit) out.push(hit)
|
||||
else if (kb.key.length === 1) out.push(kb.key.toUpperCase())
|
||||
else out.push(kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
|
||||
}
|
||||
|
||||
if (IS_MAC) return out.join("")
|
||||
return out.join("+")
|
||||
}
|
||||
|
||||
interface Props {
|
||||
inputOpacity: number
|
||||
inputBlur: number
|
||||
questionOpacity: number
|
||||
questionBlur: number
|
||||
morph: number
|
||||
isQuestion: boolean
|
||||
isPermission: boolean
|
||||
showQuestion: boolean
|
||||
showPermission: boolean
|
||||
agentName?: string
|
||||
modelName?: string
|
||||
variant?: string
|
||||
agentOptions?: string[]
|
||||
modelOptions?: string[]
|
||||
variantOptions?: string[]
|
||||
agentCurrent?: string
|
||||
modelCurrent?: string
|
||||
variantCurrent?: string
|
||||
onAgentSelect?: (value: string) => void
|
||||
onModelSelect?: (value: string) => void
|
||||
onVariantSelect?: (value: string) => void
|
||||
agentKeybind?: string
|
||||
modelKeybind?: string
|
||||
variantKeybind?: string
|
||||
modelOpenTick?: number
|
||||
agentControl?: JSXElement
|
||||
modelControl?: JSXElement
|
||||
variantControl?: JSXElement
|
||||
shell: "shell" | "normal"
|
||||
onShell: (mode: "shell" | "normal") => void
|
||||
questionIndex?: number
|
||||
questionTotal?: number
|
||||
onQuestionDismiss?: () => void
|
||||
onQuestionBack?: () => void
|
||||
onQuestionNext?: () => void
|
||||
questionBusy?: boolean
|
||||
onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
|
||||
permissionBusy?: boolean
|
||||
onRef: (el: HTMLDivElement) => void
|
||||
}
|
||||
|
||||
export function ComposerTray(props: Props) {
|
||||
const i18n = useI18n()
|
||||
const isShell = () => props.shell === "shell"
|
||||
const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 })
|
||||
const buttonsOpacity = () => 1 - shell()
|
||||
const buttonsBlur = () => shell()
|
||||
const labelOpacity = () => shell()
|
||||
const labelBlur = () => 1 - shell()
|
||||
|
||||
const hint = (title: string, keybind: string | undefined, node: JSXElement) => {
|
||||
const kb = pretty(keybind)
|
||||
if (kb) {
|
||||
return (
|
||||
<TooltipKeybind placement="top" gutter={4} title={title} keybind={kb}>
|
||||
{node}
|
||||
</TooltipKeybind>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip placement="top" value={<span>{title}</span>}>
|
||||
{node}
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.onRef}
|
||||
data-dock-surface="tray"
|
||||
data-dock-attach="top"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
"z-index": 0,
|
||||
height: "58px",
|
||||
"border-color": "light-dark(#dcd9d9, #3e3a3a)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
padding: "22px 7px 8px",
|
||||
gap: "8px",
|
||||
opacity: props.inputOpacity,
|
||||
filter: `blur(${props.inputBlur}px)`,
|
||||
"pointer-events": props.morph > 0.5 ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "6px",
|
||||
"min-width": 0,
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Shell label — fades in when shell mode active */}
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 flex items-center px-1"
|
||||
style={{
|
||||
opacity: `${labelOpacity()}`,
|
||||
filter: labelBlur() > 0.01 ? `blur(${labelBlur()}px)` : "none",
|
||||
"pointer-events": shell() > 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-13-medium text-text-strong">{i18n.t("ui.prompt.control.shell")}</span>
|
||||
</div>
|
||||
{/* Agent / model / variant buttons — fade out in shell mode */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "6px",
|
||||
opacity: `${buttonsOpacity()}`,
|
||||
filter: buttonsBlur() > 0.01 ? `blur(${buttonsBlur()}px)` : "none",
|
||||
"pointer-events": shell() < 0.5 ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.agentControl}
|
||||
fallback={
|
||||
<Show
|
||||
when={(props.agentOptions?.length ?? 0) > 0}
|
||||
fallback={hint(
|
||||
i18n.t("ui.prompt.control.agent"),
|
||||
props.agentKeybind,
|
||||
<Button variant="ghost" size="normal">
|
||||
{props.agentName ?? "Ask"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>,
|
||||
)}
|
||||
>
|
||||
{hint(
|
||||
i18n.t("ui.prompt.control.agent"),
|
||||
props.agentKeybind,
|
||||
<Select
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
options={props.agentOptions ?? []}
|
||||
current={props.agentCurrent}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
props.onAgentSelect?.(value)
|
||||
}}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
/>,
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{props.agentControl}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.modelControl}
|
||||
fallback={
|
||||
<Show
|
||||
when={(props.modelOptions?.length ?? 0) > 0}
|
||||
fallback={hint(
|
||||
i18n.t("ui.prompt.control.model"),
|
||||
props.modelKeybind,
|
||||
<Button variant="ghost" size="normal">
|
||||
<Icon name="brain" size="small" />
|
||||
{props.modelName ?? "GPT-4"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>,
|
||||
)}
|
||||
>
|
||||
{hint(
|
||||
i18n.t("ui.prompt.control.model"),
|
||||
props.modelKeybind,
|
||||
<ModelPicker
|
||||
options={props.modelOptions ?? []}
|
||||
current={props.modelCurrent ?? props.modelName}
|
||||
onSelect={props.onModelSelect}
|
||||
openTick={props.modelOpenTick}
|
||||
/>,
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{props.modelControl}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.variantControl}
|
||||
fallback={
|
||||
<Show
|
||||
when={(props.variantOptions?.length ?? 0) > 0}
|
||||
fallback={hint(
|
||||
i18n.t("ui.prompt.control.variant"),
|
||||
props.variantKeybind,
|
||||
<Button variant="ghost" size="normal">
|
||||
{props.variant ?? "Default"}
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Button>,
|
||||
)}
|
||||
>
|
||||
{hint(
|
||||
i18n.t("ui.prompt.control.variant"),
|
||||
props.variantKeybind,
|
||||
<Select
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
options={props.variantOptions ?? []}
|
||||
current={props.variantCurrent}
|
||||
onSelect={(value) => {
|
||||
if (!value) return
|
||||
props.onVariantSelect?.(value)
|
||||
}}
|
||||
class="capitalize max-w-[160px]"
|
||||
valueClass="truncate text-13-regular"
|
||||
triggerStyle={{ height: "28px" }}
|
||||
/>,
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{props.variantControl}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<RadioGroup
|
||||
options={["shell", "normal"] as const}
|
||||
current={props.shell}
|
||||
onSelect={(mode) => props.onShell(mode ?? "normal")}
|
||||
value={(mode) => mode}
|
||||
label={(mode) => <Icon name={mode === "shell" ? "console" : "prompt"} class="size-[18px]" />}
|
||||
fill
|
||||
pad="none"
|
||||
class="w-[68px] shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={props.showQuestion}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
padding: "22px 8px 8px",
|
||||
opacity: props.questionOpacity,
|
||||
filter: `blur(${props.questionBlur}px)`,
|
||||
"pointer-events": props.morph < 0.5 ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<Button variant="ghost" size="normal" onClick={() => props.onQuestionDismiss?.()}>
|
||||
{i18n.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<Show when={(props.questionIndex ?? 0) > 0}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onQuestionBack?.()}
|
||||
disabled={props.questionBusy}
|
||||
>
|
||||
{i18n.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button
|
||||
variant={(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 ? "primary" : "secondary"}
|
||||
size="normal"
|
||||
onClick={() => props.onQuestionNext?.()}
|
||||
disabled={props.questionBusy}
|
||||
>
|
||||
{(props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1
|
||||
? i18n.t("ui.common.submit")
|
||||
: i18n.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showPermission}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
"justify-content": "space-between",
|
||||
padding: "22px 8px 8px",
|
||||
opacity: props.questionOpacity,
|
||||
filter: `blur(${props.questionBlur}px)`,
|
||||
"pointer-events": props.morph < 0.5 ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<div />
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "8px" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
onClick={() => props.onPermissionDecide?.("reject")}
|
||||
disabled={props.permissionBusy}
|
||||
>
|
||||
{i18n.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onPermissionDecide?.("always")}
|
||||
disabled={props.permissionBusy}
|
||||
>
|
||||
{i18n.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="normal"
|
||||
onClick={() => props.onPermissionDecide?.("once")}
|
||||
disabled={props.permissionBusy}
|
||||
>
|
||||
{i18n.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
157
packages/ui/src/components/new-composer/types.ts
Normal file
157
packages/ui/src/components/new-composer/types.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { JSXElement } from "solid-js"
|
||||
|
||||
export interface TodoItem {
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
}
|
||||
|
||||
export type ComposerMode = "normal" | "shell"
|
||||
export type ComposerSource = "enter" | "button"
|
||||
|
||||
export type AtOption =
|
||||
| { type: "agent"; name: string; display: string }
|
||||
| { type: "file"; path: string; display: string; recent?: boolean }
|
||||
|
||||
export interface SlashCommand {
|
||||
id: string
|
||||
trigger: string
|
||||
title: string
|
||||
description?: string
|
||||
keybind?: string
|
||||
type: "builtin" | "custom"
|
||||
source?: "command" | "mcp" | "skill"
|
||||
}
|
||||
|
||||
export interface ImageAttachment {
|
||||
id: string
|
||||
filename: string
|
||||
mime: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
export type ComposerPart =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "file"; path: string; content: string }
|
||||
| { type: "agent"; name: string; content: string }
|
||||
|
||||
export type ComposerHistoryItem = {
|
||||
text: string
|
||||
parts?: ComposerPart[]
|
||||
}
|
||||
|
||||
export interface ContextItem {
|
||||
id?: string
|
||||
path: string
|
||||
selection?: { startLine: number; endLine: number }
|
||||
comment?: string
|
||||
commentID?: string
|
||||
commentOrigin?: "review" | "file"
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export interface ComposerSubmit {
|
||||
source: ComposerSource
|
||||
mode: ComposerMode
|
||||
text: string
|
||||
parts: ComposerPart[]
|
||||
files: ImageAttachment[]
|
||||
context: ContextItem[]
|
||||
}
|
||||
|
||||
export interface ComposerRuntime {
|
||||
submit?: (input: ComposerSubmit) => void | Promise<void>
|
||||
abort?: () => void | Promise<void>
|
||||
toggleAccept?: () => void | Promise<void>
|
||||
openModel?: () => void | Promise<void>
|
||||
cycleAgent?: () => void | Promise<void>
|
||||
cycleVariant?: () => void | Promise<void>
|
||||
runSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
|
||||
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
|
||||
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
|
||||
decidePermission?: (response: "once" | "always" | "reject") => void | Promise<void>
|
||||
submitQuestion?: (answers: string[][]) => void | Promise<void>
|
||||
rejectQuestion?: () => void | Promise<void>
|
||||
dialogActive?: () => boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
fileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
|
||||
searchAt?: (filter: string) => AtOption[] | Promise<AtOption[]>
|
||||
searchSlash?: (filter: string) => SlashCommand[] | Promise<SlashCommand[]>
|
||||
openContext?: (item: ContextItem) => void
|
||||
removeContext?: (item: ContextItem) => void
|
||||
}
|
||||
|
||||
export interface NewComposerProps {
|
||||
mode?: "input" | "question" | "permission"
|
||||
questionText?: string
|
||||
questionOptions?: Array<{ label: string; description?: string }>
|
||||
questionMultiple?: boolean
|
||||
questionAnswered?: boolean[]
|
||||
questionAnswers?: string[][]
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
onSubmit?: (input: ComposerSubmit) => void | Promise<void>
|
||||
onAbort?: () => void | Promise<void>
|
||||
onAcceptToggle?: () => void | Promise<void>
|
||||
dialogActive?: boolean
|
||||
readClipboardImage?: () => Promise<File | null>
|
||||
onFileRejected?: (input: { source: "paste" | "drop" | "pick"; file?: File }) => void
|
||||
onSlashCommand?: (cmd: SlashCommand) => boolean | Promise<boolean>
|
||||
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
|
||||
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
|
||||
agentName?: string
|
||||
modelName?: string
|
||||
variant?: string
|
||||
agentOptions?: string[]
|
||||
modelOptions?: string[]
|
||||
variantOptions?: string[]
|
||||
agentCurrent?: string
|
||||
modelCurrent?: string
|
||||
variantCurrent?: string
|
||||
onAgentSelect?: (value: string) => void
|
||||
onModelSelect?: (value: string) => void
|
||||
onVariantSelect?: (value: string) => void
|
||||
onModelOpen?: () => void | Promise<void>
|
||||
onAgentCycle?: () => void | Promise<void>
|
||||
onVariantCycle?: () => void | Promise<void>
|
||||
agentKeybind?: string
|
||||
modelKeybind?: string
|
||||
variantKeybind?: string
|
||||
agentControl?: JSXElement
|
||||
modelControl?: JSXElement
|
||||
variantControl?: JSXElement
|
||||
working?: boolean
|
||||
accepting?: boolean
|
||||
todos?: TodoItem[]
|
||||
showTodos?: boolean
|
||||
todoCollapsed?: boolean
|
||||
onTodoCollapseChange?: (collapsed: boolean) => void
|
||||
heightSpring?: { visualDuration: number; bounce: number }
|
||||
morphSpring?: { visualDuration: number; bounce: number }
|
||||
atOptions?: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
|
||||
slashCommands?: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
|
||||
questionIndex?: number
|
||||
questionTotal?: number
|
||||
onQuestionNext?: () => void
|
||||
onQuestionBack?: () => void
|
||||
onQuestionDismiss?: () => void
|
||||
onQuestionSubmit?: (answers: string[][]) => void | Promise<void>
|
||||
onQuestionReject?: () => void | Promise<void>
|
||||
onQuestionAnswersChange?: (answers: string[][]) => void
|
||||
onQuestionJump?: (index: number) => void
|
||||
questionBusy?: boolean
|
||||
contextItems?: ContextItem[]
|
||||
contextActive?: (item: ContextItem) => boolean
|
||||
onContextOpen?: (item: ContextItem) => void
|
||||
onContextDrop?: (item: ContextItem) => void
|
||||
forceDragType?: "image" | "@mention" | null
|
||||
permissionTool?: string
|
||||
permissionDescription?: string
|
||||
permissionPatterns?: string[]
|
||||
onPermissionDecide?: (response: "once" | "always" | "reject") => void | Promise<void>
|
||||
permissionBusy?: boolean
|
||||
runtime?: ComposerRuntime
|
||||
}
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
||||
722
packages/ui/src/components/new-composer/use-editor.ts
Normal file
722
packages/ui/src/components/new-composer/use-editor.ts
Normal file
@@ -0,0 +1,722 @@
|
||||
import { createEffect, createSignal, on } from "solid-js"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import {
|
||||
canNavigateAtCursor,
|
||||
cloneParts,
|
||||
navigateEntry,
|
||||
normalizeEntry,
|
||||
partText,
|
||||
prependEntry,
|
||||
type HistoryEntry,
|
||||
} from "./history"
|
||||
import {
|
||||
createPill,
|
||||
createTextFragment,
|
||||
getCursorPosition,
|
||||
isImeComposing,
|
||||
parseEditorParts,
|
||||
parseEditorText,
|
||||
setCursorPosition,
|
||||
setRangeEdge,
|
||||
} from "./editor-utils"
|
||||
import type { AtOption, ComposerHistoryItem, ComposerMode, ComposerPart, ComposerSource, SlashCommand } from "./types"
|
||||
|
||||
interface Props {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
onSubmit?: (input: {
|
||||
source: ComposerSource
|
||||
mode: ComposerMode
|
||||
text: string
|
||||
parts: ComposerPart[]
|
||||
}) => void | Promise<void>
|
||||
onAbort?: () => void | Promise<void>
|
||||
onAuto?: () => void | Promise<void>
|
||||
onPick?: () => void | Promise<void>
|
||||
onModel?: () => void | Promise<void>
|
||||
onAgent?: () => void | Promise<void>
|
||||
onVariant?: () => void | Promise<void>
|
||||
modelKeybind?: string
|
||||
agentKeybind?: string
|
||||
variantKeybind?: string
|
||||
onSlash?: (cmd: SlashCommand) => boolean | Promise<boolean>
|
||||
historyRead?: (mode: ComposerMode) => Array<string | ComposerHistoryItem>
|
||||
historyWrite?: (mode: ComposerMode, list: ComposerHistoryItem[]) => void
|
||||
working?: () => boolean
|
||||
atOptions: AtOption[] | ((filter: string) => AtOption[] | Promise<AtOption[]>)
|
||||
slashCommands: SlashCommand[] | ((filter: string) => SlashCommand[] | Promise<SlashCommand[]>)
|
||||
editor: () => HTMLDivElement | undefined
|
||||
measure: () => void
|
||||
}
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
type Keybind = {
|
||||
key: string
|
||||
ctrl: boolean
|
||||
meta: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}
|
||||
|
||||
const normalize = (key: string, code?: string) => {
|
||||
if (code === "Quote") return "'"
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
if (key === "Dead" && code === "Quote") return "'"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
const parse = (config: string | undefined): Keybind[] => {
|
||||
if (!config || config === "none") return []
|
||||
return config.split(",").map((combo) => {
|
||||
const out: Keybind = {
|
||||
key: "",
|
||||
ctrl: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
alt: false,
|
||||
}
|
||||
for (const part of combo.trim().toLowerCase().split("+")) {
|
||||
if (part === "ctrl" || part === "control") {
|
||||
out.ctrl = true
|
||||
continue
|
||||
}
|
||||
if (part === "meta" || part === "cmd" || part === "command") {
|
||||
out.meta = true
|
||||
continue
|
||||
}
|
||||
if (part === "mod") {
|
||||
if (IS_MAC) out.meta = true
|
||||
else out.ctrl = true
|
||||
continue
|
||||
}
|
||||
if (part === "alt" || part === "option") {
|
||||
out.alt = true
|
||||
continue
|
||||
}
|
||||
if (part === "shift") {
|
||||
out.shift = true
|
||||
continue
|
||||
}
|
||||
out.key = part
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
const match = (config: string | undefined, event: KeyboardEvent) => {
|
||||
const key = normalize(event.key, event.code)
|
||||
return parse(config).some((kb) => {
|
||||
return (
|
||||
kb.key === key &&
|
||||
kb.ctrl === !!event.ctrlKey &&
|
||||
kb.meta === !!event.metaKey &&
|
||||
kb.shift === !!event.shiftKey &&
|
||||
kb.alt === !!event.altKey
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function useEditor(props: Props) {
|
||||
const [value, setValue] = createSignal(props.value ?? "")
|
||||
const [mode, setMode] = createSignal<ComposerMode>("normal")
|
||||
const [popover, setPopover] = createSignal<"at" | "slash" | null>(null)
|
||||
const [composing, setComposing] = createSignal(false)
|
||||
|
||||
const readList = (kind: ComposerMode) => {
|
||||
const list = props.historyRead?.(kind) ?? []
|
||||
return list.map(normalizeEntry)
|
||||
}
|
||||
|
||||
const [history, setHistory] = createSignal<Record<ComposerMode, HistoryEntry[]>>({
|
||||
normal: readList("normal"),
|
||||
shell: readList("shell"),
|
||||
})
|
||||
const [index, setIndex] = createSignal(-1)
|
||||
const [saved, setSaved] = createSignal<HistoryEntry | null>(null)
|
||||
|
||||
const setList = (next: HistoryEntry[]) => {
|
||||
const kind = mode()
|
||||
setHistory((prev) => ({ ...prev, [kind]: next }))
|
||||
props.historyWrite?.(
|
||||
kind,
|
||||
next.map((item) => ({
|
||||
text: item.text,
|
||||
parts: cloneParts(item.parts),
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
const getList = () => history()[mode()]
|
||||
|
||||
const setEditorMode = (next: ComposerMode) => {
|
||||
if (next !== mode() && props.historyRead) {
|
||||
setHistory((prev) => ({ ...prev, [next]: readList(next) }))
|
||||
}
|
||||
setMode(next)
|
||||
setIndex(-1)
|
||||
setSaved(null)
|
||||
setPopover(null)
|
||||
}
|
||||
|
||||
const atKey = (item: AtOption) => (item.type === "file" ? item.path : `agent:${item.name}`)
|
||||
const at = useFilteredList<AtOption>({
|
||||
items: props.atOptions,
|
||||
key: atKey,
|
||||
filterKeys: ["display", "name", "path"],
|
||||
onSelect: (item) => {
|
||||
if (!item) return
|
||||
insertAt(item)
|
||||
},
|
||||
})
|
||||
|
||||
const slash = useFilteredList<SlashCommand>({
|
||||
items: props.slashCommands,
|
||||
key: (x) => x.id,
|
||||
filterKeys: ["trigger", "title"],
|
||||
onSelect: (item) => {
|
||||
if (!item) return
|
||||
insertSlash(item)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.value,
|
||||
(next) => {
|
||||
if (next === undefined || next === value()) return
|
||||
setText(next)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const keepCursor = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
const viewport = el.parentElement
|
||||
if (!(viewport instanceof HTMLElement)) return
|
||||
if (el.scrollHeight <= viewport.clientHeight + 2) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!el.contains(range.startContainer)) return
|
||||
|
||||
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect()
|
||||
const box = viewport.getBoundingClientRect()
|
||||
const pad = Math.max(4, Math.min(12, Math.floor(viewport.clientHeight * 0.25)))
|
||||
const top = box.top + pad
|
||||
const bottom = box.bottom - pad
|
||||
|
||||
if (rect.top < top) {
|
||||
viewport.scrollTop -= top - rect.top
|
||||
return
|
||||
}
|
||||
|
||||
if (rect.bottom > bottom) {
|
||||
viewport.scrollTop += rect.bottom - bottom
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
const text = parseEditorText(el)
|
||||
setValue(text)
|
||||
props.onValueChange?.(text)
|
||||
props.measure()
|
||||
|
||||
if (mode() !== "shell") {
|
||||
const cursor = getCursorPosition(el)
|
||||
const head = text.substring(0, cursor)
|
||||
const atMatch = head.match(/@(\S*)$/)
|
||||
const slashMatch = text.match(/^\/(\S*)$/)
|
||||
|
||||
if (atMatch) {
|
||||
at.onInput(atMatch[1] ?? "")
|
||||
setPopover("at")
|
||||
} else if (slashMatch) {
|
||||
slash.onInput(slashMatch[1] ?? "")
|
||||
setPopover("slash")
|
||||
} else {
|
||||
setPopover(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode() === "shell") setPopover(null)
|
||||
|
||||
setIndex(-1)
|
||||
setSaved(null)
|
||||
}
|
||||
|
||||
const setParts = (parts: ComposerPart[], cursor?: number) => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
|
||||
el.textContent = ""
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
if (!part.content) continue
|
||||
el.appendChild(createTextFragment(part.content))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "file") {
|
||||
el.appendChild(createPill("file", part.content || `@${part.path}`, part.path))
|
||||
continue
|
||||
}
|
||||
|
||||
el.appendChild(createPill("agent", part.content || `@${part.name}`))
|
||||
}
|
||||
|
||||
const text = partText(parts)
|
||||
if (cursor === undefined) {
|
||||
setCursorPosition(el, text.length)
|
||||
} else {
|
||||
setCursorPosition(el, cursor)
|
||||
}
|
||||
|
||||
setValue(text)
|
||||
props.onValueChange?.(text)
|
||||
props.measure()
|
||||
requestAnimationFrame(keepCursor)
|
||||
}
|
||||
|
||||
const setText = (text: string) => {
|
||||
setParts([{ type: "text", content: text }], text.length)
|
||||
}
|
||||
|
||||
const setEntry = (item: HistoryEntry, cursor: "start" | "end" = "end") => {
|
||||
setParts(item.parts, cursor === "start" ? 0 : partText(item.parts).length)
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return false
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return false
|
||||
if (selection.isCollapsed) return false
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return false
|
||||
|
||||
range.deleteContents()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
handleInput()
|
||||
return true
|
||||
}
|
||||
|
||||
const deleteSpan = (start: number, end: number) => {
|
||||
const el = props.editor()
|
||||
if (!el) return false
|
||||
if (start >= end) return false
|
||||
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(el)
|
||||
setRangeEdge(el, range, "start", start)
|
||||
setRangeEdge(el, range, "end", end)
|
||||
range.deleteContents()
|
||||
range.collapse(true)
|
||||
|
||||
const selection = window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
|
||||
handleInput()
|
||||
return true
|
||||
}
|
||||
|
||||
const lineStart = (text: string, cursor: number) => {
|
||||
const i = text.lastIndexOf("\n", Math.max(0, cursor - 1))
|
||||
if (i < 0) return 0
|
||||
return i + 1
|
||||
}
|
||||
|
||||
const lineEnd = (text: string, cursor: number) => {
|
||||
const i = text.indexOf("\n", cursor)
|
||||
if (i < 0) return text.length
|
||||
return i
|
||||
}
|
||||
|
||||
const deleteWordBackward = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return false
|
||||
const text = parseEditorText(el)
|
||||
const cursor = getCursorPosition(el)
|
||||
if (cursor <= 0) return false
|
||||
|
||||
let i = cursor
|
||||
while (i > 0 && /\s/.test(text[i - 1] ?? "")) i -= 1
|
||||
while (i > 0 && !/\s/.test(text[i - 1] ?? "")) i -= 1
|
||||
if (i === cursor) i = cursor - 1
|
||||
|
||||
return deleteSpan(i, cursor)
|
||||
}
|
||||
|
||||
const deleteLineForward = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return false
|
||||
const text = parseEditorText(el)
|
||||
const cursor = getCursorPosition(el)
|
||||
if (cursor >= text.length) return false
|
||||
|
||||
let i = lineEnd(text, cursor)
|
||||
if (i === cursor && text[i] === "\n") i += 1
|
||||
return deleteSpan(cursor, Math.min(i, text.length))
|
||||
}
|
||||
|
||||
const deleteLineBackward = () => {
|
||||
const el = props.editor()
|
||||
if (!el) return false
|
||||
const text = parseEditorText(el)
|
||||
const cursor = getCursorPosition(el)
|
||||
if (cursor <= 0) return false
|
||||
return deleteSpan(lineStart(text, cursor), cursor)
|
||||
}
|
||||
|
||||
const insertAt = (item: AtOption) => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
|
||||
const getRange = () => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return null
|
||||
const range = selection.getRangeAt(0)
|
||||
if (!el.contains(range.startContainer)) return null
|
||||
return { selection, range }
|
||||
}
|
||||
|
||||
let current = getRange()
|
||||
if (!current) {
|
||||
el.focus()
|
||||
setCursorPosition(el, parseEditorText(el).length)
|
||||
current = getRange()
|
||||
}
|
||||
if (!current) return
|
||||
|
||||
const selection = current.selection
|
||||
const range = current.range
|
||||
|
||||
const cursor = getCursorPosition(el)
|
||||
const text = parseEditorText(el)
|
||||
const head = text.substring(0, cursor)
|
||||
const match = head.match(/@(\S*)$/)
|
||||
|
||||
const label = item.type === "file" ? item.path : `@${item.name}`
|
||||
const pill = createPill(item.type, label, item.type === "file" ? item.path : undefined)
|
||||
const gap = document.createTextNode(" ")
|
||||
|
||||
if (match) {
|
||||
const start = match.index ?? cursor - match[0].length
|
||||
setRangeEdge(el, range, "start", start)
|
||||
setRangeEdge(el, range, "end", cursor)
|
||||
}
|
||||
|
||||
range.deleteContents()
|
||||
range.insertNode(gap)
|
||||
range.insertNode(pill)
|
||||
range.setStartAfter(gap)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
handleInput()
|
||||
setPopover(null)
|
||||
}
|
||||
|
||||
const applySlash = (cmd: SlashCommand) => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
el.textContent = ""
|
||||
el.appendChild(document.createTextNode(`/${cmd.trigger} `))
|
||||
setCursorPosition(el, cmd.trigger.length + 2)
|
||||
handleInput()
|
||||
setPopover(null)
|
||||
}
|
||||
|
||||
const insertSlash = (cmd: SlashCommand) => {
|
||||
const run = props.onSlash?.(cmd)
|
||||
if (run instanceof Promise) {
|
||||
void run
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
setText("")
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
applySlash(cmd)
|
||||
})
|
||||
.catch(() => {
|
||||
applySlash(cmd)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (run) {
|
||||
setText("")
|
||||
setPopover(null)
|
||||
return
|
||||
}
|
||||
|
||||
applySlash(cmd)
|
||||
}
|
||||
|
||||
const insertFile = (path: string) => {
|
||||
insertAt({
|
||||
type: "file",
|
||||
path,
|
||||
display: path,
|
||||
})
|
||||
}
|
||||
|
||||
const parts = (): ComposerPart[] => {
|
||||
const el = props.editor()
|
||||
if (!el) return []
|
||||
return parseEditorParts(el)
|
||||
}
|
||||
|
||||
const submit = (source: ComposerSource) => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
|
||||
const text = parseEditorText(el)
|
||||
const clean = text.trim()
|
||||
const parsed = parseEditorParts(el)
|
||||
if (clean) {
|
||||
const list = getList()
|
||||
const next = {
|
||||
text,
|
||||
parts: cloneParts(parsed),
|
||||
} satisfies HistoryEntry
|
||||
const out = prependEntry(list, next)
|
||||
if (out !== list) setList(out)
|
||||
}
|
||||
|
||||
void props.onSubmit?.({ source, mode: mode(), text, parts: parsed })
|
||||
}
|
||||
|
||||
const abort = () => {
|
||||
if (!props.working?.()) return
|
||||
void props.onAbort?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const el = props.editor()
|
||||
if (!el) return
|
||||
|
||||
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
|
||||
const cmd = event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey
|
||||
|
||||
if (ctrl && event.key.toLowerCase() === "w") {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteWordBackward()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && event.key === "Backspace") {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteWordBackward()
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.metaKey && !event.ctrlKey && !event.shiftKey && event.key === "Backspace" && event.altKey) {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteWordBackward()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && event.key.toLowerCase() === "k") {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteLineForward()
|
||||
return
|
||||
}
|
||||
|
||||
if (cmd && event.key === "Backspace") {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteLineBackward()
|
||||
return
|
||||
}
|
||||
|
||||
if (cmd && event.key === "Delete") {
|
||||
event.preventDefault()
|
||||
if (deleteSelection()) return
|
||||
deleteLineForward()
|
||||
return
|
||||
}
|
||||
|
||||
if (match(props.modelKeybind ?? "mod+'", event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.onModel?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (match(props.variantKeybind ?? "shift+mod+d", event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.onVariant?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (match(props.agentKeybind ?? "mod+.", event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void props.onAgent?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (mod && event.key.toLowerCase() === "u") {
|
||||
event.preventDefault()
|
||||
if (mode() !== "normal") return
|
||||
void props.onPick?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "!" && mode() === "normal") {
|
||||
const cursor = getCursorPosition(el)
|
||||
if (cursor === 0) {
|
||||
setEditorMode("shell")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
if (popover()) {
|
||||
setPopover(null)
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
if (mode() === "shell") {
|
||||
setEditorMode("normal")
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (props.working?.()) {
|
||||
abort()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (mode() === "shell" && event.key === "Backspace") {
|
||||
if (!parseEditorText(el).trim()) {
|
||||
setEditorMode("normal")
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && event.shiftKey) return
|
||||
if (event.key === "Enter" && isImeComposing(event)) return
|
||||
|
||||
if (popover()) {
|
||||
if (event.key === "Tab") {
|
||||
if (popover() === "at") {
|
||||
const selected = at.flat().find((x) => atKey(x) === at.active())
|
||||
if (selected) insertAt(selected)
|
||||
} else {
|
||||
const selected = slash.flat().find((x) => x.id === slash.active())
|
||||
if (selected) insertSlash(selected)
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
|
||||
const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
|
||||
if (nav || ctrlNav) {
|
||||
if (popover() === "at") at.onKeyDown(event)
|
||||
else slash.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (ctrl && event.code === "KeyG") {
|
||||
if (popover()) {
|
||||
setPopover(null)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
abort()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const up = event.key === "ArrowUp" || (ctrl && event.key === "p")
|
||||
const down = event.key === "ArrowDown" || (ctrl && event.key === "n")
|
||||
if (up || down) {
|
||||
if (event.altKey || event.metaKey) return
|
||||
if (event.ctrlKey && !(event.key === "n" || event.key === "p")) return
|
||||
if (popover()) return
|
||||
|
||||
const text = parseEditorText(el)
|
||||
const cursor = getCursorPosition(el)
|
||||
const list = getList()
|
||||
|
||||
const dir = up ? "up" : "down"
|
||||
if (!canNavigateAtCursor(dir, text, cursor, index() >= 0)) return
|
||||
|
||||
const out = navigateEntry({
|
||||
direction: dir,
|
||||
entries: list,
|
||||
historyIndex: index(),
|
||||
current: {
|
||||
text,
|
||||
parts: cloneParts(parseEditorParts(el)),
|
||||
},
|
||||
saved: saved(),
|
||||
})
|
||||
|
||||
if (!out.handled) return
|
||||
setIndex(out.historyIndex)
|
||||
setSaved(out.saved)
|
||||
setEntry(out.entry, out.cursor)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
submit("enter")
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value,
|
||||
setValue,
|
||||
popover,
|
||||
setPopover,
|
||||
mode,
|
||||
setMode: setEditorMode,
|
||||
composing,
|
||||
setComposing,
|
||||
atKey,
|
||||
at,
|
||||
slash,
|
||||
handleInput,
|
||||
handleKeyDown,
|
||||
setText,
|
||||
submit,
|
||||
parts,
|
||||
insertAt,
|
||||
insertFile,
|
||||
insertSlash,
|
||||
}
|
||||
}
|
||||
87
packages/ui/src/components/new-composer/use-layout.ts
Normal file
87
packages/ui/src/components/new-composer/use-layout.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
|
||||
const EDITOR_PADDING = 16
|
||||
const MAX_EDITOR_CONTENT = 200 - EDITOR_PADDING
|
||||
const BUTTON_ROW_HEIGHT = 40
|
||||
const IMAGE_BAR_HEIGHT = 80
|
||||
const CONTEXT_BAR_HEIGHT = 64
|
||||
const SHELL_PADDING_INPUT = EDITOR_PADDING + BUTTON_ROW_HEIGHT
|
||||
const SHELL_PADDING_DOCK = 16
|
||||
|
||||
interface Props {
|
||||
isQuestion: () => boolean
|
||||
isPermission: () => boolean
|
||||
imageCount: () => number
|
||||
contextCount: () => number
|
||||
heightSpring?: { visualDuration: number; bounce: number }
|
||||
morphSpring?: { visualDuration: number; bounce: number }
|
||||
}
|
||||
|
||||
export function useLayout(props: Props) {
|
||||
const [editorHeight, setEditorHeight] = createSignal(24)
|
||||
const [questionRef, setQuestionRef] = createSignal<HTMLDivElement>()
|
||||
const [permissionRef, setPermissionRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
const questionHeight = useElementHeight(questionRef, 280)
|
||||
const permissionHeight = useElementHeight(permissionRef, 200)
|
||||
|
||||
const imageBarHeight = createMemo(() => (props.imageCount() > 0 ? IMAGE_BAR_HEIGHT : 0))
|
||||
const contextBarHeight = createMemo(() => (props.contextCount() > 0 ? CONTEXT_BAR_HEIGHT : 0))
|
||||
|
||||
const target = createMemo(() => {
|
||||
if (props.isPermission()) return permissionHeight() + SHELL_PADDING_DOCK
|
||||
if (props.isQuestion()) return questionHeight() + SHELL_PADDING_DOCK
|
||||
return editorHeight() + SHELL_PADDING_INPUT + imageBarHeight() + contextBarHeight()
|
||||
})
|
||||
|
||||
const morphTarget = () => (props.isQuestion() || props.isPermission() ? 1 : 0)
|
||||
const morph = useSpring(morphTarget, () => props.morphSpring ?? { visualDuration: 0.25, bounce: 0.1 })
|
||||
|
||||
const spring = useSpring(target, () => props.heightSpring ?? { visualDuration: 0.35, bounce: 0.2 })
|
||||
|
||||
const inputOpacity = createMemo(() => Math.max(0, 1 - morph() * 1.5))
|
||||
const inputScale = createMemo(() => 1 + morph() * 0.15)
|
||||
const inputBlur = createMemo(() => morph() * 5)
|
||||
const questionOpacity = createMemo(() => Math.max(0, morph() * 1.5 - 0.5))
|
||||
const questionScale = createMemo(() => 0.85 + morph() * 0.15)
|
||||
const questionBlur = createMemo(() => (1 - morph()) * 5)
|
||||
|
||||
// Keep overlay content mounted until both morph and height springs settle
|
||||
const [overlay, setOverlay] = createSignal<"question" | "permission" | null>(null)
|
||||
createEffect(() => {
|
||||
if (props.isQuestion()) setOverlay("question")
|
||||
else if (props.isPermission()) setOverlay("permission")
|
||||
else if (morph() < 0.01 && Math.abs(spring() - target()) < 2) setOverlay(null)
|
||||
})
|
||||
const showQuestion = () => overlay() === "question"
|
||||
const showPermission = () => overlay() === "permission"
|
||||
|
||||
const height = () => spring()
|
||||
|
||||
const measure = (el: HTMLDivElement | undefined) => {
|
||||
if (!el) return
|
||||
const raw = Math.ceil(el.scrollHeight - EDITOR_PADDING)
|
||||
const next = Math.min(MAX_EDITOR_CONTENT, Math.max(24, raw))
|
||||
const curr = editorHeight()
|
||||
if (Math.abs(next - curr) <= 2) return
|
||||
setEditorHeight(next)
|
||||
}
|
||||
|
||||
return {
|
||||
measure,
|
||||
setQuestionRef,
|
||||
setPermissionRef,
|
||||
height,
|
||||
morph,
|
||||
inputOpacity,
|
||||
inputScale,
|
||||
inputBlur,
|
||||
questionOpacity,
|
||||
questionScale,
|
||||
questionBlur,
|
||||
showQuestion,
|
||||
showPermission,
|
||||
}
|
||||
}
|
||||
76
packages/ui/src/components/new-composer/use-todo.ts
Normal file
76
packages/ui/src/components/new-composer/use-todo.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { createEffect, createMemo, createSignal, on } from "solid-js"
|
||||
import { useElementHeight } from "@opencode-ai/ui/hooks"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import type { TodoItem } from "./types"
|
||||
|
||||
export const COLLAPSED_HEIGHT = 78
|
||||
|
||||
interface Props {
|
||||
todos: () => TodoItem[]
|
||||
show: () => boolean
|
||||
blocked: () => boolean
|
||||
collapsed?: () => boolean | undefined
|
||||
onCollapsed?: (value: boolean) => void
|
||||
shellHeight: () => number
|
||||
trayHeight: () => number
|
||||
trayOverlap: number
|
||||
}
|
||||
|
||||
export function useTodo(props: Props) {
|
||||
const [collapsed, setCollapsedInner] = createSignal(props.collapsed?.() ?? false)
|
||||
const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.collapsed?.(),
|
||||
(next) => {
|
||||
if (next === undefined) return
|
||||
setCollapsedInner(next)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const setCollapsed = (value: boolean | ((prev: boolean) => boolean)) => {
|
||||
const next = typeof value === "function" ? value(collapsed()) : value
|
||||
setCollapsedInner(next)
|
||||
props.onCollapsed?.(next)
|
||||
}
|
||||
|
||||
const toggle = () => setCollapsed((prev) => !prev)
|
||||
|
||||
const open = createMemo(() => props.todos().length > 0 && props.show() && !props.blocked())
|
||||
const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
|
||||
const raw = useElementHeight(contentRef, 200)
|
||||
const full = useSpring(() => Math.max(COLLAPSED_HEIGHT, raw()), { visualDuration: 0.3, bounce: 0 })
|
||||
const visible = createMemo(() => {
|
||||
const max = full()
|
||||
const cut = max - collapse() * (max - COLLAPSED_HEIGHT)
|
||||
return cut * progress()
|
||||
})
|
||||
|
||||
const overlap = createMemo(() => 36 * progress())
|
||||
const shut = createMemo(() => 1 - progress())
|
||||
const hide = createMemo(() => Math.max(collapse(), shut()))
|
||||
|
||||
const bottom = createMemo(() => props.trayHeight() - props.trayOverlap + props.shellHeight() - overlap())
|
||||
const totalHeight = createMemo(
|
||||
() => visible() - overlap() + props.shellHeight() + props.trayHeight() - props.trayOverlap,
|
||||
)
|
||||
|
||||
return {
|
||||
contentRef: setContentRef,
|
||||
collapsed,
|
||||
setCollapsed,
|
||||
toggle,
|
||||
progress,
|
||||
collapse,
|
||||
visible,
|
||||
shut,
|
||||
hide,
|
||||
bottom,
|
||||
totalHeight,
|
||||
}
|
||||
}
|
||||
92
packages/ui/src/components/rolling-results.css
Normal file
92
packages/ui/src/components/rolling-results.css
Normal file
@@ -0,0 +1,92 @@
|
||||
[data-component="rolling-results"] {
|
||||
--rolling-results-row-height: 22px;
|
||||
--rolling-results-fixed-height: var(--rolling-results-row-height);
|
||||
--rolling-results-fixed-gap: 0px;
|
||||
--rolling-results-row-gap: 0px;
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
[data-slot="rolling-results-viewport"] {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black var(--rolling-results-fade),
|
||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black var(--rolling-results-fade),
|
||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-fixed"] {
|
||||
min-width: 0;
|
||||
height: var(--rolling-results-fixed-height);
|
||||
min-height: var(--rolling-results-fixed-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-window"] {
|
||||
min-width: 0;
|
||||
margin-top: var(--rolling-results-fixed-gap);
|
||||
height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&[data-scrollable="true"] [data-slot="rolling-results-window"] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-scrollable="true"] [data-slot="rolling-results-track"] {
|
||||
transform: none !important;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-body"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-track"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--rolling-results-row-gap);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-row"],
|
||||
[data-slot="rolling-results-empty"] {
|
||||
min-width: 0;
|
||||
height: var(--rolling-results-row-height);
|
||||
min-height: var(--rolling-results-row-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-row"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-empty"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
}
|
||||
326
packages/ui/src/components/rolling-results.tsx
Normal file
326
packages/ui/src/components/rolling-results.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
||||
export type RollingResultsProps<T> = {
|
||||
items: T[]
|
||||
render: (item: T, index: number) => JSX.Element
|
||||
fixed?: JSX.Element
|
||||
getKey?: (item: T, index: number) => string
|
||||
rows?: number
|
||||
rowHeight?: number
|
||||
fixedHeight?: number
|
||||
rowGap?: number
|
||||
open?: boolean
|
||||
scrollable?: boolean
|
||||
spring?: SpringConfig
|
||||
animate?: boolean
|
||||
class?: string
|
||||
empty?: JSX.Element
|
||||
noFadeOnCollapse?: boolean
|
||||
}
|
||||
|
||||
export function RollingResults<T>(props: RollingResultsProps<T>) {
|
||||
let view: HTMLDivElement | undefined
|
||||
let track: HTMLDivElement | undefined
|
||||
let windowEl: HTMLDivElement | undefined
|
||||
let shift: AnimationPlaybackControls | undefined
|
||||
let resize: AnimationPlaybackControls | undefined
|
||||
let edgeFade: AnimationPlaybackControls | undefined
|
||||
|
||||
const reducedMotion = prefersReducedMotion
|
||||
|
||||
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
|
||||
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
|
||||
const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
|
||||
const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
|
||||
const fixed = createMemo(() => props.fixed !== undefined)
|
||||
const list = createMemo(() => props.items ?? [])
|
||||
const count = createMemo(() => list().length)
|
||||
|
||||
// scrollReady is the internal "transition complete" state.
|
||||
// It only becomes true after props.scrollable is true AND the offset animation has settled.
|
||||
const [scrollReady, setScrollReady] = createSignal(false)
|
||||
|
||||
const backstop = createMemo(() => Math.max(rows() * 2, 12))
|
||||
const rendered = createMemo(() => {
|
||||
const items = list()
|
||||
if (scrollReady()) return items
|
||||
const max = backstop()
|
||||
return items.length > max ? items.slice(-max) : items
|
||||
})
|
||||
const skipped = createMemo(() => {
|
||||
if (scrollReady()) return 0
|
||||
return count() - rendered().length
|
||||
})
|
||||
const open = createMemo(() => props.open !== false)
|
||||
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
|
||||
const noFade = () => props.noFadeOnCollapse === true
|
||||
const overflowing = createMemo(() => count() > rows())
|
||||
const shown = createMemo(() => Math.min(rows(), count()))
|
||||
const step = createMemo(() => rowHeight() + rowGap())
|
||||
const offset = createMemo(() => Math.max(0, count() - shown()) * step())
|
||||
const body = createMemo(() => {
|
||||
if (shown() > 0) {
|
||||
return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
|
||||
}
|
||||
if (props.empty === undefined) return 0
|
||||
return rowHeight()
|
||||
})
|
||||
const gap = createMemo(() => {
|
||||
if (!fixed()) return 0
|
||||
if (body() <= 0) return 0
|
||||
return rowGap()
|
||||
})
|
||||
const height = createMemo(() => {
|
||||
if (!open()) return 0
|
||||
if (!fixed()) return body()
|
||||
return fixedHeight() + gap() + body()
|
||||
})
|
||||
|
||||
const key = (item: T, index: number) => {
|
||||
const value = props.getKey
|
||||
if (value) return value(item, index)
|
||||
return String(index)
|
||||
}
|
||||
|
||||
const setTrack = (value: number) => {
|
||||
if (!track) return
|
||||
track.style.transform = `translateY(${-Math.round(value)}px)`
|
||||
}
|
||||
|
||||
const setView = (value: number) => {
|
||||
if (!view) return
|
||||
view.style.height = `${Math.max(0, Math.round(value))}px`
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setTrack(offset())
|
||||
})
|
||||
|
||||
// Original WAAPI offset animation — untouched rolling behavior.
|
||||
createEffect(
|
||||
on(
|
||||
offset,
|
||||
(next) => {
|
||||
if (!track) return
|
||||
if (scrollReady()) return
|
||||
if (props.scrollable) return
|
||||
if (!active()) {
|
||||
shift?.stop()
|
||||
shift = undefined
|
||||
setTrack(next)
|
||||
return
|
||||
}
|
||||
shift?.stop()
|
||||
const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
|
||||
shift = anim
|
||||
anim.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (shift !== anim) return
|
||||
setTrack(next)
|
||||
shift = undefined
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
// Scrollable transition: wait for the offset animation to finish,
|
||||
// then batch all DOM changes in one synchronous pass.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.scrollable === true,
|
||||
(isScrollable) => {
|
||||
if (!isScrollable) {
|
||||
setScrollReady(false)
|
||||
if (windowEl) {
|
||||
windowEl.style.overflowY = ""
|
||||
windowEl.style.maskImage = ""
|
||||
windowEl.style.webkitMaskImage = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
// Wait for the current offset animation to settle (if any).
|
||||
const done = shift?.finished ?? Promise.resolve()
|
||||
done
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (props.scrollable !== true) return
|
||||
|
||||
// Batch the signal update — Solid updates the DOM synchronously:
|
||||
// rendered() returns all items, skipped() returns 0, padding-top removed,
|
||||
// data-scrollable becomes "true".
|
||||
batch(() => setScrollReady(true))
|
||||
|
||||
// Now the DOM has all items. Safe to switch layout strategy.
|
||||
// CSS handles `transform: none !important` on [data-scrollable="true"].
|
||||
if (windowEl) {
|
||||
windowEl.style.overflowY = "auto"
|
||||
windowEl.scrollTop = windowEl.scrollHeight
|
||||
}
|
||||
updateScrollMask()
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Auto-scroll to bottom when new items arrive in scrollable mode
|
||||
const [userScrolled, setUserScrolled] = createSignal(false)
|
||||
|
||||
const updateScrollMask = () => {
|
||||
if (!windowEl) return
|
||||
if (!scrollReady()) {
|
||||
windowEl.style.maskImage = ""
|
||||
windowEl.style.webkitMaskImage = ""
|
||||
return
|
||||
}
|
||||
const { scrollTop, scrollHeight, clientHeight } = windowEl
|
||||
const atBottom = scrollHeight - scrollTop - clientHeight < 8
|
||||
// Top fade is always present in scrollable mode (matches rolling mode appearance).
|
||||
// Bottom fade only when not scrolled to the end.
|
||||
const mask = atBottom
|
||||
? "linear-gradient(to bottom, transparent 0, black 8px)"
|
||||
: "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
|
||||
windowEl.style.maskImage = mask
|
||||
windowEl.style.webkitMaskImage = mask
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!scrollReady()) {
|
||||
setUserScrolled(false)
|
||||
return
|
||||
}
|
||||
const _n = count()
|
||||
const scrolled = userScrolled()
|
||||
if (scrolled) return
|
||||
if (windowEl) {
|
||||
windowEl.scrollTop = windowEl.scrollHeight
|
||||
updateScrollMask()
|
||||
}
|
||||
})
|
||||
|
||||
const onWindowScroll = () => {
|
||||
if (!windowEl || !scrollReady()) return
|
||||
const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
|
||||
setUserScrolled(!atBottom)
|
||||
updateScrollMask()
|
||||
}
|
||||
|
||||
const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
|
||||
const applyEdge = () => {
|
||||
if (!view) return
|
||||
edgeFade?.stop()
|
||||
edgeFade = undefined
|
||||
view.style.maskImage = EDGE_MASK
|
||||
view.style.webkitMaskImage = EDGE_MASK
|
||||
view.style.maskSize = "100% 100%"
|
||||
view.style.maskRepeat = "no-repeat"
|
||||
}
|
||||
const clearEdge = () => {
|
||||
if (!view) return
|
||||
if (!active()) {
|
||||
clearMaskStyles(view)
|
||||
return
|
||||
}
|
||||
edgeFade?.stop()
|
||||
const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
|
||||
edgeFade = anim
|
||||
anim.finished
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (edgeFade !== anim || !view) return
|
||||
clearMaskStyles(view)
|
||||
edgeFade = undefined
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(height, (next, prev) => {
|
||||
if (!view) return
|
||||
if (!active()) {
|
||||
resize?.stop()
|
||||
resize = undefined
|
||||
setView(next)
|
||||
view.style.opacity = ""
|
||||
clearEdge()
|
||||
return
|
||||
}
|
||||
const collapsing = next === 0 && prev !== undefined && prev > 0
|
||||
const expanding = prev === 0 && next > 0
|
||||
resize?.stop()
|
||||
view.style.opacity = ""
|
||||
applyEdge()
|
||||
const spring = props.spring ?? GROW_SPRING
|
||||
const anim = collapsing
|
||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
|
||||
: expanding
|
||||
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
|
||||
: animate(view, { height: `${next}px` }, spring)
|
||||
resize = anim
|
||||
anim.finished
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
view.style.opacity = ""
|
||||
if (resize !== anim) return
|
||||
setView(next)
|
||||
resize = undefined
|
||||
clearEdge()
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
shift?.stop()
|
||||
resize?.stop()
|
||||
edgeFade?.stop()
|
||||
shift = undefined
|
||||
resize = undefined
|
||||
edgeFade = undefined
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="rolling-results"
|
||||
class={props.class}
|
||||
data-open={open() ? "true" : "false"}
|
||||
data-overflowing={overflowing() ? "true" : "false"}
|
||||
data-scrollable={scrollReady() ? "true" : "false"}
|
||||
data-fixed={fixed() ? "true" : "false"}
|
||||
style={{
|
||||
"--rolling-results-row-height": `${rowHeight()}px`,
|
||||
"--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
|
||||
"--rolling-results-fixed-gap": `${gap()}px`,
|
||||
"--rolling-results-row-gap": `${rowGap()}px`,
|
||||
"--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
|
||||
}}
|
||||
>
|
||||
<div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
|
||||
<Show when={fixed()}>
|
||||
<div data-slot="rolling-results-fixed">{props.fixed}</div>
|
||||
</Show>
|
||||
<div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
|
||||
<div data-slot="rolling-results-body">
|
||||
<Show when={list().length === 0 && props.empty !== undefined}>
|
||||
<div data-slot="rolling-results-empty">{props.empty}</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={track}
|
||||
data-slot="rolling-results-track"
|
||||
style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
|
||||
>
|
||||
<For each={rendered()}>
|
||||
{(item, index) => (
|
||||
<div data-slot="rolling-results-row" data-key={key(item, index())}>
|
||||
{props.render(item, index())}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
outline: none;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.scroll-view__viewport::-webkit-scrollbar {
|
||||
@@ -45,18 +48,6 @@
|
||||
background-color: var(--border-strong-base);
|
||||
}
|
||||
|
||||
.dark .scroll-view__thumb::after,
|
||||
[data-theme="dark"] .scroll-view__thumb::after {
|
||||
background-color: var(--border-weak-base);
|
||||
}
|
||||
|
||||
.dark .scroll-view__thumb:hover::after,
|
||||
[data-theme="dark"] .scroll-view__thumb:hover::after,
|
||||
.dark .scroll-view__thumb[data-dragging="true"]::after,
|
||||
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
|
||||
background-color: var(--border-strong-base);
|
||||
}
|
||||
|
||||
.scroll-view__thumb[data-visible="true"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
|
||||
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
|
||||
import { animate, type AnimationPlaybackControls } from "motion"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { FAST_SPRING } from "./motion"
|
||||
|
||||
export interface ScrollViewProps extends ComponentProps<"div"> {
|
||||
viewportRef?: (el: HTMLDivElement) => void
|
||||
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
|
||||
}
|
||||
|
||||
export function ScrollView(props: ScrollViewProps) {
|
||||
const i18n = useI18n()
|
||||
const merged = mergeProps({ orientation: "vertical" }, props)
|
||||
const [local, events, rest] = splitProps(
|
||||
merged,
|
||||
["class", "children", "viewportRef", "orientation", "style"],
|
||||
props,
|
||||
["class", "children", "viewportRef", "style"],
|
||||
[
|
||||
"onScroll",
|
||||
"onWheel",
|
||||
@@ -25,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
],
|
||||
)
|
||||
|
||||
let rootRef!: HTMLDivElement
|
||||
let viewportRef!: HTMLDivElement
|
||||
let thumbRef!: HTMLDivElement
|
||||
let anim: AnimationPlaybackControls | undefined
|
||||
|
||||
const [isHovered, setIsHovered] = createSignal(false)
|
||||
const [isDragging, setIsDragging] = createSignal(false)
|
||||
@@ -57,9 +57,12 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
const maxScrollTop = scrollHeight - clientHeight
|
||||
const maxThumbTop = trackHeight - height
|
||||
|
||||
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
|
||||
// With column-reverse: scrollTop=0 is at bottom, negative = scrolled up
|
||||
// Normalize so 0 = at top, maxScrollTop = at bottom
|
||||
const normalizedScrollTop = maxScrollTop + scrollTop
|
||||
const top = maxScrollTop > 0 ? (normalizedScrollTop / maxScrollTop) * maxThumbTop : 0
|
||||
|
||||
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
|
||||
// Ensure thumb stays within bounds
|
||||
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
|
||||
|
||||
setThumbHeight(height)
|
||||
@@ -82,6 +85,7 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
observer.disconnect()
|
||||
})
|
||||
|
||||
@@ -123,6 +127,30 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
thumbRef.addEventListener("pointerup", onPointerUp)
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
if (!anim) return
|
||||
anim.stop()
|
||||
anim = undefined
|
||||
}
|
||||
|
||||
const limit = (top: number) => {
|
||||
const max = viewportRef.scrollHeight - viewportRef.clientHeight
|
||||
return Math.max(-max, Math.min(0, top))
|
||||
}
|
||||
|
||||
const glide = (top: number) => {
|
||||
stop()
|
||||
anim = animate(viewportRef.scrollTop, limit(top), {
|
||||
...FAST_SPRING,
|
||||
onUpdate: (v) => {
|
||||
viewportRef.scrollTop = v
|
||||
},
|
||||
onComplete: () => {
|
||||
anim = undefined
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Keybinds implementation
|
||||
// We ensure the viewport has a tabindex so it can receive focus
|
||||
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
|
||||
@@ -147,11 +175,13 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
|
||||
// With column-reverse, top of content = -(scrollHeight - clientHeight)
|
||||
glide(-(viewportRef.scrollHeight - viewportRef.clientHeight))
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
|
||||
// With column-reverse, bottom of content = 0
|
||||
glide(0)
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
@@ -166,7 +196,6 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
class={`scroll-view ${local.class || ""}`}
|
||||
style={local.style}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
@@ -181,12 +210,21 @@ export function ScrollView(props: ScrollViewProps) {
|
||||
updateThumb()
|
||||
if (typeof events.onScroll === "function") events.onScroll(e as any)
|
||||
}}
|
||||
onWheel={events.onWheel as any}
|
||||
onTouchStart={events.onTouchStart as any}
|
||||
onWheel={(e) => {
|
||||
if (e.deltaY) stop()
|
||||
if (typeof events.onWheel === "function") events.onWheel(e as any)
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
stop()
|
||||
if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
|
||||
}}
|
||||
onTouchMove={events.onTouchMove as any}
|
||||
onTouchEnd={events.onTouchEnd as any}
|
||||
onTouchCancel={events.onTouchCancel as any}
|
||||
onPointerDown={events.onPointerDown as any}
|
||||
onPointerDown={(e) => {
|
||||
stop()
|
||||
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
|
||||
}}
|
||||
onClick={events.onClick as any}
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
|
||||
@@ -145,7 +145,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const searchHandles = new Map<string, FileSearchHandle>()
|
||||
const readyFiles = new Set<string>()
|
||||
const [store, setStore] = createStore<{ open: string[]; force: Record<string, boolean> }>({
|
||||
open: [],
|
||||
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
|
||||
force: {},
|
||||
})
|
||||
|
||||
|
||||
1692
packages/ui/src/components/session-timeline-simulator.stories.tsx
Normal file
1692
packages/ui/src/components/session-timeline-simulator.stories.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
||||
[data-component="session-turn"] {
|
||||
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
@@ -26,7 +25,7 @@
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
min-width: 0;
|
||||
gap: 18px;
|
||||
gap: 0px;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
@@ -43,30 +42,127 @@
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-assistant-lane"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking"] {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
color: var(--text-weak);
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
min-height: 20px;
|
||||
line-height: var(--line-height-large);
|
||||
height: 36px;
|
||||
|
||||
[data-component="spinner"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
> [data-component="text-shimmer"] {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-turn-handoff-wrap"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-handoff"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 37px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
will-change: opacity, filter;
|
||||
transition:
|
||||
opacity 180ms ease-out,
|
||||
filter 180ms ease-out,
|
||||
transform 180ms ease-out;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking"][data-visible="false"] {
|
||||
opacity: 0;
|
||||
filter: blur(2px);
|
||||
transform: translateY(1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-thinking"][data-visible="true"] {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-meta"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 37px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-meta"][data-interrupted] {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
|
||||
[data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-meta-label"] {
|
||||
user-select: none;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="text-reveal"].session-turn-thinking-heading {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
line-height: inherit;
|
||||
color: var(--text-weaker);
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
[data-slot="text-reveal-track"],
|
||||
[data-slot="text-reveal-entering"],
|
||||
[data-slot="text-reveal-leaving"] {
|
||||
min-height: 0;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.error-card {
|
||||
@@ -84,7 +180,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: stretch;
|
||||
gap: 12px;
|
||||
gap: 0px;
|
||||
|
||||
> :first-child > [data-component="markdown"]:first-child {
|
||||
margin-top: 0;
|
||||
@@ -109,6 +205,7 @@
|
||||
|
||||
[data-component="session-turn-diffs-trigger"] {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -118,7 +215,7 @@
|
||||
|
||||
[data-slot="session-turn-diffs-title"] {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@@ -135,7 +232,7 @@
|
||||
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);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diffs-meta"] {
|
||||
@@ -171,8 +268,10 @@
|
||||
|
||||
[data-slot="session-turn-diff-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
@@ -180,16 +279,22 @@
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-directory"] {
|
||||
color: var(--text-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1 1 auto;
|
||||
color: var(--text-weak);
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
unicode-bidi: plaintext;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="session-turn-diff-filename"] {
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: clip;
|
||||
white-space: nowrap;
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
@@ -3,23 +3,27 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
|
||||
import { GrowBox } from "./grow-box"
|
||||
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { SessionRetry } from "./session-retry"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
import { list } from "./text-utils"
|
||||
import { SessionRetry } from "./session-retry"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -73,18 +77,12 @@ function unwrap(message: string) {
|
||||
return message
|
||||
}
|
||||
|
||||
function same<T>(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 list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
if (Array.isArray(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
const hidden = new Set(["todowrite", "todoread"])
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const idle: SessionStatus = { type: "idle" as const }
|
||||
const handoffHoldMs = 120
|
||||
|
||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
if (part.type === "tool") {
|
||||
@@ -141,6 +139,7 @@ export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
messageID: string
|
||||
animate?: boolean
|
||||
showReasoningSummaries?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
@@ -159,11 +158,7 @@ export function SessionTurn(
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
|
||||
|
||||
@@ -191,42 +186,8 @@ export function SessionTurn(
|
||||
return msg
|
||||
})
|
||||
|
||||
const pending = createMemo(() => {
|
||||
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
return messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
})
|
||||
|
||||
const pendingUser = createMemo(() => {
|
||||
const item = pending()
|
||||
if (!item?.parentID) return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
const result = Binary.search(messages, item.parentID, (m) => m.id)
|
||||
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
|
||||
if (!msg || msg.role !== "user") return
|
||||
return msg
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return props.active
|
||||
const msg = message()
|
||||
const parent = pendingUser()
|
||||
if (!msg || !parent) return false
|
||||
return parent.id === msg.id
|
||||
})
|
||||
|
||||
const queued = createMemo(() => {
|
||||
if (typeof props.queued === "boolean") return props.queued
|
||||
const id = message()?.id
|
||||
if (!id) return false
|
||||
if (!pendingUser()) return false
|
||||
const item = pending()
|
||||
if (!item) return false
|
||||
return id > item.id
|
||||
})
|
||||
|
||||
const active = createMemo(() => props.active ?? false)
|
||||
const queued = createMemo(() => props.queued ?? false)
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyParts
|
||||
@@ -289,7 +250,7 @@ export function SessionTurn(
|
||||
const error = createMemo(
|
||||
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
||||
)
|
||||
const showAssistantCopyPartID = createMemo(() => {
|
||||
const assistantCopyPart = createMemo(() => {
|
||||
const messages = assistantMessages()
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -299,13 +260,18 @@ export function SessionTurn(
|
||||
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
|
||||
if (!part || part.type !== "text") continue
|
||||
const text = part.text?.trim()
|
||||
if (!text) continue
|
||||
return {
|
||||
id: part.id,
|
||||
text,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
|
||||
const errorText = createMemo(() => {
|
||||
const msg = error()?.data?.message
|
||||
if (typeof msg === "string") return unwrap(msg)
|
||||
@@ -313,18 +279,14 @@ export function SessionTurn(
|
||||
return unwrap(String(msg))
|
||||
})
|
||||
|
||||
const status = createMemo(() => {
|
||||
if (props.status !== undefined) return props.status
|
||||
if (typeof props.active === "boolean" && !props.active) return idle
|
||||
return data.store.session_status[props.sessionID] ?? idle
|
||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
||||
const working = createMemo(() => {
|
||||
if (status().type === "idle") return false
|
||||
if (!message()) return false
|
||||
return active()
|
||||
})
|
||||
const working = createMemo(() => status().type !== "idle" && active())
|
||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||
|
||||
const assistantCopyPartID = createMemo(() => {
|
||||
if (working()) return null
|
||||
return showAssistantCopyPartID() ?? null
|
||||
})
|
||||
const showDiffSummary = createMemo(() => edited() > 0 && !working())
|
||||
const turnDurationMs = createMemo(() => {
|
||||
const start = message()?.time.created
|
||||
if (typeof start !== "number") return undefined
|
||||
@@ -364,13 +326,109 @@ export function SessionTurn(
|
||||
.filter((text): text is string => !!text)
|
||||
.at(-1),
|
||||
)
|
||||
const showThinking = createMemo(() => {
|
||||
const thinking = createMemo(() => {
|
||||
if (!working() || !!error()) return false
|
||||
if (queued()) return false
|
||||
if (status().type === "retry") return false
|
||||
if (showReasoningSummaries()) return assistantVisible() === 0
|
||||
return true
|
||||
})
|
||||
const hasAssistant = createMemo(() => assistantMessages().length > 0)
|
||||
const animateEnabled = createMemo(() => props.animate !== false)
|
||||
const [live, setLive] = createSignal(false)
|
||||
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
|
||||
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
|
||||
const duration = createMemo(() => {
|
||||
const ms = turnDurationMs()
|
||||
if (typeof ms !== "number" || ms < 0) return ""
|
||||
|
||||
const total = Math.round(ms / 1000)
|
||||
if (total < 60) return `${total}s`
|
||||
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
return `${minutes}m ${seconds}s`
|
||||
})
|
||||
const meta = createMemo(() => {
|
||||
const item = assistantCopyPart()
|
||||
if (!item) return ""
|
||||
|
||||
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
|
||||
const model = item.message.modelID
|
||||
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
|
||||
item.message.modelID
|
||||
]?.name ?? item.message.modelID)
|
||||
: ""
|
||||
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [handoffHold, setHandoffHold] = createSignal(false)
|
||||
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
|
||||
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
|
||||
const lane = createMemo(() => hasAssistant() || handoffOpen())
|
||||
|
||||
let liveFrame: number | undefined
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let handoffTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const copyAssistant = async () => {
|
||||
const text = assistantCopyPart()?.text
|
||||
if (!text) return
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(() => {
|
||||
copiedTimer = undefined
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [animateEnabled(), working()] as const,
|
||||
([enabled, isWorking]) => {
|
||||
if (liveFrame !== undefined) {
|
||||
cancelAnimationFrame(liveFrame)
|
||||
liveFrame = undefined
|
||||
}
|
||||
if (!enabled || !isWorking || live()) return
|
||||
liveFrame = requestAnimationFrame(() => {
|
||||
liveFrame = undefined
|
||||
setLive(true)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [thinkingOpen(), metaOpen()] as const,
|
||||
([thinkingNow, metaNow]) => {
|
||||
if (handoffTimer !== undefined) {
|
||||
clearTimeout(handoffTimer)
|
||||
handoffTimer = undefined
|
||||
}
|
||||
|
||||
if (thinkingNow) {
|
||||
setHandoffHold(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (metaNow) {
|
||||
setHandoffHold(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!handoffHold()) return
|
||||
handoffTimer = setTimeout(() => {
|
||||
handoffTimer = undefined
|
||||
setHandoffHold(false)
|
||||
}, handoffHoldMs)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working,
|
||||
@@ -378,6 +436,119 @@ export function SessionTurn(
|
||||
overflowAnchor: "dynamic",
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
|
||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
||||
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
|
||||
})
|
||||
|
||||
const turnDiffSummary = () => (
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "37px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
|
||||
const divider = (label: string) => (
|
||||
<div data-component="compaction-part">
|
||||
<div data-slot="compaction-part-divider">
|
||||
<span data-slot="compaction-part-line" />
|
||||
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
|
||||
{label}
|
||||
</span>
|
||||
<span data-slot="compaction-part-line" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div
|
||||
@@ -388,149 +559,120 @@ export function SessionTurn(
|
||||
>
|
||||
<div onClick={autoScroll.handleInteraction}>
|
||||
<Show when={message()}>
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={message()!.id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={compaction()!} message={message()!} hideDetails />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
{(msg) => (
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={msg().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<UserMessageDisplay
|
||||
message={msg()}
|
||||
parts={parts()}
|
||||
interrupted={interrupted()}
|
||||
animate={props.animate}
|
||||
queued={queued()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
{(part) => (
|
||||
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={part()} message={msg()} hideDetails />
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
</GrowBox>
|
||||
)}
|
||||
</Show>
|
||||
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
|
||||
<Show when={hasAssistant()}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={working()}
|
||||
style={{ contain: "layout paint" }}
|
||||
>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
showTurnDiffSummary={showDiffSummary()}
|
||||
turnDiffSummary={turnDiffSummary}
|
||||
working={working()}
|
||||
animate={live()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<GrowBox
|
||||
animate={live()}
|
||||
animateToggle={live()}
|
||||
open={handoffOpen()}
|
||||
fade
|
||||
slot="session-turn-handoff-wrap"
|
||||
>
|
||||
<div data-slot="session-turn-handoff">
|
||||
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<TextReveal
|
||||
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={900}
|
||||
/>
|
||||
</div>
|
||||
<Show when={metaOpen()}>
|
||||
<div
|
||||
data-slot="session-turn-meta"
|
||||
data-visible={thinkingVisible() ? "false" : "true"}
|
||||
data-interrupted={interrupted() ? "" : undefined}
|
||||
>
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void copyAssistant()}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={meta()}>
|
||||
<span
|
||||
data-slot="session-turn-meta-label"
|
||||
class="text-12-regular text-text-weak cursor-default"
|
||||
>
|
||||
{meta()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</GrowBox>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
|
||||
{divider(i18n.t("ui.message.interrupted"))}
|
||||
</GrowBox>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<GrowBox
|
||||
animate={props.animate !== false}
|
||||
fade
|
||||
gap={0}
|
||||
open={showDiffSummary() && !assistantCopyPartID()}
|
||||
>
|
||||
{turnDiffSummary()}
|
||||
</GrowBox>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
310
packages/ui/src/components/shell-rolling-results.tsx
Normal file
310
packages/ui/src/components/shell-rolling-results.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { RollingResults } from "./rolling-results"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { GROW_SPRING } from "./motion"
|
||||
import { useSpring } from "./motion-spring"
|
||||
import {
|
||||
busy,
|
||||
createThrottledValue,
|
||||
hold,
|
||||
updateScrollMask,
|
||||
useCollapsible,
|
||||
useRowWipe,
|
||||
useToolFade,
|
||||
} from "./tool-utils"
|
||||
|
||||
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
useToolFade(() => ref, { wipe: true, animate: props.animate })
|
||||
|
||||
return (
|
||||
<span data-slot="shell-rolling-subtitle">
|
||||
<span ref={ref}>{props.text}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function firstLine(text: string) {
|
||||
return text
|
||||
.split(/\r\n|\n|\r/g)
|
||||
.map((item) => item.trim())
|
||||
.find((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function shellRows(output: string) {
|
||||
const rows: { id: string; text: string }[] = []
|
||||
const lines = output
|
||||
.split(/\r\n|\n|\r/g)
|
||||
.map((item) => item.trimEnd())
|
||||
.filter((item) => item.length > 0)
|
||||
const start = Math.max(0, lines.length - 80)
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
rows.push({ id: `line:${i}`, text: lines[i]! })
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
useToolFade(() => ref, { wipe: true, animate: props.animate })
|
||||
|
||||
return (
|
||||
<div data-component="shell-rolling-command">
|
||||
<span ref={ref} data-slot="shell-rolling-text">
|
||||
<span data-slot="shell-rolling-prompt">$</span> {props.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const rows = 10
|
||||
const rowHeight = 22
|
||||
const max = rows * rowHeight
|
||||
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
let bodyRef: HTMLDivElement | undefined
|
||||
let scrollRef: HTMLDivElement | undefined
|
||||
let topRef: HTMLDivElement | undefined
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [cap, setCap] = createSignal(max)
|
||||
|
||||
const updateMask = () => {
|
||||
if (scrollRef) updateScrollMask(scrollRef)
|
||||
}
|
||||
|
||||
const resize = () => {
|
||||
const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
|
||||
setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
resize()
|
||||
return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resize()
|
||||
if (!topRef) return
|
||||
const obs = new ResizeObserver(resize)
|
||||
obs.observe(topRef)
|
||||
onCleanup(() => obs.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.cmd
|
||||
props.out
|
||||
queueMicrotask(() => {
|
||||
resize()
|
||||
updateMask()
|
||||
})
|
||||
})
|
||||
|
||||
useCollapsible({
|
||||
content: () => contentRef,
|
||||
body: () => bodyRef,
|
||||
open: () => props.open,
|
||||
measure,
|
||||
onOpen: updateMask,
|
||||
})
|
||||
|
||||
const handleCopy = async (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const cmd = props.cmd ? `$ ${props.cmd}` : ""
|
||||
const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
|
||||
if (!text) return
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
|
||||
<div ref={bodyRef} data-component="shell-expanded-shell">
|
||||
<div data-slot="shell-expanded-body">
|
||||
<div ref={topRef} data-slot="shell-expanded-top">
|
||||
<div data-slot="shell-expanded-command">
|
||||
<span data-slot="shell-expanded-prompt">$</span>
|
||||
<span data-slot="shell-expanded-input">{props.cmd}</span>
|
||||
</div>
|
||||
<div data-slot="shell-expanded-actions">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
class="shell-expanded-copy"
|
||||
onMouseDown={(e: MouseEvent) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={props.out}>
|
||||
<>
|
||||
<div data-slot="shell-expanded-divider" />
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-component="shell-expanded-output"
|
||||
data-scrollable
|
||||
onScroll={updateMask}
|
||||
style={{ "max-height": `${cap()}px` }}
|
||||
>
|
||||
<pre data-slot="shell-expanded-pre">
|
||||
<code>{props.out}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
|
||||
const i18n = useI18n()
|
||||
const wiped = new Set<string>()
|
||||
const [mounted, setMounted] = createSignal(false)
|
||||
const [userToggled, setUserToggled] = createSignal(false)
|
||||
const [userOpen, setUserOpen] = createSignal(false)
|
||||
onMount(() => setMounted(true))
|
||||
const state = createMemo(() => props.part.state as Record<string, any>)
|
||||
const pending = createMemo(() => busy(props.part.state.status))
|
||||
const autoOpen = hold(pending, 2000)
|
||||
const effectiveOpen = createMemo(() => {
|
||||
if (pending()) return true
|
||||
if (userToggled()) return userOpen()
|
||||
return autoOpen()
|
||||
})
|
||||
const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
|
||||
const previewOpen = createMemo(() => effectiveOpen() && !expanded())
|
||||
const command = createMemo(() => {
|
||||
const value = state().input?.command ?? state().metadata?.command
|
||||
if (typeof value === "string") return value
|
||||
return ""
|
||||
})
|
||||
const subtitle = createMemo(() => {
|
||||
const value = state().input?.description ?? state().metadata?.description
|
||||
if (typeof value === "string" && value.trim().length > 0) return value
|
||||
return firstLine(command()) ?? ""
|
||||
})
|
||||
const output = createMemo(() => {
|
||||
const value = state().output ?? state().metadata?.output
|
||||
if (typeof value === "string") return value
|
||||
return ""
|
||||
})
|
||||
const reduce = prefersReducedMotion
|
||||
const skip = () => reduce() || props.animate === false
|
||||
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
|
||||
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
|
||||
const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
|
||||
const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
|
||||
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
|
||||
let headerClipRef: HTMLDivElement | undefined
|
||||
const handleHeaderClick = () => {
|
||||
if (pending()) return
|
||||
const el = headerClipRef
|
||||
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
|
||||
const beforeY = el?.getBoundingClientRect().top ?? 0
|
||||
setUserToggled(true)
|
||||
setUserOpen((prev) => !prev)
|
||||
if (viewport && el) {
|
||||
requestAnimationFrame(() => {
|
||||
const afterY = el.getBoundingClientRect().top
|
||||
const delta = afterY - beforeY
|
||||
if (delta !== 0) viewport.scrollTop += delta
|
||||
})
|
||||
}
|
||||
}
|
||||
const line = createMemo(() => firstLine(command()))
|
||||
const fixed = createMemo(() => {
|
||||
const value = line()
|
||||
if (!value) return
|
||||
return <ShellRollingCommand text={value} animate={props.animate} />
|
||||
})
|
||||
const text = createThrottledValue(() => stripAnsi(output()))
|
||||
const rows = createMemo(() => shellRows(text()))
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="shell-rolling-results"
|
||||
style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
|
||||
>
|
||||
<div
|
||||
ref={headerClipRef}
|
||||
data-slot="shell-rolling-header-clip"
|
||||
data-scroll-preserve
|
||||
data-clickable={!pending() ? "true" : "false"}
|
||||
onClick={handleHeaderClick}
|
||||
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
|
||||
>
|
||||
<div data-slot="shell-rolling-header">
|
||||
<span data-slot="shell-rolling-title">
|
||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
||||
</span>
|
||||
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="shell-rolling-actions">
|
||||
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="shell-rolling-preview"
|
||||
style={{
|
||||
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
|
||||
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
|
||||
}}
|
||||
>
|
||||
<RollingResults
|
||||
class="shell-rolling-output"
|
||||
noFadeOnCollapse
|
||||
items={rows()}
|
||||
fixed={fixed()}
|
||||
fixedHeight={22}
|
||||
rows={5}
|
||||
rowHeight={22}
|
||||
rowGap={0}
|
||||
open={previewOpen()}
|
||||
animate={props.animate !== false}
|
||||
getKey={(row) => row.id}
|
||||
render={(row) => {
|
||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
||||
useRowWipe({
|
||||
id: () => row.id,
|
||||
text: () => row.text,
|
||||
ref: textRef,
|
||||
seen: wiped,
|
||||
})
|
||||
return (
|
||||
<div data-component="shell-rolling-row">
|
||||
<span ref={setTextRef} data-slot="shell-rolling-text">
|
||||
{row.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { BasicTool } from "./basic-tool"
|
||||
import { animate } from "motion"
|
||||
|
||||
export default {
|
||||
title: "UI/Shell Submessage Motion",
|
||||
id: "components-shell-submessage-motion",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Interactive playground for animating the Shell tool subtitle ("submessage") in the timeline trigger row.
|
||||
|
||||
### Production component path
|
||||
- Trigger layout: \`packages/ui/src/components/basic-tool.tsx\`
|
||||
- Bash tool subtitle source: \`packages/ui/src/components/message-part.tsx\` (tool: \`bash\`, \`trigger.subtitle\`)
|
||||
|
||||
### What this playground tunes
|
||||
- Width reveal (spring-driven pixel width via \`useSpring\`)
|
||||
- Opacity fade
|
||||
- Blur settle`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "6px 14px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "13px",
|
||||
}) as const
|
||||
|
||||
const sliderLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
"min-width": "84px",
|
||||
"flex-shrink": "0",
|
||||
"text-align": "right",
|
||||
}
|
||||
|
||||
const sliderValue = {
|
||||
"font-family": "monospace",
|
||||
"font-size": "11px",
|
||||
color: "var(--color-text-weak, #aaa)",
|
||||
"min-width": "76px",
|
||||
}
|
||||
|
||||
const shellCss = `
|
||||
[data-component="shell-submessage-scene"] [data-component="tool-trigger"] [data-slot="basic-tool-tool-info-main"] {
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
filter: blur(var(--shell-sub-blur, 2px));
|
||||
transition-property: opacity, filter;
|
||||
transition-duration: var(--shell-sub-fade-ms, 320ms);
|
||||
transition-timing-function: var(--shell-sub-fade-ease, cubic-bezier(0.22, 1, 0.36, 1));
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"][data-visible] [data-slot="shell-submessage-value"] {
|
||||
opacity: 1;
|
||||
filter: blur(0px);
|
||||
}
|
||||
`
|
||||
|
||||
const ease = {
|
||||
smooth: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
snappy: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
standard: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||
linear: "linear",
|
||||
}
|
||||
|
||||
function SpringSubmessage(props: { text: string; visible: boolean; visualDuration: number; bounce: number }) {
|
||||
let ref: HTMLSpanElement | undefined
|
||||
let widthRef: HTMLSpanElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (!widthRef) return
|
||||
if (props.visible) {
|
||||
requestAnimationFrame(() => {
|
||||
ref?.setAttribute("data-visible", "")
|
||||
animate(
|
||||
widthRef!,
|
||||
{ width: "auto" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
})
|
||||
} else {
|
||||
ref?.removeAttribute("data-visible")
|
||||
animate(
|
||||
widthRef,
|
||||
{ width: "0px" },
|
||||
{ type: "spring", visualDuration: props.visualDuration, bounce: props.bounce },
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<span ref={ref} data-component="shell-submessage">
|
||||
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: "0px" }}>
|
||||
<span data-slot="basic-tool-tool-subtitle">
|
||||
<span data-slot="shell-submessage-value">{props.text || "\u00A0"}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [text, setText] = createSignal("Prints five topic blocks between timed commands")
|
||||
const [show, setShow] = createSignal(true)
|
||||
const [visualDuration, setVisualDuration] = createSignal(0.35)
|
||||
const [bounce, setBounce] = createSignal(0)
|
||||
const [fadeMs, setFadeMs] = createSignal(320)
|
||||
const [blur, setBlur] = createSignal(2)
|
||||
const [fadeEase, setFadeEase] = createSignal<keyof typeof ease>("snappy")
|
||||
const [auto, setAuto] = createSignal(false)
|
||||
let replayTimer
|
||||
let autoTimer
|
||||
|
||||
const replay = () => {
|
||||
setShow(false)
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
replayTimer = setTimeout(() => {
|
||||
setShow(true)
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const stopAuto = () => {
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
autoTimer = undefined
|
||||
setAuto(false)
|
||||
}
|
||||
|
||||
const toggleAuto = () => {
|
||||
if (auto()) {
|
||||
stopAuto()
|
||||
return
|
||||
}
|
||||
setAuto(true)
|
||||
autoTimer = setInterval(replay, 2200)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (replayTimer) clearTimeout(replayTimer)
|
||||
if (autoTimer) clearInterval(autoTimer)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component="shell-submessage-scene"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "20px",
|
||||
padding: "20px",
|
||||
"max-width": "860px",
|
||||
"--shell-sub-fade-ms": `${fadeMs()}ms`,
|
||||
"--shell-sub-blur": `${blur()}px`,
|
||||
"--shell-sub-fade-ease": ease[fadeEase()],
|
||||
}}
|
||||
>
|
||||
<style>{shellCss}</style>
|
||||
|
||||
<BasicTool
|
||||
icon="console"
|
||||
defaultOpen
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
<span data-slot="basic-tool-tool-title">Shell</span>
|
||||
<SpringSubmessage text={text()} visible={show()} visualDuration={visualDuration()} bounce={bounce()} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
"border-radius": "8px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-secondary, #161616)",
|
||||
padding: "14px 16px",
|
||||
"font-family": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
"font-size": "18px",
|
||||
color: "var(--color-text, #eee)",
|
||||
"white-space": "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{"$ cat <<'TOPIC1'"}
|
||||
</div>
|
||||
</BasicTool>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={replay} style={btn()}>
|
||||
Replay entry
|
||||
</button>
|
||||
<button onClick={() => setShow((v) => !v)} style={btn(show())}>
|
||||
{show() ? "Hide subtitle" : "Show subtitle"}
|
||||
</button>
|
||||
<button onClick={toggleAuto} style={btn(auto())}>
|
||||
{auto() ? "Stop auto replay" : "Auto replay"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
"border-top": "1px solid var(--color-divider, #333)",
|
||||
"padding-top": "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>subtitle</span>
|
||||
<input
|
||||
value={text()}
|
||||
onInput={(e) => setText(e.currentTarget.value)}
|
||||
style={{
|
||||
width: "420px",
|
||||
"max-width": "100%",
|
||||
padding: "6px 8px",
|
||||
"border-radius": "6px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>visualDuration</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.05}
|
||||
max={1.5}
|
||||
step={0.01}
|
||||
value={visualDuration()}
|
||||
onInput={(e) => setVisualDuration(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{visualDuration().toFixed(2)}s</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={0.5}
|
||||
step={0.01}
|
||||
value={bounce()}
|
||||
onInput={(e) => setBounce(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{bounce().toFixed(2)}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade ease</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setFadeEase((v) =>
|
||||
v === "snappy" ? "smooth" : v === "smooth" ? "standard" : v === "standard" ? "linear" : "snappy",
|
||||
)
|
||||
}
|
||||
style={btn()}
|
||||
>
|
||||
{fadeEase()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>fade</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1400}
|
||||
step={10}
|
||||
value={fadeMs()}
|
||||
onInput={(e) => setFadeMs(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{fadeMs()}ms</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>blur</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={14}
|
||||
step={0.5}
|
||||
value={blur()}
|
||||
onInput={(e) => setBlur(Number(e.currentTarget.value))}
|
||||
/>
|
||||
<span style={sliderValue}>{blur()}px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
* Instead of sliding text through a fixed mask (odometer style),
|
||||
* the mask itself sweeps across each span to reveal/hide text.
|
||||
*
|
||||
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
|
||||
* Direction: bottom-to-top. New text rises in from below, old text exits upward.
|
||||
*
|
||||
* Entering: gradient reveals top-to-bottom (top of text appears first).
|
||||
* Entering: gradient reveals bottom-to-top (bottom of text appears first).
|
||||
* gradient(to bottom, white 33%, transparent 33%+edge)
|
||||
* pos 0 100% = transparent covers element = hidden
|
||||
* pos 0 0% = white covers element = visible
|
||||
*
|
||||
* Leaving: gradient hides top-to-bottom (top of text disappears first).
|
||||
* Leaving: gradient hides bottom-to-top (bottom of text disappears first).
|
||||
* gradient(to top, white 33%, transparent 33%+edge)
|
||||
* pos 0 100% = white covers element = visible
|
||||
* pos 0 0% = transparent covers element = hidden
|
||||
@@ -56,17 +56,17 @@
|
||||
transition-timing-function: var(--_spring);
|
||||
}
|
||||
|
||||
/* ── entering: reveal top-to-bottom ──
|
||||
* Gradient(to top): white at bottom, transparent at top of mask.
|
||||
* Settled pos 0 100% = white covers element = visible
|
||||
* Swap pos 0 0% = transparent covers = hidden
|
||||
* Slides from above: translateY(-travel) → translateY(0)
|
||||
/* ── entering: reveal bottom-to-top ──
|
||||
* Gradient(to bottom): white at top, transparent at bottom of mask.
|
||||
* Settled pos 0 0% = white covers element = visible
|
||||
* Swap pos 0 100% = transparent covers = hidden
|
||||
* Rises from below: translateY(travel) → translateY(0)
|
||||
*/
|
||||
[data-slot="text-reveal-entering"] {
|
||||
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
transition-property:
|
||||
mask-position,
|
||||
-webkit-mask-position,
|
||||
@@ -74,37 +74,37 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── leaving: hide top-to-bottom + slide downward ──
|
||||
* Gradient(to bottom): white at top, transparent at bottom of mask.
|
||||
* Swap pos 0 0% = white covers element = visible
|
||||
* Settled pos 0 100% = transparent covers = hidden
|
||||
* Slides down: translateY(0) → translateY(travel)
|
||||
/* ── leaving: hide bottom-to-top + slide upward ──
|
||||
* Gradient(to top): white at bottom, transparent at top of mask.
|
||||
* Swap pos 0 100% = white covers element = visible
|
||||
* Settled pos 0 0% = transparent covers = hidden
|
||||
* Slides up: translateY(0) → translateY(-travel)
|
||||
*/
|
||||
[data-slot="text-reveal-leaving"] {
|
||||
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
transition-property:
|
||||
mask-position,
|
||||
-webkit-mask-position,
|
||||
transform;
|
||||
transform: translateY(var(--_travel));
|
||||
transform: translateY(calc(var(--_travel) * -1));
|
||||
}
|
||||
|
||||
/* ── swapping: instant reset ──
|
||||
* Snap entering to hidden (above), leaving to visible (center).
|
||||
* Snap entering to hidden (below), leaving to visible (center).
|
||||
*/
|
||||
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
transform: translateY(calc(var(--_travel) * -1));
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
transform: translateY(var(--_travel));
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
|
||||
mask-position: 0 0%;
|
||||
-webkit-mask-position: 0 0%;
|
||||
mask-position: 0 100%;
|
||||
-webkit-mask-position: 0 100%;
|
||||
transform: translateY(0);
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
@@ -126,15 +126,14 @@
|
||||
&[data-truncate="true"] [data-slot="text-reveal-track"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&[data-truncate="true"] [data-slot="text-reveal-entering"],
|
||||
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
|
||||
export default {
|
||||
title: "UI/TextReveal",
|
||||
id: "components-text-reveal",
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component: `### Overview
|
||||
Playground for the TextReveal text transition component.
|
||||
|
||||
**Hybrid** — mask wipe + vertical slide: gradient sweeps AND text moves downward.
|
||||
|
||||
**Wipe only** — pure mask wipe: gradient sweeps top-to-bottom, text stays in place.`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const TEXTS = [
|
||||
"Refactor ToolStatusTitle DOM measurement",
|
||||
"Remove inline measure nodes",
|
||||
"Run typechecks and report changes",
|
||||
"Verify reduced-motion behavior",
|
||||
"Review diff for animation edge cases",
|
||||
"Check keyboard semantics",
|
||||
undefined,
|
||||
"Planning key generation details",
|
||||
"Analyzing error handling",
|
||||
"Considering edge cases",
|
||||
]
|
||||
|
||||
const btn = (accent?: boolean) =>
|
||||
({
|
||||
padding: "5px 12px",
|
||||
"border-radius": "6px",
|
||||
border: accent ? "1px solid var(--color-accent, #58f)" : "1px solid var(--color-divider, #333)",
|
||||
background: accent ? "var(--color-accent, #58f)" : "var(--color-fill-element, #222)",
|
||||
color: "var(--color-text, #eee)",
|
||||
cursor: "pointer",
|
||||
"font-size": "12px",
|
||||
}) as const
|
||||
|
||||
const sliderLabel = {
|
||||
width: "90px",
|
||||
"font-size": "12px",
|
||||
color: "var(--color-text-secondary, #a3a3a3)",
|
||||
"flex-shrink": "0",
|
||||
} as const
|
||||
|
||||
const cardStyle = {
|
||||
padding: "20px 24px",
|
||||
"border-radius": "10px",
|
||||
border: "1px solid var(--color-divider, #333)",
|
||||
background: "var(--color-fill-element, #1a1a1a)",
|
||||
display: "grid",
|
||||
gap: "12px",
|
||||
} as const
|
||||
|
||||
const cardLabel = {
|
||||
"font-size": "11px",
|
||||
"font-family": "monospace",
|
||||
color: "var(--color-text-weak, #666)",
|
||||
} as const
|
||||
|
||||
const previewRow = {
|
||||
display: "flex",
|
||||
"align-items": "center",
|
||||
gap: "8px",
|
||||
"font-size": "14px",
|
||||
"font-weight": "500",
|
||||
"line-height": "20px",
|
||||
color: "var(--text-weak, #aaa)",
|
||||
"min-height": "20px",
|
||||
overflow: "visible",
|
||||
} as const
|
||||
|
||||
const headingSlot = {
|
||||
"min-width": "0",
|
||||
overflow: "visible",
|
||||
color: "var(--text-weaker, #888)",
|
||||
"font-weight": "400",
|
||||
} as const
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const [index, setIndex] = createSignal(0)
|
||||
const [cycling, setCycling] = createSignal(false)
|
||||
const [growOnly, setGrowOnly] = createSignal(true)
|
||||
|
||||
const [duration, setDuration] = createSignal(600)
|
||||
const [bounce, setBounce] = createSignal(1.0)
|
||||
const [bounceSoft, setBounceSoft] = createSignal(1.0)
|
||||
|
||||
const [hybridTravel, setHybridTravel] = createSignal(25)
|
||||
const [hybridEdge, setHybridEdge] = createSignal(17)
|
||||
|
||||
const [edge, setEdge] = createSignal(17)
|
||||
const [revealTravel, setRevealTravel] = createSignal(0)
|
||||
|
||||
let timer: number | undefined
|
||||
const text = () => TEXTS[index()]
|
||||
const next = () => setIndex((i) => (i + 1) % TEXTS.length)
|
||||
const prev = () => setIndex((i) => (i - 1 + TEXTS.length) % TEXTS.length)
|
||||
|
||||
const toggleCycle = () => {
|
||||
if (cycling()) {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
setCycling(false)
|
||||
return
|
||||
}
|
||||
setCycling(true)
|
||||
const tick = () => {
|
||||
next()
|
||||
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
|
||||
}
|
||||
timer = window.setTimeout(tick, 700 + Math.floor(Math.random() * 600))
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
|
||||
const spring = () => `cubic-bezier(0.34, ${bounce()}, 0.64, 1)`
|
||||
const springSoft = () => `cubic-bezier(0.34, ${bounceSoft()}, 0.64, 1)`
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "24px", padding: "20px", "max-width": "700px" }}>
|
||||
<div style={{ display: "grid", gap: "16px" }}>
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>text-reveal (mask wipe + slide)</span>
|
||||
<div style={previewRow}>
|
||||
<span>Thinking</span>
|
||||
<span style={headingSlot}>
|
||||
<TextReveal
|
||||
class="text-14-regular"
|
||||
text={text()}
|
||||
duration={duration()}
|
||||
edge={hybridEdge()}
|
||||
travel={hybridTravel()}
|
||||
spring={spring()}
|
||||
springSoft={springSoft()}
|
||||
growOnly={growOnly()}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={cardStyle}>
|
||||
<span style={cardLabel}>text-reveal (mask wipe only)</span>
|
||||
<div style={previewRow}>
|
||||
<span>Thinking</span>
|
||||
<span style={headingSlot}>
|
||||
<TextReveal
|
||||
class="text-14-regular"
|
||||
text={text()}
|
||||
duration={duration()}
|
||||
edge={edge()}
|
||||
travel={revealTravel()}
|
||||
spring={spring()}
|
||||
springSoft={springSoft()}
|
||||
growOnly={growOnly()}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "6px", "flex-wrap": "wrap" }}>
|
||||
{TEXTS.map((t, i) => (
|
||||
<button onClick={() => setIndex(i)} style={btn(index() === i)}>
|
||||
{t ?? "(none)"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
|
||||
<button onClick={prev} style={btn()}>
|
||||
Prev
|
||||
</button>
|
||||
<button onClick={next} style={btn()}>
|
||||
Next
|
||||
</button>
|
||||
<button onClick={toggleCycle} style={btn(cycling())}>
|
||||
{cycling() ? "Stop cycle" : "Auto cycle"}
|
||||
</button>
|
||||
<button onClick={() => setGrowOnly((v) => !v)} style={btn(growOnly())}>
|
||||
{growOnly() ? "growOnly: on" : "growOnly: off"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "8px", "max-width": "480px" }}>
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)" }}>Hybrid (wipe + slide)</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>edge</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={hybridEdge()}
|
||||
onInput={(e) => setHybridEdge(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridEdge()}%</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>travel</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="40"
|
||||
step="1"
|
||||
value={hybridTravel()}
|
||||
onInput={(e) => setHybridTravel(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{hybridTravel()}px</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>Shared</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>duration</span>
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="1400"
|
||||
step="10"
|
||||
value={duration()}
|
||||
onInput={(e) => setDuration(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{duration()}ms</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="2"
|
||||
step="0.01"
|
||||
value={bounce()}
|
||||
onInput={(e) => setBounce(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounce().toFixed(2)}</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>bounce soft</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="1.5"
|
||||
step="0.01"
|
||||
value={bounceSoft()}
|
||||
onInput={(e) => setBounceSoft(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{bounceSoft().toFixed(2)}</span>
|
||||
</label>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #666)", "margin-top": "8px" }}>
|
||||
Wipe only
|
||||
</div>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>edge</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="40"
|
||||
step="1"
|
||||
value={edge()}
|
||||
onInput={(e) => setEdge(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{edge()}%</span>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "flex", "align-items": "center", gap: "12px" }}>
|
||||
<span style={sliderLabel}>travel</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="16"
|
||||
step="1"
|
||||
value={revealTravel()}
|
||||
onInput={(e) => setRevealTravel(e.currentTarget.valueAsNumber)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: "60px", "text-align": "right", "font-size": "12px" }}>{revealTravel()}px</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ "font-size": "11px", color: "var(--color-text-weak, #888)", "font-family": "monospace" }}>
|
||||
text: {text() ?? "(none)"} · growOnly: {growOnly() ? "on" : "off"}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user