Просмотр исходного кода

Add share/unshare functionality to session management

paviko 1 месяц назад
Родитель
Сommit
56e2c546e1

+ 34 - 0
packages/opencode/webgui/src/components/CompactHeader/ActionButtons.tsx

@@ -7,6 +7,9 @@ interface ActionButtonsProps {
   onOpenSettings: () => void
   onNewSession: () => void
   isCreatingSession: boolean
+  isShared: boolean
+  isSharing: boolean
+  onToggleShare: () => void
 }
 
 export function ActionButtons({
@@ -16,6 +19,9 @@ export function ActionButtons({
   onOpenSettings,
   onNewSession,
   isCreatingSession,
+  isShared,
+  isSharing,
+  onToggleShare,
 }: ActionButtonsProps) {
   return (
     <div className="flex items-center gap-1">
@@ -96,6 +102,34 @@ export function ActionButtons({
           <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
         </svg>
       </button>
+
+      {/* Share/Unshare session button */}
+      <button
+        onClick={onToggleShare}
+        disabled={isSharing}
+        className="w-5 h-5 flex items-center justify-center text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
+        title={isShared ? "Unshare Session" : "Share Session"}
+      >
+        {isShared ? (
+          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
+            />
+          </svg>
+        ) : (
+          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
+            />
+          </svg>
+        )}
+      </button>
     </div>
   )
 }

+ 6 - 0
packages/opencode/webgui/src/components/CompactHeader/SessionDropdown.tsx

@@ -16,6 +16,7 @@ interface SessionDropdownProps {
   editInputRef: React.RefObject<HTMLInputElement | null>
   selectedSessionRef: React.RefObject<HTMLDivElement | null>
   sessionListRef: React.RefObject<HTMLDivElement | null>
+  sharingSessionId: string | null
   onSearchChange: (value: string) => void
   onSearchKeyDown: (e: React.KeyboardEvent) => void
   onToggleSelectMode: () => void
@@ -28,6 +29,7 @@ interface SessionDropdownProps {
   onBulkDeleteStart: () => void
   onCheckboxChange: (sessionId: string, checked: boolean) => void
   onKeyDown: (e: React.KeyboardEvent) => void
+  onToggleShare: (sessionId: string, e: React.MouseEvent) => void
 }
 
 export function SessionDropdown({
@@ -45,6 +47,7 @@ export function SessionDropdown({
   editInputRef,
   selectedSessionRef,
   sessionListRef,
+  sharingSessionId,
   onSearchChange,
   onSearchKeyDown,
   onToggleSelectMode,
@@ -57,6 +60,7 @@ export function SessionDropdown({
   onBulkDeleteStart,
   onCheckboxChange,
   onKeyDown,
+  onToggleShare,
 }: SessionDropdownProps) {
   if (!isDropdownOpen) return null
 
@@ -114,6 +118,7 @@ export function SessionDropdown({
         editInputRef={editInputRef}
         selectedSessionRef={selectedSessionRef}
         sessionListRef={sessionListRef}
+        sharingSessionId={sharingSessionId}
         onSessionSelect={onSessionSelect}
         onEditStart={onEditStart}
         onEditSave={onEditSave}
@@ -122,6 +127,7 @@ export function SessionDropdown({
         onDeleteStart={onDeleteStart}
         onCheckboxChange={onCheckboxChange}
         onKeyDown={onKeyDown}
+        onToggleShare={onToggleShare}
       />
 
       {/* Bulk delete button (shown in select mode when sessions are selected) */}

+ 67 - 1
packages/opencode/webgui/src/components/CompactHeader/SessionItem.tsx

@@ -1,10 +1,14 @@
 import { isDefaultTitle } from "../../state/SessionContext"
 import { formatTimestamp } from "./utils"
+import { ideBridge } from "../../lib/ideBridge"
 
 interface SessionItemProps {
   session: {
     id: string
     title: string | null
+    share?: {
+      url: string
+    }
     time: {
       created: number
     }
@@ -18,6 +22,7 @@ interface SessionItemProps {
   editingTitle: string
   editInputRef: React.RefObject<HTMLInputElement | null>
   selectedSessionRef: React.RefObject<HTMLDivElement | null>
+  isSharing: boolean
   onSelect: () => void
   onEditStart: (e: React.MouseEvent) => void
   onEditSave: () => void
@@ -26,6 +31,7 @@ interface SessionItemProps {
   onDeleteStart: (e: React.MouseEvent) => void
   onCheckboxChange: (checked: boolean) => void
   onKeyDown: (e: React.KeyboardEvent) => void
+  onToggleShare: (e: React.MouseEvent) => void
 }
 
 export function SessionItem({
@@ -39,6 +45,7 @@ export function SessionItem({
   editingTitle,
   editInputRef,
   selectedSessionRef,
+  isSharing,
   onSelect,
   onEditStart,
   onEditSave,
@@ -47,9 +54,22 @@ export function SessionItem({
   onDeleteStart,
   onCheckboxChange,
   onKeyDown,
+  onToggleShare,
 }: SessionItemProps) {
   const displayTitle = session.title || "Untitled"
   const hasDefaultTitle = isDefaultTitle(displayTitle)
+  const isShared = !!session.share?.url
+
+  const handleLinkClick = (e: React.MouseEvent) => {
+    e.stopPropagation()
+    if (session.share?.url) {
+      if (ideBridge.isInstalled()) {
+        ideBridge.send({ type: "openUrl", payload: { url: session.share.url } })
+      } else {
+        window.open(session.share.url, "_blank", "noopener,noreferrer")
+      }
+    }
+  }
 
   return (
     <div
@@ -132,8 +152,53 @@ export function SessionItem({
                 {formatTimestamp(session.time.created)}
               </span>
 
-              {/* Edit and Delete buttons (visible on hover or when active) */}
+              {/* Action buttons (visible on hover or when active) */}
               <div className={`${isActive ? "flex" : "hidden group-hover:flex"} items-center gap-1`}>
+                {/* Link button (only shown if shared) */}
+                {isShared && (
+                  <button
+                    onClick={handleLinkClick}
+                    className="p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
+                    title="Open share link"
+                  >
+                    <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
+                      />
+                    </svg>
+                  </button>
+                )}
+                {/* Share/Unshare button */}
+                <button
+                  onClick={onToggleShare}
+                  disabled={isSharing}
+                  className="p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50"
+                  title={isShared ? "Unshare session" : "Share session"}
+                >
+                  {isShared ? (
+                    <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
+                      />
+                    </svg>
+                  ) : (
+                    <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"
+                      />
+                    </svg>
+                  )}
+                </button>
+                {/* Edit button */}
                 <button
                   onClick={onEditStart}
                   className="p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
@@ -148,6 +213,7 @@ export function SessionItem({
                     />
                   </svg>
                 </button>
+                {/* Delete button */}
                 <button
                   onClick={onDeleteStart}
                   className="p-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"

+ 6 - 0
packages/opencode/webgui/src/components/CompactHeader/SessionList.tsx

@@ -13,6 +13,7 @@ interface SessionListProps {
   editInputRef: React.RefObject<HTMLInputElement | null>
   selectedSessionRef: React.RefObject<HTMLDivElement | null>
   sessionListRef: React.RefObject<HTMLDivElement | null>
+  sharingSessionId: string | null
   onSessionSelect: (sessionId: string) => void
   onEditStart: (sessionId: string, currentTitle: string, e: React.MouseEvent) => void
   onEditSave: (sessionId: string) => void
@@ -21,6 +22,7 @@ interface SessionListProps {
   onDeleteStart: (sessionId: string, e: React.MouseEvent) => void
   onCheckboxChange: (sessionId: string, checked: boolean) => void
   onKeyDown: (e: React.KeyboardEvent) => void
+  onToggleShare: (sessionId: string, e: React.MouseEvent) => void
 }
 
 export function SessionList({
@@ -35,6 +37,7 @@ export function SessionList({
   editInputRef,
   selectedSessionRef,
   sessionListRef,
+  sharingSessionId,
   onSessionSelect,
   onEditStart,
   onEditSave,
@@ -43,6 +46,7 @@ export function SessionList({
   onDeleteStart,
   onCheckboxChange,
   onKeyDown,
+  onToggleShare,
 }: SessionListProps) {
   if (filteredSessions.length === 0) {
     return (
@@ -72,6 +76,7 @@ export function SessionList({
             editingTitle={editingTitle}
             editInputRef={editInputRef}
             selectedSessionRef={selectedSessionRef}
+            isSharing={sharingSessionId === session.id}
             onSelect={() => onSessionSelect(session.id)}
             onEditStart={(e) => onEditStart(session.id, displayTitle, e)}
             onEditSave={() => onEditSave(session.id)}
@@ -80,6 +85,7 @@ export function SessionList({
             onDeleteStart={(e) => onDeleteStart(session.id, e)}
             onCheckboxChange={(checked) => onCheckboxChange(session.id, checked)}
             onKeyDown={onKeyDown}
+            onToggleShare={(e) => onToggleShare(session.id, e)}
           />
         )
       })}

+ 84 - 2
packages/opencode/webgui/src/components/CompactHeader/index.tsx

@@ -1,4 +1,4 @@
-import { useState, forwardRef, useImperativeHandle } from "react"
+import { useState, forwardRef, useImperativeHandle, useCallback } from "react"
 import type { ConnectionState } from "../../lib/api/events"
 import { useTheme } from "../../state/ThemeContext"
 import { useSession, isDefaultTitle } from "../../state/SessionContext"
@@ -11,6 +11,8 @@ import { StatusIndicator } from "./StatusIndicator"
 import { ActionButtons } from "./ActionButtons"
 import { UsageDisplay } from "./UsageDisplay"
 import { SessionDropdown } from "./SessionDropdown"
+import { sdk } from "../../lib/api/sdkClient"
+import { useToast } from "../../state/ToastContext"
 
 interface CompactHeaderProps {
   connectionState: ConnectionState
@@ -26,10 +28,85 @@ const CompactHeader = forwardRef<
   CompactHeaderProps
 >(({ connectionState, onNewSession, isCreatingSession, onOpenCommandPalette }, ref) => {
   const { theme, toggleTheme } = useTheme()
-  const { currentSession, sessions, switchSession, updateSessionTitle, deleteSession } = useSession()
+  const { currentSession, setCurrentSession, sessions, setSessions, switchSession, updateSessionTitle, deleteSession } =
+    useSession()
   const usage = useSessionUsage()
+  const toast = useToast()
 
   const [isSettingsOpen, setIsSettingsOpen] = useState(false)
+  const [isSharing, setIsSharing] = useState(false)
+  const [sharingSessionId, setSharingSessionId] = useState<string | null>(null)
+
+  const isShared = !!currentSession?.share?.url
+
+  const handleToggleShare = useCallback(async () => {
+    if (!currentSession || currentSession.id.startsWith("virtual-")) return
+
+    setIsSharing(true)
+    if (isShared) {
+      const res = await sdk.session.unshare({ path: { id: currentSession.id } })
+      if (res.data) {
+        setCurrentSession(res.data)
+        toast.showToast("Session unshared", { variant: "success" })
+      } else {
+        toast.showToast("Failed to unshare session", { variant: "error" })
+      }
+    } else {
+      const res = await sdk.session.share({ path: { id: currentSession.id } })
+      if (res.data) {
+        setCurrentSession(res.data)
+        if (res.data.share?.url) {
+          await navigator.clipboard.writeText(res.data.share.url)
+          toast.showToast("Share URL copied to clipboard", { variant: "success" })
+        }
+      } else {
+        toast.showToast("Failed to share session", { variant: "error" })
+      }
+    }
+    setIsSharing(false)
+  }, [currentSession, isShared, setCurrentSession, toast])
+
+  const handleToggleShareSession = useCallback(
+    async (sessionId: string, e: React.MouseEvent) => {
+      e.stopPropagation()
+      const session = sessions.find((s) => s.id === sessionId)
+      if (!session) return
+
+      setSharingSessionId(sessionId)
+      const sessionIsShared = !!session.share?.url
+
+      if (sessionIsShared) {
+        const res = await sdk.session.unshare({ path: { id: sessionId } })
+        if (res.data) {
+          // Update session in the list for immediate UI feedback
+          setSessions(sessions.map((s) => (s.id === sessionId ? res.data! : s)))
+          if (currentSession?.id === sessionId) {
+            setCurrentSession(res.data)
+          }
+          toast.showToast("Session unshared", { variant: "success" })
+        } else {
+          toast.showToast("Failed to unshare session", { variant: "error" })
+        }
+      } else {
+        const res = await sdk.session.share({ path: { id: sessionId } })
+        if (res.data) {
+          // Update session in the list for immediate UI feedback
+          setSessions(sessions.map((s) => (s.id === sessionId ? res.data! : s)))
+          if (currentSession?.id === sessionId) {
+            setCurrentSession(res.data)
+          }
+          if (res.data.share?.url) {
+            await navigator.clipboard.writeText(res.data.share.url)
+            toast.showToast("Share URL copied to clipboard", { variant: "success" })
+          }
+        } else {
+          toast.showToast("Failed to share session", { variant: "error" })
+        }
+      }
+      setSharingSessionId(null)
+    },
+    [sessions, currentSession, setCurrentSession, setSessions, toast],
+  )
 
   // Session dropdown management
   const dropdown = useSessionDropdown(sessions)
@@ -112,6 +189,7 @@ const CompactHeader = forwardRef<
             editInputRef={actions.editInputRef}
             selectedSessionRef={dropdown.selectedSessionRef}
             sessionListRef={dropdown.sessionListRef}
+            sharingSessionId={sharingSessionId}
             onSearchChange={dropdown.setSearchQuery}
             onSearchKeyDown={dropdown.handleSearchKeyDown}
             onToggleSelectMode={dropdown.toggleSelectMode}
@@ -124,6 +202,7 @@ const CompactHeader = forwardRef<
             onBulkDeleteStart={handleBulkDeleteStart}
             onCheckboxChange={dropdown.handleSessionCheckboxChange}
             onKeyDown={(e) => dropdown.handleKeyDown(e, handleSessionSelect)}
+            onToggleShare={handleToggleShareSession}
           />
         </div>
 
@@ -137,6 +216,9 @@ const CompactHeader = forwardRef<
             onOpenSettings={() => setIsSettingsOpen(true)}
             onNewSession={onNewSession}
             isCreatingSession={isCreatingSession}
+            isShared={isShared}
+            isSharing={isSharing}
+            onToggleShare={handleToggleShare}
           />
         </div>
       </header>