|
|
@@ -11,14 +11,18 @@ import { InlineInput } from "@opencode-ai/ui/inline-input"
|
|
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
|
|
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
|
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
|
|
+import { TextField } from "@opencode-ai/ui/text-field"
|
|
|
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 { Popover as KobaltePopover } from "@kobalte/core/popover"
|
|
|
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 { useGlobalSDK } from "@/context/global-sdk"
|
|
|
+import { usePlatform } from "@/context/platform"
|
|
|
import { useSettings } from "@/context/settings"
|
|
|
import { useSDK } from "@/context/sdk"
|
|
|
import { useSync } from "@/context/sync"
|
|
|
@@ -209,11 +213,13 @@ export function MessageTimeline(props: {
|
|
|
|
|
|
const params = useParams()
|
|
|
const navigate = useNavigate()
|
|
|
+ const globalSDK = useGlobalSDK()
|
|
|
const sdk = useSDK()
|
|
|
const sync = useSync()
|
|
|
const settings = useSettings()
|
|
|
const dialog = useDialog()
|
|
|
const language = useLanguage()
|
|
|
+ const platform = usePlatform()
|
|
|
|
|
|
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
|
|
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
|
@@ -292,6 +298,8 @@ export function MessageTimeline(props: {
|
|
|
return sync.session.get(id)
|
|
|
})
|
|
|
const titleValue = createMemo(() => info()?.title)
|
|
|
+ const shareUrl = createMemo(() => info()?.share?.url)
|
|
|
+ const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
|
|
const parentID = createMemo(() => info()?.parentID)
|
|
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
|
|
const stageCfg = { init: 1, batch: 3 }
|
|
|
@@ -308,9 +316,55 @@ export function MessageTimeline(props: {
|
|
|
saving: false,
|
|
|
menuOpen: false,
|
|
|
pendingRename: false,
|
|
|
+ pendingShare: false,
|
|
|
})
|
|
|
let titleRef: HTMLInputElement | undefined
|
|
|
|
|
|
+ const [share, setShare] = createStore({
|
|
|
+ open: false,
|
|
|
+ dismiss: null as "escape" | "outside" | null,
|
|
|
+ })
|
|
|
+
|
|
|
+ let more: HTMLButtonElement | undefined
|
|
|
+
|
|
|
+ const [req, setReq] = createStore({ share: false, unshare: false })
|
|
|
+
|
|
|
+ const shareSession = () => {
|
|
|
+ const id = sessionID()
|
|
|
+ if (!id || req.share) return
|
|
|
+ if (!shareEnabled()) return
|
|
|
+ setReq("share", true)
|
|
|
+ globalSDK.client.session
|
|
|
+ .share({ sessionID: id, directory: sdk.directory })
|
|
|
+ .catch((error) => {
|
|
|
+ console.error("Failed to share session", error)
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ setReq("share", false)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const unshareSession = () => {
|
|
|
+ const id = sessionID()
|
|
|
+ if (!id || req.unshare) return
|
|
|
+ if (!shareEnabled()) return
|
|
|
+ setReq("unshare", true)
|
|
|
+ globalSDK.client.session
|
|
|
+ .unshare({ sessionID: id, directory: sdk.directory })
|
|
|
+ .catch((error) => {
|
|
|
+ console.error("Failed to unshare session", error)
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ setReq("unshare", false)
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const viewShare = () => {
|
|
|
+ const url = shareUrl()
|
|
|
+ if (!url) return
|
|
|
+ platform.openLink(url)
|
|
|
+ }
|
|
|
+
|
|
|
const errorMessage = (err: unknown) => {
|
|
|
if (err && typeof err === "object" && "data" in err) {
|
|
|
const data = (err as { data?: { message?: string } }).data
|
|
|
@@ -323,7 +377,15 @@ export function MessageTimeline(props: {
|
|
|
createEffect(
|
|
|
on(
|
|
|
sessionKey,
|
|
|
- () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
|
|
+ () =>
|
|
|
+ setTitle({
|
|
|
+ draft: "",
|
|
|
+ editing: false,
|
|
|
+ saving: false,
|
|
|
+ menuOpen: false,
|
|
|
+ pendingRename: false,
|
|
|
+ pendingShare: false,
|
|
|
+ }),
|
|
|
{ defer: true },
|
|
|
),
|
|
|
)
|
|
|
@@ -672,7 +734,10 @@ export function MessageTimeline(props: {
|
|
|
gutter={4}
|
|
|
placement="bottom-end"
|
|
|
open={title.menuOpen}
|
|
|
- onOpenChange={(open) => setTitle("menuOpen", open)}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ setTitle("menuOpen", open)
|
|
|
+ if (open) return
|
|
|
+ }}
|
|
|
>
|
|
|
<DropdownMenu.Trigger
|
|
|
as={IconButton}
|
|
|
@@ -680,15 +745,25 @@ export function MessageTimeline(props: {
|
|
|
variant="ghost"
|
|
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
|
aria-label={language.t("common.moreOptions")}
|
|
|
+ ref={(el: HTMLButtonElement) => {
|
|
|
+ more = el
|
|
|
+ }}
|
|
|
/>
|
|
|
<DropdownMenu.Portal>
|
|
|
<DropdownMenu.Content
|
|
|
style={{ "min-width": "104px" }}
|
|
|
onCloseAutoFocus={(event) => {
|
|
|
- if (!title.pendingRename) return
|
|
|
- event.preventDefault()
|
|
|
- setTitle("pendingRename", false)
|
|
|
- openTitleEditor()
|
|
|
+ if (title.pendingRename) {
|
|
|
+ event.preventDefault()
|
|
|
+ setTitle("pendingRename", false)
|
|
|
+ openTitleEditor()
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (title.pendingShare) {
|
|
|
+ event.preventDefault()
|
|
|
+ setTitle("pendingShare", false)
|
|
|
+ requestAnimationFrame(() => setShare({ open: true, dismiss: null }))
|
|
|
+ }
|
|
|
}}
|
|
|
>
|
|
|
<DropdownMenu.Item
|
|
|
@@ -699,6 +774,17 @@ export function MessageTimeline(props: {
|
|
|
>
|
|
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
|
</DropdownMenu.Item>
|
|
|
+ <Show when={shareEnabled()}>
|
|
|
+ <DropdownMenu.Item
|
|
|
+ onSelect={() => {
|
|
|
+ setTitle({ pendingShare: true, menuOpen: false })
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <DropdownMenu.ItemLabel>
|
|
|
+ {language.t("session.share.action.share")}
|
|
|
+ </DropdownMenu.ItemLabel>
|
|
|
+ </DropdownMenu.Item>
|
|
|
+ </Show>
|
|
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
|
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
|
|
</DropdownMenu.Item>
|
|
|
@@ -711,6 +797,104 @@ export function MessageTimeline(props: {
|
|
|
</DropdownMenu.Content>
|
|
|
</DropdownMenu.Portal>
|
|
|
</DropdownMenu>
|
|
|
+
|
|
|
+ <KobaltePopover
|
|
|
+ open={share.open}
|
|
|
+ anchorRef={() => more}
|
|
|
+ placement="bottom-end"
|
|
|
+ gutter={4}
|
|
|
+ modal={false}
|
|
|
+ onOpenChange={(open) => {
|
|
|
+ if (open) setShare("dismiss", null)
|
|
|
+ setShare("open", open)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <KobaltePopover.Portal>
|
|
|
+ <KobaltePopover.Content
|
|
|
+ data-component="popover-content"
|
|
|
+ style={{ "min-width": "320px" }}
|
|
|
+ onEscapeKeyDown={(event) => {
|
|
|
+ setShare({ dismiss: "escape", open: false })
|
|
|
+ event.preventDefault()
|
|
|
+ event.stopPropagation()
|
|
|
+ }}
|
|
|
+ onPointerDownOutside={() => {
|
|
|
+ setShare({ dismiss: "outside", open: false })
|
|
|
+ }}
|
|
|
+ onFocusOutside={() => {
|
|
|
+ setShare({ dismiss: "outside", open: false })
|
|
|
+ }}
|
|
|
+ onCloseAutoFocus={(event) => {
|
|
|
+ if (share.dismiss === "outside") event.preventDefault()
|
|
|
+ setShare("dismiss", null)
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div class="flex flex-col p-3">
|
|
|
+ <div class="flex flex-col gap-1">
|
|
|
+ <div class="text-13-medium text-text-strong">
|
|
|
+ {language.t("session.share.popover.title")}
|
|
|
+ </div>
|
|
|
+ <div class="text-12-regular text-text-weak">
|
|
|
+ {shareUrl()
|
|
|
+ ? language.t("session.share.popover.description.shared")
|
|
|
+ : language.t("session.share.popover.description.unshared")}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="mt-3 flex flex-col gap-2">
|
|
|
+ <Show
|
|
|
+ when={shareUrl()}
|
|
|
+ fallback={
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="primary"
|
|
|
+ class="w-full"
|
|
|
+ onClick={shareSession}
|
|
|
+ disabled={req.share}
|
|
|
+ >
|
|
|
+ {req.share
|
|
|
+ ? language.t("session.share.action.publishing")
|
|
|
+ : language.t("session.share.action.publish")}
|
|
|
+ </Button>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <div class="flex flex-col gap-2">
|
|
|
+ <TextField
|
|
|
+ value={shareUrl() ?? ""}
|
|
|
+ readOnly
|
|
|
+ copyable
|
|
|
+ copyKind="link"
|
|
|
+ tabIndex={-1}
|
|
|
+ class="w-full"
|
|
|
+ />
|
|
|
+ <div class="grid grid-cols-2 gap-2">
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="secondary"
|
|
|
+ class="w-full shadow-none border border-border-weak-base"
|
|
|
+ onClick={unshareSession}
|
|
|
+ disabled={req.unshare}
|
|
|
+ >
|
|
|
+ {req.unshare
|
|
|
+ ? language.t("session.share.action.unpublishing")
|
|
|
+ : language.t("session.share.action.unpublish")}
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="large"
|
|
|
+ variant="primary"
|
|
|
+ class="w-full"
|
|
|
+ onClick={viewShare}
|
|
|
+ disabled={req.unshare}
|
|
|
+ >
|
|
|
+ {language.t("session.share.action.view")}
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </KobaltePopover.Content>
|
|
|
+ </KobaltePopover.Portal>
|
|
|
+ </KobaltePopover>
|
|
|
</div>
|
|
|
)}
|
|
|
</Show>
|