diff options
| author | David Hill <[email protected]> | 2026-03-12 18:26:50 +0000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-12 18:26:50 +0000 |
| commit | 184732fc2097166921dd46fbb9a8ce433a96b237 (patch) | |
| tree | 6dd45cc5483f968fe4ad3c19456eb62c54b97b94 /packages/app/src/pages | |
| parent | b66222baf7a09af692e8de06179c1c3e51715269 (diff) | |
| download | opencode-184732fc2097166921dd46fbb9a8ce433a96b237.tar.gz opencode-184732fc2097166921dd46fbb9a8ce433a96b237.zip | |
fix(app): titlebar cleanup (#17206)
Diffstat (limited to 'packages/app/src/pages')
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 202 |
1 files changed, 196 insertions, 6 deletions
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 50f9b452a..ad4ef8443 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -11,15 +11,19 @@ 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 { useSessionKey } from "@/pages/session/session-layout" +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" @@ -215,12 +219,14 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const navigate = useNavigate() + const globalSDK = useGlobalSDK() const sdk = useSDK() const sync = useSync() const settings = useSettings() const dialog = useDialog() const language = useLanguage() const { params, sessionKey } = useSessionKey() + const platform = usePlatform() const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) const sessionID = createMemo(() => params.id) @@ -298,6 +304,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 } @@ -314,9 +322,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((err: unknown) => { + console.error("Failed to share session", err) + }) + .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((err: unknown) => { + console.error("Failed to unshare session", err) + }) + .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 @@ -329,7 +383,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 }, ), ) @@ -678,23 +740,42 @@ 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} icon="dot-grid" variant="ghost" class="size-6 rounded-md data-[expanded]:bg-surface-base-active" + classList={{ + "bg-surface-base-active": share.open || title.pendingShare, + }} aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen || share.open || title.pendingShare} + 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() + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + } }} > <DropdownMenu.Item @@ -705,6 +786,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> @@ -717,6 +809,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> |
