From 494e6fff019bb502fff88a07d6c519c063af9a02 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 17 Dec 2025 03:47:44 -0600 Subject: [PATCH] feat(desktop): share sessions --- packages/desktop/src/components/header.tsx | 36 +++++++- packages/desktop/src/pages/layout.tsx | 26 +----- packages/desktop/src/pages/session.tsx | 47 +---------- packages/desktop/src/utils/solid-dnd.tsx | 55 +++++++++++++ packages/ui/src/components/icon.tsx | 1 + packages/ui/src/components/popover.css | 95 ++++++++++++++++++++++ packages/ui/src/components/popover.tsx | 44 ++++++++++ packages/ui/src/components/text-field.tsx | 10 +-- packages/ui/src/styles/index.css | 1 + 9 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 packages/desktop/src/utils/solid-dnd.tsx create mode 100644 packages/ui/src/components/popover.css create mode 100644 packages/ui/src/components/popover.tsx diff --git a/packages/desktop/src/components/header.tsx b/packages/desktop/src/components/header.tsx index cc4d01816..d70bfee24 100644 --- a/packages/desktop/src/components/header.tsx +++ b/packages/desktop/src/components/header.tsx @@ -1,27 +1,34 @@ import { useGlobalSync } from "@/context/global-sync" +import { useGlobalSDK } from "@/context/global-sdk" import { useLayout } from "@/context/layout" import { Session } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" +import { Popover } from "@opencode-ai/ui/popover" import { Select } from "@opencode-ai/ui/select" +import { TextField } from "@opencode-ai/ui/text-field" import { Tooltip } from "@opencode-ai/ui/tooltip" import { base64Decode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { A, useParams } from "@solidjs/router" -import { createMemo, Show } from "solid-js" +import { createMemo, createResource, Show } from "solid-js" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { iife } from "@opencode-ai/util/iife" export function Header(props: { navigateToProject: (directory: string) => void navigateToSession: (session: Session | undefined) => void }) { const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const store = createMemo(() => globalSync.child(currentDirectory())[0]) const sessions = createMemo(() => store().session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + const shareEnabled = createMemo(() => store().config.share !== "disabled") return (
@@ -105,6 +112,33 @@ export function Header(props: { + + + + + } + > + {iife(() => { + const [url] = createResource( + () => currentSession(), + async (session) => { + if (!session) return + let shareURL = session.share?.url + if (!shareURL) { + shareURL = await globalSDK.client.session + .share({ sessionID: session.id, directory: currentDirectory() }) + .then((r) => r.data?.share?.url) + } + return shareURL + }, + ) + return {(url) => } + })} + + diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index bed8950c7..618b84840 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -25,9 +25,8 @@ import { SortableProvider, closestCenter, createSortable, - useDragDropContext, } from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" import { Toast } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" @@ -37,6 +36,7 @@ import { Header } from "@/components/header" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectProvider } from "@/components/dialog-select-provider" import { useCommand } from "@/context/command" +import { ConstrainDragXAxis } from "@/utils/solid-dnd" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -301,28 +301,6 @@ export default function Layout(props: ParentProps) { setStore("activeDraggable", undefined) } - const ConstrainDragXAxis = (): JSX.Element => { - const context = useDragDropContext() - if (!context) return <> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-x-axis", - order: 100, - callback: (transform) => ({ ...transform, x: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <> - } - const ProjectAvatar = (props: { project: Project class?: string diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d024d5047..3415d0c4e 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -23,9 +23,8 @@ import { SortableProvider, closestCenter, createSortable, - useDragDropContext, } from "@thisbeyond/solid-dnd" -import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" @@ -42,6 +41,7 @@ import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2" import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { extractPromptFromParts } from "@/utils/prompt" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" export default function Page() { const layout = useLayout() @@ -324,19 +324,6 @@ export default function Page() { if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return if (dialog.active) return - if (event.key === "PageUp" || event.key === "PageDown") { - const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement - if (scrollContainer) { - event.preventDefault() - const scrollAmount = scrollContainer.clientHeight * 0.8 - scrollContainer.scrollBy({ - top: event.key === "PageUp" ? -scrollAmount : scrollAmount, - behavior: "instant", - }) - } - return - } - const focused = document.activeElement === inputRef if (focused) { if (event.key === "Escape") inputRef?.blur() @@ -519,36 +506,6 @@ export default function Page() { ) } - const ConstrainDragYAxis = (): JSX.Element => { - const context = useDragDropContext() - if (!context) return <> - const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context - const transformer: Transformer = { - id: "constrain-y-axis", - order: 100, - callback: (transform) => ({ ...transform, y: 0 }), - } - onDragStart((event) => { - const id = getDraggableId(event) - if (!id) return - addTransformer("draggables", id, transformer) - }) - onDragEnd((event) => { - const id = getDraggableId(event) - if (!id) return - removeTransformer("draggables", id, transformer.id) - }) - return <> - } - - const getDraggableId = (event: unknown): string | undefined => { - if (typeof event !== "object" || event === null) return undefined - if (!("draggable" in event)) return undefined - const draggable = (event as { draggable?: { id?: unknown } }).draggable - if (!draggable) return undefined - return typeof draggable.id === "string" ? draggable.id : undefined - } - const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length) return ( diff --git a/packages/desktop/src/utils/solid-dnd.tsx b/packages/desktop/src/utils/solid-dnd.tsx new file mode 100644 index 000000000..a634be4b4 --- /dev/null +++ b/packages/desktop/src/utils/solid-dnd.tsx @@ -0,0 +1,55 @@ +import { useDragDropContext } from "@thisbeyond/solid-dnd" +import { JSXElement } from "solid-js" +import type { Transformer } from "@thisbeyond/solid-dnd" + +export const getDraggableId = (event: unknown): string | undefined => { + if (typeof event !== "object" || event === null) return undefined + if (!("draggable" in event)) return undefined + const draggable = (event as { draggable?: { id?: unknown } }).draggable + if (!draggable) return undefined + return typeof draggable.id === "string" ? draggable.id : undefined +} + +export const ConstrainDragXAxis = (): JSXElement => { + const context = useDragDropContext() + if (!context) return <> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-x-axis", + order: 100, + callback: (transform) => ({ ...transform, x: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <> +} + +export const ConstrainDragYAxis = (): JSXElement => { + const context = useDragDropContext() + if (!context) return <> + const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context + const transformer: Transformer = { + id: "constrain-y-axis", + order: 100, + callback: (transform) => ({ ...transform, y: 0 }), + } + onDragStart((event) => { + const id = getDraggableId(event) + if (!id) return + addTransformer("draggables", id, transformer) + }) + onDragEnd((event) => { + const id = getDraggableId(event) + if (!id) return + removeTransformer("draggables", id, transformer.id) + }) + return <> +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index b8e8106e8..94d9544d6 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -52,6 +52,7 @@ const icons = { copy: ``, check: ``, photo: ``, + share: ``, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css new file mode 100644 index 000000000..74d7f5a39 --- /dev/null +++ b/packages/ui/src/components/popover.css @@ -0,0 +1,95 @@ +[data-slot="popover-trigger"] { + display: inline-flex; +} + +[data-component="popover-content"] { + z-index: 50; + min-width: 200px; + max-width: 320px; + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background-color: var(--surface-raised-stronger-non-alpha); + box-shadow: var(--shadow-md); + transform-origin: var(--kb-popover-content-transform-origin); + + &:focus-within { + outline: none; + } + + &[data-closed] { + animation: popover-close 0.15s ease-out; + } + + &[data-expanded] { + animation: popover-open 0.15s ease-out; + } + + [data-slot="popover-header"] { + display: flex; + padding: 12px; + padding-bottom: 0; + justify-content: space-between; + align-items: center; + gap: 8px; + + [data-slot="popover-title"] { + flex: 1; + color: var(--text-strong); + margin: 0; + + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="popover-close-button"] { + flex-shrink: 0; + } + } + + [data-slot="popover-description"] { + padding: 0 12px; + margin: 0; + color: var(--text-base); + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="popover-body"] { + padding: 12px; + } + + [data-slot="popover-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes popover-open { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes popover-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/ui/src/components/popover.tsx b/packages/ui/src/components/popover.tsx new file mode 100644 index 000000000..3262098e5 --- /dev/null +++ b/packages/ui/src/components/popover.tsx @@ -0,0 +1,44 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { ComponentProps, JSXElement, ParentProps, Show, splitProps } from "solid-js" +import { IconButton } from "./icon-button" + +export interface PopoverProps extends ParentProps, Omit, "children"> { + trigger: JSXElement + title?: JSXElement + description?: JSXElement + class?: ComponentProps<"div">["class"] + classList?: ComponentProps<"div">["classList"] +} + +export function Popover(props: PopoverProps) { + const [local, rest] = splitProps(props, ["trigger", "title", "description", "class", "classList", "children"]) + + return ( + + + {local.trigger} + + + + {/* */} + +
+ {local.title} + +
+
+ + {local.description} + +
{local.children}
+
+
+
+ ) +} diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index 63ffb2594..77f014b6b 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -56,6 +56,10 @@ export function TextField(props: TextFieldProps) { setTimeout(() => setCopied(false), 2000) } + function handleClick() { + if (local.copyable) handleCopy() + } + return ( ) } - -/** @deprecated Use TextField instead */ -export const Input = TextField -/** @deprecated Use TextFieldProps instead */ -export type InputProps = TextFieldProps diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3f8838a7a..c4302a4d3 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -27,6 +27,7 @@ @import "../components/markdown.css" layer(components); @import "../components/message-part.css" layer(components); @import "../components/message-nav.css" layer(components); +@import "../components/popover.css" layer(components); @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components);