2
0
Эх сурвалжийг харах

feat(desktop): share sessions

Adam 2 сар өмнө
parent
commit
494e6fff01

+ 35 - 1
packages/desktop/src/components/header.tsx

@@ -1,27 +1,34 @@
 import { useGlobalSync } from "@/context/global-sync"
 import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
 import { useLayout } from "@/context/layout"
 import { useLayout } from "@/context/layout"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { Session } from "@opencode-ai/sdk/v2/client"
 import { Button } from "@opencode-ai/ui/button"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Mark } from "@opencode-ai/ui/logo"
 import { Mark } from "@opencode-ai/ui/logo"
+import { Popover } from "@opencode-ai/ui/popover"
 import { Select } from "@opencode-ai/ui/select"
 import { Select } from "@opencode-ai/ui/select"
+import { TextField } from "@opencode-ai/ui/text-field"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { getFilename } from "@opencode-ai/util/path"
 import { getFilename } from "@opencode-ai/util/path"
 import { A, useParams } from "@solidjs/router"
 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: {
 export function Header(props: {
   navigateToProject: (directory: string) => void
   navigateToProject: (directory: string) => void
   navigateToSession: (session: Session | undefined) => void
   navigateToSession: (session: Session | undefined) => void
 }) {
 }) {
   const globalSync = useGlobalSync()
   const globalSync = useGlobalSync()
+  const globalSDK = useGlobalSDK()
   const layout = useLayout()
   const layout = useLayout()
   const params = useParams()
   const params = useParams()
   const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
   const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
   const store = createMemo(() => globalSync.child(currentDirectory())[0])
   const store = createMemo(() => globalSync.child(currentDirectory())[0])
   const sessions = createMemo(() => store().session ?? [])
   const sessions = createMemo(() => store().session ?? [])
   const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
   const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+  const shareEnabled = createMemo(() => store().config.share !== "disabled")
 
 
   return (
   return (
     <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
     <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
@@ -105,6 +112,33 @@ export function Header(props: {
                 </div>
                 </div>
               </Button>
               </Button>
             </Tooltip>
             </Tooltip>
+            <Show when={shareEnabled() && currentSession()}>
+              <Popover
+                title="Share session"
+                trigger={
+                  <Tooltip class="shrink-0" value="Share session">
+                    <IconButton icon="share" variant="ghost" class="" />
+                  </Tooltip>
+                }
+              >
+                {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 <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
+                })}
+              </Popover>
+            </Show>
           </div>
           </div>
         </Show>
         </Show>
       </div>
       </div>

+ 2 - 24
packages/desktop/src/pages/layout.tsx

@@ -25,9 +25,8 @@ import {
   SortableProvider,
   SortableProvider,
   closestCenter,
   closestCenter,
   createSortable,
   createSortable,
-  useDragDropContext,
 } from "@thisbeyond/solid-dnd"
 } 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 { useProviders } from "@/hooks/use-providers"
 import { Toast } from "@opencode-ai/ui/toast"
 import { Toast } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSDK } from "@/context/global-sdk"
@@ -37,6 +36,7 @@ import { Header } from "@/components/header"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { useCommand } from "@/context/command"
 import { useCommand } from "@/context/command"
+import { ConstrainDragXAxis } from "@/utils/solid-dnd"
 
 
 export default function Layout(props: ParentProps) {
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
@@ -301,28 +301,6 @@ export default function Layout(props: ParentProps) {
     setStore("activeDraggable", undefined)
     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: {
   const ProjectAvatar = (props: {
     project: Project
     project: Project
     class?: string
     class?: string

+ 2 - 45
packages/desktop/src/pages/session.tsx

@@ -23,9 +23,8 @@ import {
   SortableProvider,
   SortableProvider,
   closestCenter,
   closestCenter,
   createSortable,
   createSortable,
-  useDragDropContext,
 } from "@thisbeyond/solid-dnd"
 } 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 type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 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 { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
 import { usePrompt } from "@/context/prompt"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { extractPromptFromParts } from "@/utils/prompt"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 
 
 export default function Page() {
 export default function Page() {
   const layout = useLayout()
   const layout = useLayout()
@@ -324,19 +324,6 @@ export default function Page() {
     if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
     if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
     if (dialog.active) 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
     const focused = document.activeElement === inputRef
     if (focused) {
     if (focused) {
       if (event.key === "Escape") inputRef?.blur()
       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)
   const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
 
 
   return (
   return (

+ 55 - 0
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 <></>
+}

+ 1 - 0
packages/ui/src/components/icon.tsx

@@ -52,6 +52,7 @@ const icons = {
   copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
   copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
   check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
   check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
   photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
   photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
+  share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
 }
 }
 
 
 export interface IconProps extends ComponentProps<"svg"> {
 export interface IconProps extends ComponentProps<"svg"> {

+ 95 - 0
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);
+  }
+}

+ 44 - 0
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<ComponentProps<typeof Kobalte>, "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 (
+    <Kobalte gutter={4} {...rest}>
+      <Kobalte.Trigger as="div" data-slot="popover-trigger">
+        {local.trigger}
+      </Kobalte.Trigger>
+      <Kobalte.Portal>
+        <Kobalte.Content
+          data-component="popover-content"
+          classList={{
+            ...(local.classList ?? {}),
+            [local.class ?? ""]: !!local.class,
+          }}
+        >
+          {/* <Kobalte.Arrow data-slot="popover-arrow" /> */}
+          <Show when={local.title}>
+            <div data-slot="popover-header">
+              <Kobalte.Title data-slot="popover-title">{local.title}</Kobalte.Title>
+              <Kobalte.CloseButton data-slot="popover-close-button" as={IconButton} icon="close" variant="ghost" />
+            </div>
+          </Show>
+          <Show when={local.description}>
+            <Kobalte.Description data-slot="popover-description">{local.description}</Kobalte.Description>
+          </Show>
+          <div data-slot="popover-body">{local.children}</div>
+        </Kobalte.Content>
+      </Kobalte.Portal>
+    </Kobalte>
+  )
+}

+ 5 - 5
packages/ui/src/components/text-field.tsx

@@ -56,6 +56,10 @@ export function TextField(props: TextFieldProps) {
     setTimeout(() => setCopied(false), 2000)
     setTimeout(() => setCopied(false), 2000)
   }
   }
 
 
+  function handleClick() {
+    if (local.copyable) handleCopy()
+  }
+
   return (
   return (
     <Kobalte
     <Kobalte
       data-component="input"
       data-component="input"
@@ -65,6 +69,7 @@ export function TextField(props: TextFieldProps) {
       value={local.value}
       value={local.value}
       onChange={local.onChange}
       onChange={local.onChange}
       onKeyDown={local.onKeyDown}
       onKeyDown={local.onKeyDown}
+      onClick={handleClick}
       required={local.required}
       required={local.required}
       disabled={local.disabled}
       disabled={local.disabled}
       readOnly={local.readOnly}
       readOnly={local.readOnly}
@@ -96,8 +101,3 @@ export function TextField(props: TextFieldProps) {
     </Kobalte>
     </Kobalte>
   )
   )
 }
 }
-
-/** @deprecated Use TextField instead */
-export const Input = TextField
-/** @deprecated Use TextFieldProps instead */
-export type InputProps = TextFieldProps

+ 1 - 0
packages/ui/src/styles/index.css

@@ -27,6 +27,7 @@
 @import "../components/markdown.css" layer(components);
 @import "../components/markdown.css" layer(components);
 @import "../components/message-part.css" layer(components);
 @import "../components/message-part.css" layer(components);
 @import "../components/message-nav.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/progress-circle.css" layer(components);
 @import "../components/resize-handle.css" layer(components);
 @import "../components/resize-handle.css" layer(components);
 @import "../components/select.css" layer(components);
 @import "../components/select.css" layer(components);