diff options
| author | Adam <[email protected]> | 2025-11-25 16:02:27 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-11-25 16:02:31 -0600 |
| commit | 447713244820e875e192a5815c4d8bc76c03b40f (patch) | |
| tree | a3df06b1df0585a8387c37826ea516ce08169289 /packages | |
| parent | eaeea45ace01e20405d49d1a659030a4d131dc83 (diff) | |
| download | opencode-447713244820e875e192a5815c4d8bc76c03b40f.tar.gz opencode-447713244820e875e192a5815c4d8bc76c03b40f.zip | |
fix: sanitize absolute paths
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/desktop/src/components/file-tree.tsx | 3 | ||||
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 32 | ||||
| -rw-r--r-- | packages/desktop/src/pages/directory-layout.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 3 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 3 | ||||
| -rw-r--r-- | packages/desktop/src/pages/session.tsx | 2 | ||||
| -rw-r--r-- | packages/desktop/src/ui/collapsible.tsx | 62 | ||||
| -rw-r--r-- | packages/desktop/src/ui/index.ts | 6 | ||||
| -rw-r--r-- | packages/desktop/src/utils/index.ts | 1 | ||||
| -rw-r--r-- | packages/desktop/src/utils/path.ts | 20 | ||||
| -rw-r--r-- | packages/enterprise/src/routes/share/[shareID].tsx | 403 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.css | 11 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 73 | ||||
| -rw-r--r-- | packages/ui/src/components/message-progress.tsx | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 6 | ||||
| -rw-r--r-- | packages/ui/src/context/data.tsx | 4 | ||||
| -rw-r--r-- | packages/util/src/sanitize.ts | 28 |
18 files changed, 309 insertions, 355 deletions
diff --git a/packages/desktop/src/components/file-tree.tsx b/packages/desktop/src/components/file-tree.tsx index f3729a8d3..0841c71d1 100644 --- a/packages/desktop/src/components/file-tree.tsx +++ b/packages/desktop/src/components/file-tree.tsx @@ -1,5 +1,5 @@ import { useLocal, type LocalFile } from "@/context/local" -import { Collapsible } from "@/ui" +import { Collapsible } from "@opencode-ai/ui/collapsible" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js" @@ -76,6 +76,7 @@ export default function FileTree(props: { <Switch> <Match when={node.type === "directory"}> <Collapsible + variant="ghost" class="w-full" forceMount={false} // open={local.file.node(node.path)?.expanded} diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 94d8ff882..976924223 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -1,7 +1,6 @@ import { useFilteredList } from "@opencode-ai/ui/hooks" import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js" import { createStore } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" import { createFocusSignal } from "@solid-primitives/active-element" import { useLocal } from "@/context/local" import { DateTime } from "luxon" @@ -16,6 +15,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Tooltip } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" +import { getDirectory, getFilename } from "@opencode-ai/util/path" interface PromptInputProps { class?: string diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 38058c370..3eb921a31 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,4 +1,3 @@ -import type { Part } from "@opencode-ai/sdk" import { produce } from "solid-js/store" import { createMemo } from "solid-js" import { Binary } from "@opencode-ai/util/binary" @@ -34,29 +33,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) - const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g")) - const sanitize = (text: string) => text.replace(sanitizer(), "") const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") - const sanitizePart = (part: Part) => { - if (part.type === "tool") { - if (part.state.status === "completed" || part.state.status === "error") { - for (const key in part.state.metadata) { - if (typeof part.state.metadata[key] === "string") { - part.state.metadata[key] = sanitize(part.state.metadata[key] as string) - } - } - for (const key in part.state.input) { - if (typeof part.state.input[key] === "string") { - part.state.input[key] = sanitize(part.state.input[key] as string) - } - } - if ("error" in part.state) { - part.state.error = sanitize(part.state.error as string) - } - } - } - return part - } return { data: store, @@ -88,10 +65,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .slice() .sort((a, b) => a.id.localeCompare(b.id)) for (const message of messages.data!) { - draft.part[message.info.id] = message.parts - .slice() - .map(sanitizePart) - .sort((a, b) => a.id.localeCompare(b.id)) + draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) } draft.session_diff[sessionID] = diff.data ?? [] }), @@ -105,7 +79,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, load, absolute, - sanitize, + get directory() { + return store.path.directory + }, } }, }) diff --git a/packages/desktop/src/pages/directory-layout.tsx b/packages/desktop/src/pages/directory-layout.tsx index 2fe750fda..de16eff30 100644 --- a/packages/desktop/src/pages/directory-layout.tsx +++ b/packages/desktop/src/pages/directory-layout.tsx @@ -21,7 +21,7 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() return ( - <DataProvider data={sync.data}> + <DataProvider data={sync.data} directory={directory()}> <LocalProvider>{props.children}</LocalProvider> </DataProvider> ) diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index e773fff57..58fcb20ce 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -1,8 +1,9 @@ import { useGlobalSync } from "@/context/global-sync" -import { base64Encode, getFilename } from "@/utils" +import { base64Encode } from "@/utils" import { For } from "solid-js" import { A } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" +import { getFilename } from "@opencode-ai/util/path" export default function Home() { const sync = useGlobalSync() diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index c9bb559d8..15180c885 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -3,7 +3,7 @@ import { DateTime } from "luxon" import { A, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Encode, getFilename } from "@/utils" +import { base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -11,6 +11,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { getFilename } from "@opencode-ai/util/path" export default function Layout(props: ParentProps) { const params = useParams() diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index 0eb0f37ce..40acac663 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,7 +1,6 @@ import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" -import { getDirectory, getFilename } from "@/utils" import { PromptInput } from "@/components/prompt-input" import { DateTime } from "luxon" import { FileIcon } from "@opencode-ai/ui/file-icon" @@ -30,6 +29,7 @@ import type { JSX } from "solid-js" import { useSync } from "@/context/sync" import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" +import { getDirectory, getFilename } from "@opencode-ai/util/path" export default function Page() { const layout = useLayout() diff --git a/packages/desktop/src/ui/collapsible.tsx b/packages/desktop/src/ui/collapsible.tsx deleted file mode 100644 index 5fbb6c7a4..000000000 --- a/packages/desktop/src/ui/collapsible.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Collapsible as KobalteCollapsible } from "@kobalte/core/collapsible" -import { Icon, IconProps } from "@opencode-ai/ui/icon" -import { splitProps } from "solid-js" -import type { ComponentProps, ParentProps } from "solid-js" - -export interface CollapsibleProps extends ComponentProps<typeof KobalteCollapsible> {} -export interface CollapsibleTriggerProps extends ComponentProps<typeof KobalteCollapsible.Trigger> {} -export interface CollapsibleContentProps extends ComponentProps<typeof KobalteCollapsible.Content> {} - -function CollapsibleRoot(props: CollapsibleProps) { - return <KobalteCollapsible forceMount {...props} /> -} - -function CollapsibleTrigger(props: CollapsibleTriggerProps) { - const [local, others] = splitProps(props, ["class"]) - return ( - <KobalteCollapsible.Trigger - classList={{ - "w-full group/collapsible": true, - [local.class ?? ""]: !!local.class, - }} - {...others} - /> - ) -} - -function CollapsibleContent(props: ParentProps<CollapsibleContentProps>) { - const [local, others] = splitProps(props, ["class", "children"]) - return ( - <KobalteCollapsible.Content - classList={{ - "h-0 overflow-hidden transition-all duration-100 ease-out": true, - "data-expanded:h-fit": true, - [local.class]: !!local.class, - }} - {...others} - > - {local.children} - </KobalteCollapsible.Content> - ) -} - -function CollapsibleArrow(props: Partial<IconProps>) { - const [local, others] = splitProps(props, ["class", "name"]) - return ( - <Icon - name={local.name ?? "chevron-right"} - classList={{ - "flex-none text-text-muted transition-transform duration-100": true, - "group-data-[expanded]/collapsible:rotate-90": true, - [local.class ?? ""]: !!local.class, - }} - {...others} - /> - ) -} - -export const Collapsible = Object.assign(CollapsibleRoot, { - Trigger: CollapsibleTrigger, - Content: CollapsibleContent, - Arrow: CollapsibleArrow, -}) diff --git a/packages/desktop/src/ui/index.ts b/packages/desktop/src/ui/index.ts deleted file mode 100644 index 8cbf0834f..000000000 --- a/packages/desktop/src/ui/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - Collapsible, - type CollapsibleProps, - type CollapsibleTriggerProps, - type CollapsibleContentProps, -} from "./collapsible" diff --git a/packages/desktop/src/utils/index.ts b/packages/desktop/src/utils/index.ts index 63a656cc4..e50efe837 100644 --- a/packages/desktop/src/utils/index.ts +++ b/packages/desktop/src/utils/index.ts @@ -1,3 +1,2 @@ -export * from "./path" export * from "./dom" export * from "./encode" diff --git a/packages/desktop/src/utils/path.ts b/packages/desktop/src/utils/path.ts deleted file mode 100644 index d23568ae6..000000000 --- a/packages/desktop/src/utils/path.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useSync } from "@/context/sync" - -export function getFilename(path: string) { - if (!path) return "" - const trimmed = path.replace(/[\/]+$/, "") - const parts = trimmed.split("/") - return parts[parts.length - 1] ?? "" -} - -export function getDirectory(path: string) { - const sync = useSync() - const parts = path.split("/") - const dir = parts.slice(0, parts.length - 1).join("/") - return dir ? sync.sanitize(dir + "/") : "" -} - -export function getFileExtension(path: string) { - const parts = path.split(".") - return parts[parts.length - 1] -} diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 292038094..42dd7e957 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -141,219 +141,226 @@ export default function () { }} > <Show when={data()}> - {(data) => ( - <DataProvider data={data()}> - {iife(() => { - const [store, setStore] = createStore({ - messageId: undefined as string | undefined, - }) - const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id)) - if (!match().found) throw new Error(`Session ${data().sessionID} not found`) - const info = createMemo(() => data().session[match().index]) - const messages = createMemo(() => - data().sessionID - ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( - (a, b) => b.time.created - a.time.created, - ) - : [], - ) - const firstUserMessage = createMemo(() => messages().at(0)) - const activeMessage = createMemo( - () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), - ) - function setActiveMessage(message: UserMessage | undefined) { - if (message) { - setStore("messageId", message.id) - } else { - setStore("messageId", undefined) + {(data) => { + const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id)) + if (!match().found) throw new Error(`Session ${data().sessionID} not found`) + const info = createMemo(() => data().session[match().index]) + + return ( + <DataProvider data={data()} directory={info().directory}> + {iife(() => { + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + }) + const messages = createMemo(() => + data().sessionID + ? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort( + (a, b) => b.time.created - a.time.created, + ) + : [], + ) + const firstUserMessage = createMemo(() => messages().at(0)) + const activeMessage = createMemo( + () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), + ) + function setActiveMessage(message: UserMessage | undefined) { + if (message) { + setStore("messageId", message.id) + } else { + setStore("messageId", undefined) + } } - } - const provider = createMemo(() => activeMessage()?.model?.providerID) - const modelID = createMemo(() => activeMessage()?.model?.modelID) - const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) - const diffs = createMemo(() => { - const diffs = data().session_diff[data().sessionID] ?? [] - const preloaded = data().session_diff_preload[data().sessionID] ?? [] - return diffs.map((diff) => ({ - ...diff, - preloaded: preloaded.find((d) => d.newFile.name === diff.file), - })) - }) + const provider = createMemo(() => activeMessage()?.model?.providerID) + const modelID = createMemo(() => activeMessage()?.model?.modelID) + const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID())) + const diffs = createMemo(() => { + const diffs = data().session_diff[data().sessionID] ?? [] + const preloaded = data().session_diff_preload[data().sessionID] ?? [] + return diffs.map((diff) => ({ + ...diff, + preloaded: preloaded.find((d) => d.newFile.name === diff.file), + })) + }) - const title = () => ( - <div class="flex flex-col gap-4 shrink-0"> - <div class="h-8 flex gap-4 items-center justify-start self-stretch"> - <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base"> - <Mark class="shrink-0 w-3 my-0.5" /> - <div class="text-12-mono text-text-base">v{info().version}</div> - </div> - <div class="flex gap-2 items-center"> - <img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" /> - <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div> - </div> - <div class="text-12-regular text-text-weaker"> - {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} + const title = () => ( + <div class="flex flex-col gap-4 shrink-0"> + <div class="h-8 flex gap-4 items-center justify-start self-stretch"> + <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base"> + <Mark class="shrink-0 w-3 my-0.5" /> + <div class="text-12-mono text-text-base">v{info().version}</div> + </div> + <div class="flex gap-2 items-center"> + <img src={`https://models.dev/logos/${provider()}.svg`} class="size-3.5 shrink-0 dark:invert" /> + <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div> + </div> + <div class="text-12-regular text-text-weaker"> + {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")} + </div> </div> + <div class="text-left text-16-medium text-text-strong">{info().title}</div> </div> - <div class="text-left text-16-medium text-text-strong">{info().title}</div> - </div> - ) + ) - const turns = () => ( - <div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar"> - {title()} - <div class="flex flex-col gap-15 items-start justify-start mt-4"> - <For each={messages()}> - {(message) => ( - <SessionTurn - sessionID={data().sessionID} - messageID={message.id} - classes={{ - root: "min-w-0 w-full relative", - content: - "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", - }} - /> - )} - </For> - </div> - <div class="flex items-center justify-center pt-20 pb-8 shrink-0"> - <Logo class="w-58.5 opacity-12" /> + const turns = () => ( + <div class="relative mt-2 pt-6 pb-8 px-4 min-w-0 w-full h-full overflow-y-auto no-scrollbar"> + {title()} + <div class="flex flex-col gap-15 items-start justify-start mt-4"> + <For each={messages()}> + {(message) => ( + <SessionTurn + sessionID={data().sessionID} + messageID={message.id} + classes={{ + root: "min-w-0 w-full relative", + content: + "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]", + }} + /> + )} + </For> + </div> + <div class="flex items-center justify-center pt-20 pb-8 shrink-0"> + <Logo class="w-58.5 opacity-12" /> + </div> </div> - </div> - ) + ) - const wide = createMemo(() => diffs().length === 0) + const wide = createMemo(() => diffs().length === 0) - return ( - <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col"> - <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base"> - <div class=""> - <a href="https://opencode.ai"> - <Mark /> - </a> - </div> - <div class="flex gap-3 items-center"> - <IconButton - as={"a"} - href="https://github.com/sst/opencode" - target="_blank" - icon="github" - variant="ghost" - /> - <IconButton - as={"a"} - href="https://opencode.ai/discord" - target="_blank" - icon="discord" - variant="ghost" - /> - </div> - </header> - <div class="select-text flex flex-col flex-1 min-h-0"> - <div class="hidden md:flex w-full flex-1 min-h-0"> - <div - classList={{ - "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true, - "px-21 @4xl:px-6 max-w-2xl": !wide(), - "px-6 max-w-2xl": wide(), - }} - > - {title()} - <div class="flex items-start justify-start h-full min-h-0"> - <Show when={messages().length > 1}> - <> - <div class="md:hidden absolute right-full"> - <MessageNav - class="mt-2 mr-3" - messages={messages()} - current={activeMessage()} - onMessageSelect={setActiveMessage} - size="compact" - /> - </div> - <div - classList={{ - "hidden md:block": true, - "absolute right-[90%]": !wide(), - "absolute right-full": wide(), - }} - > - <MessageNav + return ( + <div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col"> + <header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base"> + <div class=""> + <a href="https://opencode.ai"> + <Mark /> + </a> + </div> + <div class="flex gap-3 items-center"> + <IconButton + as={"a"} + href="https://github.com/sst/opencode" + target="_blank" + icon="github" + variant="ghost" + /> + <IconButton + as={"a"} + href="https://opencode.ai/discord" + target="_blank" + icon="discord" + variant="ghost" + /> + </div> + </header> + <div class="select-text flex flex-col flex-1 min-h-0"> + <div class="hidden md:flex w-full flex-1 min-h-0"> + <div + classList={{ + "@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full mx-auto": true, + "px-21 @4xl:px-6 max-w-2xl": !wide(), + "px-6 max-w-2xl": wide(), + }} + > + {title()} + <div class="flex items-start justify-start h-full min-h-0"> + <Show when={messages().length > 1}> + <> + <div class="md:hidden absolute right-full"> + <MessageNav + class="mt-2 mr-3" + messages={messages()} + current={activeMessage()} + onMessageSelect={setActiveMessage} + size="compact" + /> + </div> + <div classList={{ - "mt-2.5 mr-3": !wide(), - "mt-0.5 mr-8": wide(), + "hidden md:block": true, + "absolute right-[90%]": !wide(), + "absolute right-full": wide(), }} - messages={messages()} - current={activeMessage()} - onMessageSelect={setActiveMessage} - size={wide() ? "normal" : "compact"} - /> + > + <MessageNav + classList={{ + "mt-2.5 mr-3": !wide(), + "mt-0.5 mr-8": wide(), + }} + messages={messages()} + current={activeMessage()} + onMessageSelect={setActiveMessage} + size={wide() ? "normal" : "compact"} + /> + </div> + </> + </Show> + <SessionTurn + sessionID={data().sessionID} + messageID={store.messageId ?? firstUserMessage()!.id!} + classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }} + > + <div class="flex items-center justify-center pb-8 shrink-0"> + <Logo class="w-58.5 opacity-12" /> </div> - </> - </Show> - <SessionTurn - sessionID={data().sessionID} - messageID={store.messageId ?? firstUserMessage()!.id!} - classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }} - > - <div class="flex items-center justify-center pb-8 shrink-0"> - <Logo class="w-58.5 opacity-12" /> - </div> - </SessionTurn> + </SessionTurn> + </div> </div> + <Show when={diffs().length > 0}> + <div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base"> + <SessionReview + diffs={diffs()} + classes={{ + root: "pb-20", + header: "px-6", + container: "px-6", + }} + /> + </div> + </Show> </div> - <Show when={diffs().length > 0}> - <div class="relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base"> - <SessionReview - diffs={diffs()} - classes={{ - root: "pb-20", - header: "px-6", - container: "px-6", - }} - /> - </div> - </Show> + <Switch> + <Match when={diffs().length > 0}> + <Tabs class="md:hidden"> + <Tabs.List> + <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}> + Session + </Tabs.Trigger> + <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}> + 5 Files Changed + </Tabs.Trigger> + </Tabs.List> + <Tabs.Content value="session" class="!overflow-hidden"> + {turns()} + </Tabs.Content> + <Tabs.Content + forceMount + value="review" + class="!overflow-hidden hidden data-[selected]:block" + > + <div class="relative h-full pt-8 overflow-y-auto no-scrollbar"> + <SessionReview + diffs={diffs()} + classes={{ + root: "pb-20", + header: "px-4", + container: "px-4", + }} + /> + </div> + </Tabs.Content> + </Tabs> + </Match> + <Match when={true}> + <div class="md:hidden !overflow-hidden">{turns()}</div> + </Match> + </Switch> </div> - <Switch> - <Match when={diffs().length > 0}> - <Tabs class="md:hidden"> - <Tabs.List> - <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}> - Session - </Tabs.Trigger> - <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}> - 5 Files Changed - </Tabs.Trigger> - </Tabs.List> - <Tabs.Content value="session" class="!overflow-hidden"> - {turns()} - </Tabs.Content> - <Tabs.Content forceMount value="review" class="!overflow-hidden hidden data-[selected]:block"> - <div class="relative h-full pt-8 overflow-y-auto no-scrollbar"> - <SessionReview - diffs={diffs()} - classes={{ - root: "pb-20", - header: "px-4", - container: "px-4", - }} - /> - </div> - </Tabs.Content> - </Tabs> - </Match> - <Match when={true}> - <div class="md:hidden !overflow-hidden">{turns()}</div> - </Match> - </Switch> </div> - </div> - ) - })} - </DataProvider> - )} + ) + })} + </DataProvider> + ) + }} </Show> </ErrorBoundary> ) diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index dd6166112..0b1e8d490 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -63,6 +63,17 @@ [data-component="tool-output"] { white-space: pre; + padding: 8px 12px; + height: fit-content; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + + pre { + margin: 0; + padding: 0; + } } [data-component="edit-trigger"], diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index dd920f101..40740fa1f 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -16,35 +16,26 @@ import { Checkbox } from "./checkbox" import { Diff } from "./diff" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { sanitize, sanitizePart } from "@opencode-ai/util/sanitize" export interface MessageProps { message: MessageType parts: PartType[] + sanitize?: RegExp } export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean + sanitize?: RegExp } export type PartComponent = Component<MessagePartProps> export const PART_MAPPING: Record<string, PartComponent | undefined> = {} -function getFilename(path: string) { - if (!path) return "" - const trimmed = path.replace(/[\/]+$/, "") - const parts = trimmed.split("/") - return parts[parts.length - 1] ?? "" -} - -function getDirectory(path: string) { - const parts = path.split("/") - const dir = parts.slice(0, parts.length - 1).join("/") - return dir ? dir + "/" : "" -} - export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } @@ -57,21 +48,27 @@ export function Message(props: MessageProps) { </Match> <Match when={props.message.role === "assistant" && props.message}> {(assistantMessage) => ( - <AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} /> + <AssistantMessageDisplay + message={assistantMessage() as AssistantMessage} + parts={props.parts} + sanitize={props.sanitize} + /> )} </Match> </Switch> ) } -export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) { +export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) { const filteredParts = createMemo(() => { return props.parts?.filter((x) => { if (x.type === "reasoning") return false return x.type !== "tool" || (x as ToolPart).tool !== "todoread" }) }) - return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For> + return ( + <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For> + ) } export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) { @@ -88,7 +85,13 @@ export function Part(props: MessagePartProps) { const component = createMemo(() => PART_MAPPING[props.part.type]) return ( <Show when={component()}> - <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} /> + <Dynamic + component={component()} + part={props.part} + message={props.message} + hideDetails={props.hideDetails} + sanitize={props.sanitize} + /> </Show> ) } @@ -99,6 +102,7 @@ export interface ToolProps { tool: string output?: string hideDetails?: boolean + sanitize?: RegExp } export type ToolComponent = Component<ToolProps> @@ -166,6 +170,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { metadata={metadata} output={part.state.status === "completed" ? part.state.output : undefined} hideDetails={props.hideDetails} + sanitize={props.sanitize} /> </Match> </Switch> @@ -177,10 +182,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { PART_MAPPING["text"] = function TextPartDisplay(props) { const part = props.part as TextPart + const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(part, props.sanitize) as TextPart) : part)) return ( <Show when={part.text.trim()}> <div data-component="text-part"> - <Markdown text={part.text.trim()} /> + <Markdown text={sanitized().text.trim()} /> </div> </Show> ) @@ -205,7 +211,7 @@ ToolRegistry.register({ icon="glasses" trigger={{ title: "Read", - subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", + subtitle: props.input.filePath ? getFilename(sanitize(props.input.filePath, props.sanitize)) : "", }} /> ) @@ -216,9 +222,12 @@ ToolRegistry.register({ name: "list", render(props) { return ( - <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}> + <BasicTool + icon="bullet-list" + trigger={{ title: "List", subtitle: getDirectory(sanitize(props.input.path, props.sanitize) || "/") }} + > <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> + <div data-component="tool-output">{sanitize(props.output, props.sanitize)}</div> </Show> </BasicTool> ) @@ -321,12 +330,14 @@ ToolRegistry.register({ icon="console" trigger={{ title: "Shell", - subtitle: "Ran " + props.input.command, + subtitle: props.input.description, }} > - <Show when={false && props.output}> - <div data-component="tool-output">{props.output}</div> - </Show> + <div data-component="tool-output"> + <Markdown + text={`\`\`\`command\n$ ${sanitize(props.input.command, props.sanitize)}${props.output ? "\n\n" + props.output : ""}\n\`\`\``} + /> + </div> </BasicTool> ) }, @@ -344,9 +355,13 @@ ToolRegistry.register({ <div data-slot="message-part-title">Edit</div> <div data-slot="message-part-path"> <Show when={props.input.filePath?.includes("/")}> - <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> + <span data-slot="message-part-directory"> + {getDirectory(sanitize(props.input.filePath!, props.sanitize))} + </span> </Show> - <span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span> + <span data-slot="message-part-filename"> + {getFilename(sanitize(props.input.filePath ?? "", props.sanitize))} + </span> </div> </div> <div data-slot="message-part-actions"> @@ -361,11 +376,11 @@ ToolRegistry.register({ <div data-component="edit-content"> <Diff before={{ - name: getFilename(props.metadata.filediff.path), + name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)), contents: props.metadata.filediff.before, }} after={{ - name: getFilename(props.metadata.filediff.path), + name: getFilename(sanitize(props.metadata.filediff.path, props.sanitize)), contents: props.metadata.filediff.after, }} /> diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx index ca42d26ec..adb245ab4 100644 --- a/packages/ui/src/components/message-progress.tsx +++ b/packages/ui/src/components/message-progress.tsx @@ -6,6 +6,7 @@ import type { AssistantMessage as AssistantMessageType, ToolPart } from "@openco export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) { const data = useData() + const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined)) const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.part[m.id])) const done = createMemo(() => props.done ?? false) const currentTask = createMemo( @@ -152,7 +153,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa ) return ( <div data-slot="message-progress-item"> - <Part message={message()!} part={part} /> + <Part message={message()!} part={part} sanitize={sanitizer()} /> </div> ) }} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a7bd456a4..d146bae95 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -31,6 +31,7 @@ export function SessionTurn( const match = Binary.search(data.session, props.sessionID, (s) => s.id) if (!match.found) throw new Error(`Session ${props.sessionID} not found`) + const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined)) const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : [])) const userMessages = createMemo(() => messages() @@ -116,7 +117,7 @@ export function SessionTurn( </div> </div> <div data-slot="session-turn-message-content"> - <Message message={msg()} parts={parts()} /> + <Message message={msg()} parts={parts()} sanitize={sanitizer()} /> </div> {/* Summary */} <Show when={completed()}> @@ -222,10 +223,11 @@ export function SessionTurn( <Message message={assistantMessage} parts={parts().filter((p) => p?.id !== last()?.id)} + sanitize={sanitizer()} /> ) } - return <Message message={assistantMessage} parts={parts()} /> + return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} /> }} </For> <Show when={error()}> diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index c2766a5af..ad5a212ab 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -23,7 +23,7 @@ type Data = { export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", - init: (props: { data: Data }) => { - return props.data + init: (props: { data: Data; directory: string }) => { + return { ...props.data, directory: props.directory } }, }) diff --git a/packages/util/src/sanitize.ts b/packages/util/src/sanitize.ts new file mode 100644 index 000000000..892fe87cf --- /dev/null +++ b/packages/util/src/sanitize.ts @@ -0,0 +1,28 @@ +import { Part } from "@opencode-ai/sdk" + +export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? "" + +export const sanitizePart = (part: Part, remove: RegExp) => { + if (part.type === "text") { + part.text = sanitize(part.text, remove) + } else if (part.type === "reasoning") { + part.text = sanitize(part.text, remove) + } else if (part.type === "tool") { + if (part.state.status === "completed" || part.state.status === "error") { + for (const key in part.state.metadata) { + if (typeof part.state.metadata[key] === "string") { + part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove) + } + } + for (const key in part.state.input) { + if (typeof part.state.input[key] === "string") { + part.state.input[key] = sanitize(part.state.input[key] as string, remove) + } + } + if ("error" in part.state) { + part.state.error = sanitize(part.state.error as string, remove) + } + } + } + return part +} |
