Jelajahi Sumber

JetBrains: fixed not showing tooltips

paviko 3 minggu lalu
induk
melakukan
4d3cb8bdb9
33 mengubah file dengan 492 tambahan dan 157 penghapusan
  1. 1 0
      hosts/IDE_BRIDGE.md
  2. 1 25
      hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt
  3. 3 0
      hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt
  4. 2 3
      packages/opencode/webgui/src/components/AgentSelector.tsx
  5. 1 0
      packages/opencode/webgui/src/components/CodeBlock.tsx
  6. 2 0
      packages/opencode/webgui/src/components/CompactHeader/ActionButtons.tsx
  7. 1 0
      packages/opencode/webgui/src/components/CompactHeader/SessionDropdown.tsx
  8. 4 0
      packages/opencode/webgui/src/components/CompactHeader/SessionItem.tsx
  9. 3 1
      packages/opencode/webgui/src/components/CompactHeader/StatusIndicator.tsx
  10. 1 0
      packages/opencode/webgui/src/components/CompactHeader/UsageDisplay.tsx
  11. 1 0
      packages/opencode/webgui/src/components/CompactHeader/index.tsx
  12. 3 0
      packages/opencode/webgui/src/components/DiffModal/DiffHeader.tsx
  13. 1 0
      packages/opencode/webgui/src/components/DiffModal/DiffNavigation.tsx
  14. 73 70
      packages/opencode/webgui/src/components/FileChangesPanel.tsx
  15. 1 0
      packages/opencode/webgui/src/components/MessageInput/EditorToolbar.tsx
  16. 3 0
      packages/opencode/webgui/src/components/MessageInput/MessageActions.tsx
  17. 60 57
      packages/opencode/webgui/src/components/MessageList/ActionButtons.tsx
  18. 1 0
      packages/opencode/webgui/src/components/MessageList/MessageStats.tsx
  19. 2 1
      packages/opencode/webgui/src/components/ModelSelector.tsx
  20. 1 0
      packages/opencode/webgui/src/components/SettingsPanel/SettingsHeader.tsx
  21. 1 0
      packages/opencode/webgui/src/components/VariantSelector.tsx
  22. 1 0
      packages/opencode/webgui/src/components/attachment/AttachmentComponent.tsx
  23. 4 0
      packages/opencode/webgui/src/components/common/Button.tsx
  24. 4 0
      packages/opencode/webgui/src/components/common/IconButton.tsx
  25. 1 0
      packages/opencode/webgui/src/components/mention/MentionNode.tsx
  26. 1 0
      packages/opencode/webgui/src/components/parts/FilePart.tsx
  27. 2 0
      packages/opencode/webgui/src/components/parts/PatchPart.tsx
  28. 1 0
      packages/opencode/webgui/src/components/parts/ToolPart/ToolHeader.tsx
  29. 1 0
      packages/opencode/webgui/src/components/settings/ApiKeysTab/KeyInput.tsx
  30. 1 0
      packages/opencode/webgui/src/components/settings/ApiKeysTab/ProviderCard.tsx
  31. 30 0
      packages/opencode/webgui/src/index.css
  32. 278 0
      packages/opencode/webgui/src/lib/tooltipPolyfill.ts
  33. 2 0
      packages/opencode/webgui/src/main.tsx

+ 1 - 0
hosts/IDE_BRIDGE.md

@@ -151,6 +151,7 @@ if (ideBridge.isInstalled()) {
 - **insertPaths** — payload: `{ paths: string[] }`
 - **pastePath** — payload: `{ path: string }`
 - **updateOpenedFiles** — payload: `{ openedFiles: string[], currentFile: string | null }`
+- **setTooltipPolyfill** — payload: `{ enabled: boolean }` — JetBrains-only: enables CSS tooltip polyfill for `title` tooltips
 
 ### Web UI → JetBrains host (handled)
 

+ 1 - 25
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt

@@ -168,31 +168,7 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
                                             logger.warn("Failed to install IdeOpenFilesUpdater", e)
                                         }
 
-                                        // Immediate attempt to enable tooltip polyfill (redundant with load handler)
-                                        try {
-                                            val polyfillScriptEarly = """
-                                                (function(){
-                                                    try { 
-                                                        document.documentElement.classList.add('tip-polyfill'); 
-                                                    } catch(e){}
-                                                    try { 
-                                                        if (window.__setTooltipPolyfill) {
-                                                            window.__setTooltipPolyfill(true);
-                                                        }
-                                                    } catch(e){}
-                                                })();
-                                            """.trimIndent()
-                                            browser.cefBrowser.executeJavaScript(
-                                                polyfillScriptEarly,
-                                                browser.cefBrowser.url,
-                                                0
-                                            )
-                                        } catch (e: Exception) {
-                                            logger.debug(
-                                                "Early tooltip polyfill injection failed (will retry on load)",
-                                                e
-                                            )
-                                        }
+                                        // Tooltip polyfill is enabled via IdeBridge message.
                                     } catch (e: Exception) {
                                         logger.error("Failed to create browser component", e)
                                         mainPanel.removeAll()

+ 3 - 0
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -91,6 +91,9 @@ object IdeBridge {
                 state.ready = true
                 flushOutbox(project)
             }
+
+            // JetBrains JCEF does not reliably show native title tooltips; enable UI polyfill.
+            send(project, "setTooltipPolyfill", mapOf("enabled" to true))
         } catch (t: Throwable) {
             logger.warn("Failed to inject ideBridge", t)
         }

+ 2 - 3
packages/opencode/webgui/src/components/AgentSelector.tsx

@@ -33,9 +33,7 @@ export function AgentSelector({ selectedAgent, onSelect, disabled }: AgentSelect
 
         if (response.data) {
           // Filter agents to only show 'primary' and 'all' modes (not 'subagent') and hide 'hidden' agents
-          const filteredAgents = response.data.filter(
-            (agent: any) => agent.mode !== "subagent" && !agent.hidden,
-          )
+          const filteredAgents = response.data.filter((agent: any) => agent.mode !== "subagent" && !agent.hidden)
           setAgents(filteredAgents)
         }
       } catch (err) {
@@ -88,6 +86,7 @@ export function AgentSelector({ selectedAgent, onSelect, disabled }: AgentSelect
         disabled={disabled || isLoading}
         className="h-6 px-2 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
         title="Select agent"
+        data-tip="Select agent"
       >
         {getCurrentDisplay()}
         <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 1 - 0
packages/opencode/webgui/src/components/CodeBlock.tsx

@@ -72,6 +72,7 @@ export function CodeBlock({ language, value, inline = false }: CodeBlockProps) {
           onClick={handleCopy}
           className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors px-2 py-1 rounded hover:bg-gray-300/70 dark:hover:bg-gray-700/50"
           title={copied ? "Copied!" : "Copy code"}
+          data-tip={copied ? "Copied!" : "Copy code"}
         >
           {copied ? (
             <>

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

@@ -99,6 +99,7 @@ export function ActionButtons({
         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"}
+        data-tip={isShared ? "Unshare Session" : "Share Session"}
       >
         {isShared ? (
           <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -127,6 +128,7 @@ export function ActionButtons({
         disabled={isCreatingSession}
         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="New Session (Cmd/Ctrl+N)"
+        data-tip="New Session (Cmd/Ctrl+N)"
       >
         <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
           <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />

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

@@ -86,6 +86,7 @@ export function SessionDropdown({
                 : "bg-gray-100 text-gray-700 border-gray-300 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600"
             }`}
             title={isSelectMode ? "Cancel selection" : "Select multiple sessions"}
+            data-tip={isSelectMode ? "Cancel selection" : "Select multiple sessions"}
           >
             {isSelectMode ? (
               <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 4 - 0
packages/opencode/webgui/src/components/CompactHeader/SessionItem.tsx

@@ -160,6 +160,7 @@ export function SessionItem({
                     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"
+                    data-tip="Open share link"
                   >
                     <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                       <path
@@ -177,6 +178,7 @@ export function SessionItem({
                   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"}
+                  data-tip={isShared ? "Unshare session" : "Share session"}
                 >
                   {isShared ? (
                     <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -203,6 +205,7 @@ export function SessionItem({
                   onClick={onEditStart}
                   className="p-1 text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
                   title="Edit title"
+                  data-tip="Edit title"
                 >
                   <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                     <path
@@ -218,6 +221,7 @@ export function SessionItem({
                   onClick={onDeleteStart}
                   className="p-1 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400"
                   title="Delete session"
+                  data-tip="Delete session"
                 >
                   <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                     <path

+ 3 - 1
packages/opencode/webgui/src/components/CompactHeader/StatusIndicator.tsx

@@ -6,12 +6,14 @@ interface StatusIndicatorProps {
 }
 
 export function StatusIndicator({ connectionState }: StatusIndicatorProps) {
+  const tip = CONNECTION_TOOLTIPS[connectionState]
   return (
     <div
       className={`w-2 h-2 rounded-full ${CONNECTION_COLORS[connectionState]} ${
         connectionState === "connecting" || connectionState === "error" ? "animate-pulse" : ""
       }`}
-      title={CONNECTION_TOOLTIPS[connectionState]}
+      title={tip}
+      data-tip={tip}
     />
   )
 }

+ 1 - 0
packages/opencode/webgui/src/components/CompactHeader/UsageDisplay.tsx

@@ -32,6 +32,7 @@ export function UsageDisplay({ usage }: UsageDisplayProps) {
         onClick={() => setShowDetails((v) => !v)}
         className="flex items-center gap-1.5 group whitespace-nowrap overflow-hidden"
         title="Show usage details"
+        data-tip="Show usage details"
       >
         <div className="w-[80px] h-2.5 bg-gray-100 dark:bg-gray-800 rounded overflow-hidden relative">
           <div className={`${color} h-3`} style={{ width: `${pct}%` }} />

+ 1 - 0
packages/opencode/webgui/src/components/CompactHeader/index.tsx

@@ -163,6 +163,7 @@ const CompactHeader = forwardRef<
               currentHasDefaultTitle ? "text-gray-500 dark:text-gray-500 italic" : "text-gray-700 dark:text-gray-300"
             }`}
             title={currentTitle}
+            data-tip={currentTitle}
           >
             <span>{currentSession ? truncatedTitle : "No Session"}</span>
             <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 3 - 0
packages/opencode/webgui/src/components/DiffModal/DiffHeader.tsx

@@ -39,6 +39,7 @@ export function DiffHeader({ patchHash, viewMode, onViewModeChange, onClose, sho
                   : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
               }`}
               title="Split view"
+              data-tip="Split view"
             >
               Split
             </button>
@@ -50,6 +51,7 @@ export function DiffHeader({ patchHash, viewMode, onViewModeChange, onClose, sho
                   : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100"
               }`}
               title="Unified view"
+              data-tip="Unified view"
             >
               Unified
             </button>
@@ -61,6 +63,7 @@ export function DiffHeader({ patchHash, viewMode, onViewModeChange, onClose, sho
           onClick={onClose}
           className="p-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
           title="Close (Esc)"
+          data-tip="Close (Esc)"
         >
           <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
             <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

+ 1 - 0
packages/opencode/webgui/src/components/DiffModal/DiffNavigation.tsx

@@ -23,6 +23,7 @@ export function DiffNavigation({ diffs, selectedFile, onSelectFile }: DiffNaviga
               : "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-900"
           }`}
           title={diff.file}
+          data-tip={diff.file}
         >
           {diff.file.split("/").pop() || diff.file}
         </button>

+ 73 - 70
packages/opencode/webgui/src/components/FileChangesPanel.tsx

@@ -34,7 +34,7 @@ export function FileChangesPanel({ diffs = [], fallbackFiles = [] }: FileChanges
         sum.deletions += diff.deletions
         return sum
       },
-      { additions: 0, deletions: 0 }
+      { additions: 0, deletions: 0 },
     )
     return {
       modified: modifiedEntries,
@@ -53,77 +53,80 @@ export function FileChangesPanel({ diffs = [], fallbackFiles = [] }: FileChanges
     <div className="bg-gray-50 dark:bg-gray-900">
       {/* File list */}
       <div className="max-h-40 overflow-y-auto">
-          <div className="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap items-center gap-x-3 gap-y-1">
-            <span>
-              {mergedDiffs.length} file{mergedDiffs.length !== 1 ? "s" : ""}
-            </span>
-            <span>
-              {modified.length} modified • {deleted.length} deleted
-            </span>
-            {totalAdditions > 0 && <span className="text-green-600 dark:text-green-400">+{totalAdditions}</span>}
-            {totalDeletions > 0 && <span className="text-red-600 dark:text-red-400">-{totalDeletions}</span>}
-            <span className="text-gray-500 dark:text-gray-500">
-              net {netChange >= 0 ? "+" : ""}
-              {netChange}
-            </span>
+        <div className="px-3 py-1.5 text-xs text-gray-600 dark:text-gray-400 flex flex-wrap items-center gap-x-3 gap-y-1">
+          <span>
+            {mergedDiffs.length} file{mergedDiffs.length !== 1 ? "s" : ""}
+          </span>
+          <span>
+            {modified.length} modified • {deleted.length} deleted
+          </span>
+          {totalAdditions > 0 && <span className="text-green-600 dark:text-green-400">+{totalAdditions}</span>}
+          {totalDeletions > 0 && <span className="text-red-600 dark:text-red-400">-{totalDeletions}</span>}
+          <span className="text-gray-500 dark:text-gray-500">
+            net {netChange >= 0 ? "+" : ""}
+            {netChange}
+          </span>
+        </div>
+        {modified.length > 0 && (
+          <div className="px-3 py-1.5 flex flex-wrap items-center gap-1.5">
+            {modified.map((diff) => {
+              const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
+              const baseName = displayPath.split("/").pop() || displayPath
+              return (
+                <span
+                  key={diff.file}
+                  role="button"
+                  tabIndex={0}
+                  onClick={() => openFile({ path: diff.file, display: displayPath || diff.file })}
+                  onKeyDown={(e) => {
+                    if (e.key === "Enter" || e.key === " ") {
+                      e.preventDefault()
+                      openFile({ path: diff.file, display: displayPath || diff.file })
+                    }
+                  }}
+                  className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
+                  title={displayPath || diff.file}
+                  data-tip={displayPath || diff.file}
+                >
+                  {baseName}
+                  {diff.additions > 0 && (
+                    <span className="text-green-600 dark:text-green-400 text-[10px]">+{diff.additions}</span>
+                  )}
+                  {diff.deletions > 0 && (
+                    <span className="text-red-600 dark:text-red-400 text-[10px]">-{diff.deletions}</span>
+                  )}
+                </span>
+              )
+            })}
           </div>
-          {modified.length > 0 && (
-            <div className="px-3 py-1.5 flex flex-wrap items-center gap-1.5">
-              {modified.map((diff) => {
-                const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
-                const baseName = displayPath.split("/").pop() || displayPath
-                return (
-                  <span
-                    key={diff.file}
-                    role="button"
-                    tabIndex={0}
-                    onClick={() => openFile({ path: diff.file, display: displayPath || diff.file })}
-                    onKeyDown={(e) => {
-                      if (e.key === "Enter" || e.key === " ") {
-                        e.preventDefault()
-                        openFile({ path: diff.file, display: displayPath || diff.file })
-                      }
-                    }}
-                    className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/60"
-                    title={displayPath || diff.file}
-                  >
-                    {baseName}
-                    {diff.additions > 0 && (
-                      <span className="text-green-600 dark:text-green-400 text-[10px]">+{diff.additions}</span>
-                    )}
-                    {diff.deletions > 0 && (
-                      <span className="text-red-600 dark:text-red-400 text-[10px]">-{diff.deletions}</span>
-                    )}
-                  </span>
-                )
-              })}
-            </div>
-          )}
-
-          {deleted.length > 0 && (
-            <div className="border-t border-gray-200 dark:border-gray-800 px-3 py-1.5 flex flex-wrap items-center gap-1.5">
-              {deleted.map((diff) => {
-                const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
-                const baseName = displayPath.split("/").pop() || displayPath
-                return (
-                  <span
-                    key={diff.file}
-                    className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-gray-100 dark:bg-gray-800/50 text-gray-500 dark:text-gray-500 rounded line-through"
-                    title={displayPath || diff.file}
-                  >
-                    {baseName}
-                    {diff.additions > 0 && (
-                      <span className="text-green-600 dark:text-green-400 text-[10px] no-underline">+{diff.additions}</span>
-                    )}
-                    {diff.deletions > 0 && (
-                      <span className="text-red-600 dark:text-red-400 text-[10px] no-underline">-{diff.deletions}</span>
-                    )}
-                  </span>
-                )
-              })}
-            </div>
-          )}
+        )}
 
+        {deleted.length > 0 && (
+          <div className="border-t border-gray-200 dark:border-gray-800 px-3 py-1.5 flex flex-wrap items-center gap-1.5">
+            {deleted.map((diff) => {
+              const displayPath = toDisplayPath(diff.file, worktree) || normalizePath(diff.file)
+              const baseName = displayPath.split("/").pop() || displayPath
+              return (
+                <span
+                  key={diff.file}
+                  className="inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-mono bg-gray-100 dark:bg-gray-800/50 text-gray-500 dark:text-gray-500 rounded line-through"
+                  title={displayPath || diff.file}
+                  data-tip={displayPath || diff.file}
+                >
+                  {baseName}
+                  {diff.additions > 0 && (
+                    <span className="text-green-600 dark:text-green-400 text-[10px] no-underline">
+                      +{diff.additions}
+                    </span>
+                  )}
+                  {diff.deletions > 0 && (
+                    <span className="text-red-600 dark:text-red-400 text-[10px] no-underline">-{diff.deletions}</span>
+                  )}
+                </span>
+              )
+            })}
+          </div>
+        )}
       </div>
     </div>
   )

+ 1 - 0
packages/opencode/webgui/src/components/MessageInput/EditorToolbar.tsx

@@ -61,6 +61,7 @@ export function EditorToolbar({
             onClick={onRetry}
             className="h-6 px-2 flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 rounded border border-red-200 dark:border-red-800"
             title="Restore failed message"
+            data-tip="Restore failed message"
           >
             <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
               <path

+ 3 - 0
packages/opencode/webgui/src/components/MessageInput/MessageActions.tsx

@@ -25,6 +25,7 @@ export function MessageActions({
         onClick={onCompactClick}
         disabled={isCompactDisabled}
         title="Compact session history"
+        data-tip="Compact session history"
       >
         <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
           <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7h16M6 12h12M8 17h8" />
@@ -37,6 +38,7 @@ export function MessageActions({
           disabled={isButtonDisabled}
           className="h-6 w-6 flex items-center justify-center text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 disabled:opacity-30 disabled:cursor-not-allowed"
           title="Send (Cmd/Ctrl+Enter)"
+          data-tip="Send (Cmd/Ctrl+Enter)"
         >
           <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
             <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
@@ -47,6 +49,7 @@ export function MessageActions({
           onClick={onAbort}
           className="h-6 w-6 flex items-center justify-center text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
           title="Stop generation"
+          data-tip="Stop generation"
         >
           <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
             <rect x="6" y="6" width="12" height="12" rx="1" ry="1" />

+ 60 - 57
packages/opencode/webgui/src/components/MessageList/ActionButtons.tsx

@@ -75,73 +75,76 @@ export function ActionButtons({ onFork, onRevert, revertBusy, tokens, cost, isUs
       <div className="sticky top-1 h-0 w-full overflow-visible">
         <div className="absolute left-1/2 -translate-x-1/2 top-0 flex gap-0.5 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-px pointer-events-auto">
           {canCopy && (
-          <IconButton
-            onClick={handleCopy}
-            size="sm"
-            className="p-0.5"
-            aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
-            title={copied ? "Copied!" : "Copy to clipboard"}
-            icon={
-              copied ? (
+            <IconButton
+              onClick={handleCopy}
+              size="sm"
+              className="p-0.5"
+              aria-label={copied ? "Copied to clipboard" : "Copy to clipboard"}
+              title={copied ? "Copied!" : "Copy to clipboard"}
+              data-tip={copied ? "Copied!" : "Copy to clipboard"}
+              icon={
+                copied ? (
+                  <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+                  </svg>
+                ) : (
+                  <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
+                    />
+                  </svg>
+                )
+              }
+            />
+          )}
+
+          {isUser && onFork && (
+            <IconButton
+              onClick={onFork}
+              size="sm"
+              className="p-0.5"
+              aria-label="Fork session at this message"
+              title="Fork session at this message"
+              data-tip="Fork session at this message"
+              icon={
                 <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
+                  />
                 </svg>
-              ) : (
+              }
+            />
+          )}
+          {isUser && onRevert && (
+            <IconButton
+              onClick={onRevert}
+              size="sm"
+              className="p-0.5 hover:text-red-600 dark:hover:text-red-400"
+              disabled={revertBusy}
+              aria-label="Undo from this message (revert)"
+              title="Undo from this message (revert)"
+              data-tip="Undo from this message (revert)"
+              icon={
                 <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                   <path
                     strokeLinecap="round"
                     strokeLinejoin="round"
                     strokeWidth={2}
-                    d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
+                    d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
                   />
                 </svg>
-              )
-            }
-          />
-        )}
-
-        {isUser && onFork && (
-          <IconButton
-            onClick={onFork}
-            size="sm"
-            className="p-0.5"
-            aria-label="Fork session at this message"
-            title="Fork session at this message"
-            icon={
-              <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                <path
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                  strokeWidth={2}
-                  d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
-                />
-              </svg>
-            }
-          />
-        )}
-        {isUser && onRevert && (
-          <IconButton
-            onClick={onRevert}
-            size="sm"
-            className="p-0.5 hover:text-red-600 dark:hover:text-red-400"
-            disabled={revertBusy}
-            aria-label="Undo from this message (revert)"
-            title="Undo from this message (revert)"
-            icon={
-              <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                <path
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                  strokeWidth={2}
-                  d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
-                />
-              </svg>
-            }
-          />
-        )}
-        {hasTokens && tokens && typeof cost === "number" && <MessageStats tokens={tokens} cost={cost} />}
+              }
+            />
+          )}
+          {hasTokens && tokens && typeof cost === "number" && <MessageStats tokens={tokens} cost={cost} />}
+        </div>
       </div>
     </div>
-  </div>
   )
 }

+ 1 - 0
packages/opencode/webgui/src/components/MessageList/MessageStats.tsx

@@ -65,6 +65,7 @@ export function MessageStats({ tokens, cost }: MessageStatsProps) {
         className="modern-icon-button w-6 h-6 p-0.5 flex items-center justify-center"
         aria-label="Show token usage"
         title="Show token usage"
+        data-tip="Show token usage"
       >
         <div className="w-3 h-3">
           <svg className="w-full h-full" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 2 - 1
packages/opencode/webgui/src/components/ModelSelector.tsx

@@ -126,6 +126,7 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
         disabled={disabled || isLoading}
         className="h-6 px-2 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
         title="Select model"
+        data-tip="Select model"
       >
         {getCurrentDisplay()}
         <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -163,7 +164,7 @@ export function ModelSelector({ selectedProviderId, selectedModelId, onSelect, d
                     </div>
                     {filterRecent().map((item) => {
                       const isSelected = selectedProviderId === item.provider_id && selectedModelId === item.model_id
-                      
+
                       // Find model name from providers list
                       const provider = providers.find((p) => p.id === item.provider_id)
                       const modelName = provider?.models[item.model_id]?.name || item.model_id

+ 1 - 0
packages/opencode/webgui/src/components/SettingsPanel/SettingsHeader.tsx

@@ -13,6 +13,7 @@ export function SettingsHeader({ onClose }: SettingsHeaderProps) {
         size="md"
         aria-label="Close"
         title="Close"
+        data-tip="Close"
         icon={
           <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
             <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

+ 1 - 0
packages/opencode/webgui/src/components/VariantSelector.tsx

@@ -42,6 +42,7 @@ export function VariantSelector({
         disabled={isDisabled}
         className="h-6 px-2 text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800 rounded disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
         title="Select reasoning effort"
+        data-tip="Select reasoning effort"
       >
         {/* Sparkles icon */}
         <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 1 - 0
packages/opencode/webgui/src/components/attachment/AttachmentComponent.tsx

@@ -78,6 +78,7 @@ export function AttachmentComponent({ nodeKey, metadata }: AttachmentComponentPr
         onClick={handleRemove}
         className="ml-0.5 hover:bg-blue-200 dark:hover:bg-blue-800/50 rounded p-0.5"
         title="Remove attachment"
+        data-tip="Remove attachment"
       >
         <svg className="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
           <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

+ 4 - 0
packages/opencode/webgui/src/components/common/Button.tsx

@@ -64,10 +64,14 @@ export function Button({
   const variantClass = variantClasses[variant]
   const sizeClass = sizeClasses[size]
 
+  const title = typeof props.title === "string" ? props.title : undefined
+  const tip = typeof title === "string" && title.length > 0 ? title : undefined
+
   return (
     <button
       className={`modern-button ${variantClass} ${sizeClass} ${className}`}
       disabled={disabled || loading}
+      data-tip={tip}
       {...props}
     >
       {loading ? (

+ 4 - 0
packages/opencode/webgui/src/components/common/IconButton.tsx

@@ -46,10 +46,14 @@ export function IconButton({ size = "md", icon, className = "", "aria-label": ar
   const sizeClass = sizeClasses[size]
   const iconSizeClass = iconSizeClasses[size]
 
+  const title = typeof props.title === "string" ? props.title : undefined
+  const tip = typeof title === "string" && title.length > 0 ? title : undefined
+
   return (
     <button
       className={`modern-icon-button ${sizeClass} flex items-center justify-center ${className}`}
       aria-label={ariaLabel}
+      data-tip={tip}
       {...props}
     >
       <div className={iconSizeClass}>{icon}</div>

+ 1 - 0
packages/opencode/webgui/src/components/mention/MentionNode.tsx

@@ -199,6 +199,7 @@ function MentionComponent({ metadata }: MentionComponentProps) {
       role={isFileLike ? "button" : undefined}
       tabIndex={isFileLike ? 0 : undefined}
       title={metadata.path}
+      data-tip={metadata.path}
       onClick={handleActivate}
       onKeyDown={handleKeyDown}
     >

+ 1 - 0
packages/opencode/webgui/src/components/parts/FilePart.tsx

@@ -85,6 +85,7 @@ export function FilePart({ part }: FilePartProps) {
       onClick={handleOpen}
       onKeyDown={handleKeyDown}
       title={part.source?.path || part.filename}
+      data-tip={part.source?.path || part.filename}
     >
       {getFileIcon()}
       <span className="font-mono">{displayName}</span>

+ 2 - 0
packages/opencode/webgui/src/components/parts/PatchPart.tsx

@@ -80,6 +80,7 @@ export function PatchPart({ part, sessionID, messageID }: PatchPartProps) {
                 }}
                 className="underline decoration-dotted cursor-pointer hover:opacity-80"
                 title={single.display}
+                data-tip={single.display}
               >
                 {`Edited ${single.display}`}
               </span>
@@ -134,6 +135,7 @@ export function PatchPart({ part, sessionID, messageID }: PatchPartProps) {
                       }}
                       className="font-mono text-gray-700 dark:text-gray-300 underline decoration-dotted cursor-pointer hover:opacity-80"
                       title={entry.display}
+                      data-tip={entry.display}
                     >
                       {entry.display}
                     </span>

+ 1 - 0
packages/opencode/webgui/src/components/parts/ToolPart/ToolHeader.tsx

@@ -55,6 +55,7 @@ export function ToolHeader({ tool, status, toolName, filePath, isExpanded, onTog
             onKeyDown={handlePathKeyDown}
             className="underline decoration-dotted cursor-pointer hover:opacity-80"
             title={displayPath || filePath}
+            data-tip={displayPath || filePath}
           >
             {displayPath || filePath}
           </span>

+ 1 - 0
packages/opencode/webgui/src/components/settings/ApiKeysTab/KeyInput.tsx

@@ -20,6 +20,7 @@ export function KeyInput({ providerName, value, showKey, onValueChange, onToggle
         onClick={onToggleVisibility}
         className="absolute right-1 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded"
         title={showKey ? "Hide" : "Show"}
+        data-tip={showKey ? "Hide" : "Show"}
       >
         {showKey ? (
           <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">

+ 1 - 0
packages/opencode/webgui/src/components/settings/ApiKeysTab/ProviderCard.tsx

@@ -87,6 +87,7 @@ export function ProviderCard({
             onClick={(e) => onDelete(provider.id, e)}
             className="p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
             title="Remove provider"
+            data-tip="Remove provider"
           >
             <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
               <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />

+ 30 - 0
packages/opencode/webgui/src/index.css

@@ -10,6 +10,36 @@ body {
   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", sans-serif;
 }
 
+/* Tooltip polyfill (opt-in via .tip-polyfill on <html>) */
+.tip-polyfill [data-tip] {
+  /* anchor marker only; bubble is rendered into a single fixed #oc-tip */
+}
+
+.tip-polyfill #oc-tip {
+  position: fixed;
+  z-index: 100000;
+  pointer-events: none;
+  white-space: pre-wrap;
+
+  padding: 6px 8px;
+  border-radius: 8px;
+  font-size: 12px;
+  line-height: 1.2;
+  max-width: 320px;
+
+  color: #111827;
+  background: rgba(255, 255, 255, 0.98);
+  border: 1px solid rgba(17, 24, 39, 0.14);
+  box-shadow: 0 10px 25px rgba(0, 0, 0, 0.14);
+}
+
+.dark.tip-polyfill #oc-tip {
+  color: rgba(255, 255, 255, 0.92);
+  background: rgba(17, 24, 39, 0.96);
+  border: 1px solid rgba(255, 255, 255, 0.14);
+  box-shadow: 0 14px 30px rgba(0, 0, 0, 0.35);
+}
+
 * {
   box-sizing: border-box;
 }

+ 278 - 0
packages/opencode/webgui/src/lib/tooltipPolyfill.ts

@@ -0,0 +1,278 @@
+import { ideBridge } from "./ideBridge"
+
+const state = {
+  active: false,
+  observer: null as MutationObserver | null,
+  tip: null as HTMLDivElement | null,
+  target: null as HTMLElement | null,
+  cleanup: null as (() => void) | null,
+  timer: null as number | null,
+}
+
+function syncElement(el: Element) {
+  if (!(el instanceof HTMLElement)) return
+  const title = el.getAttribute("title")
+  if (!title) return
+  if (el.getAttribute("data-tip") === title) return
+  el.setAttribute("data-tip", title)
+}
+
+function syncAll() {
+  document.querySelectorAll("[title]").forEach(syncElement)
+}
+
+function getTipEl(): HTMLDivElement {
+  if (state.tip) return state.tip
+  const el = document.createElement("div")
+  el.id = "oc-tip"
+  el.setAttribute("role", "tooltip")
+  el.hidden = true
+  document.body.appendChild(el)
+  state.tip = el
+  return el
+}
+
+function clamp(value: number, min: number, max: number) {
+  if (value < min) return min
+  if (value > max) return max
+  return value
+}
+
+function isVisible(el: Element | null) {
+  if (!el) return false
+  if (!(el instanceof HTMLElement)) return false
+  const style = window.getComputedStyle(el)
+  if (style.display === "none") return false
+  if (style.visibility === "hidden") return false
+  return true
+}
+
+function findTarget(node: EventTarget | null): HTMLElement | null {
+  if (!(node instanceof Element)) return null
+  const el = node.closest?.("[data-tip]")
+  if (!el) return null
+  if (!(el instanceof HTMLElement)) return null
+  if (!isVisible(el)) return null
+  const tip = el.getAttribute("data-tip")
+  if (!tip || tip.trim().length === 0) return null
+  return el
+}
+
+function setText(el: HTMLDivElement, value: string) {
+  // Keep it text-only (no HTML injection) and preserve basic spacing.
+  el.textContent = value
+}
+
+function place(el: HTMLDivElement, anchor: HTMLElement) {
+  const rect = anchor.getBoundingClientRect()
+  const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
+  const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0)
+  const margin = 8
+
+  el.style.maxWidth = `min(320px, ${Math.max(0, vw - margin * 2)}px)`
+
+  // Measure after maxWidth applies.
+  el.style.left = "0px"
+  el.style.top = "0px"
+  const w = el.offsetWidth
+  const h = el.offsetHeight
+
+  const center = rect.left + rect.width / 2
+  const left = clamp(center - w / 2, margin, vw - margin - w)
+
+  const preferBelow = true
+  const belowTop = rect.bottom + 6
+  const aboveTop = rect.top - h - 6
+  const top = preferBelow
+    ? belowTop + h + margin <= vh
+      ? belowTop
+      : aboveTop >= margin
+        ? aboveTop
+        : Math.max(margin, belowTop)
+    : aboveTop >= margin
+      ? aboveTop
+      : belowTop + h + margin <= vh
+        ? belowTop
+        : Math.max(margin, belowTop)
+
+  el.style.left = `${Math.round(left)}px`
+  el.style.top = `${Math.round(top)}px`
+}
+
+function show(target: HTMLElement) {
+  const value = target.getAttribute("data-tip")
+  if (!value || value.trim().length === 0) return
+
+  const tip = getTipEl()
+  setText(tip, value)
+
+  // Prevent native tooltip from also firing (in case it works).
+  const title = target.getAttribute("title")
+  if (title && !target.hasAttribute("data-oc-title")) {
+    target.setAttribute("data-oc-title", title)
+    target.removeAttribute("title")
+  }
+
+  tip.hidden = false
+  place(tip, target)
+  state.target = target
+}
+
+function hide() {
+  if (state.timer != null) {
+    window.clearTimeout(state.timer)
+    state.timer = null
+  }
+
+  const tip = state.tip
+  if (tip) tip.hidden = true
+
+  const target = state.target
+  if (target) {
+    const title = target.getAttribute("data-oc-title")
+    if (title != null) {
+      target.setAttribute("title", title)
+      target.removeAttribute("data-oc-title")
+    }
+  }
+  state.target = null
+}
+
+function installRuntime() {
+  if (state.cleanup) return
+
+  const scheduleShow = (next: HTMLElement) => {
+    if (state.timer != null) window.clearTimeout(state.timer)
+    state.timer = window.setTimeout(() => {
+      state.timer = null
+      if (!state.active) return
+      if (state.target !== next) return
+      show(next)
+    }, 500)
+  }
+
+  const onOver = (ev: Event) => {
+    if (!state.active) return
+    const next = findTarget(ev.target)
+    if (!next) return
+    if (state.target === next) return
+    state.target = next
+    scheduleShow(next)
+  }
+
+  const onOut = (ev: MouseEvent) => {
+    if (!state.active) return
+    const current = state.target
+    if (!current) return
+
+    const related = ev.relatedTarget
+    if (related instanceof Node && current.contains(related)) return
+
+    const next = findTarget(related)
+    if (next) {
+      state.target = next
+      scheduleShow(next)
+      return
+    }
+    hide()
+  }
+
+  const onFocusIn = (ev: FocusEvent) => {
+    if (!state.active) return
+    const next = findTarget(ev.target)
+    if (!next) return
+    state.target = next
+    scheduleShow(next)
+  }
+
+  const onFocusOut = (ev: FocusEvent) => {
+    if (!state.active) return
+    const next = findTarget(ev.relatedTarget)
+    if (next) {
+      state.target = next
+      scheduleShow(next)
+      return
+    }
+    hide()
+  }
+
+  const onScrollResize = () => {
+    if (!state.active) return
+    const tip = state.tip
+    const target = state.target
+    if (!tip || tip.hidden) return
+    if (!target) return
+    place(tip, target)
+  }
+
+  document.addEventListener("mouseover", onOver, true)
+  document.addEventListener("mouseout", onOut, true)
+  document.addEventListener("focusin", onFocusIn, true)
+  document.addEventListener("focusout", onFocusOut, true)
+  window.addEventListener("scroll", onScrollResize, true)
+  window.addEventListener("resize", onScrollResize)
+
+  state.cleanup = () => {
+    document.removeEventListener("mouseover", onOver, true)
+    document.removeEventListener("mouseout", onOut, true)
+    document.removeEventListener("focusin", onFocusIn, true)
+    document.removeEventListener("focusout", onFocusOut, true)
+    window.removeEventListener("scroll", onScrollResize, true)
+    window.removeEventListener("resize", onScrollResize)
+    if (state.timer != null) {
+      window.clearTimeout(state.timer)
+      state.timer = null
+    }
+  }
+}
+
+export function setTooltipPolyfill(enabled: boolean) {
+  if (typeof document === "undefined") return
+
+  state.active = enabled
+  document.documentElement.classList.toggle("tip-polyfill", enabled)
+
+  if (!enabled) {
+    state.observer?.disconnect()
+    state.observer = null
+    state.cleanup?.()
+    state.cleanup = null
+    hide()
+    return
+  }
+
+  syncAll()
+  installRuntime()
+
+  if (state.observer) return
+  state.observer = new MutationObserver((mutations) => {
+    if (!state.active) return
+    for (const m of mutations) {
+      if (m.type === "attributes") {
+        if (m.attributeName === "title") syncElement(m.target as Element)
+        continue
+      }
+      for (const n of m.addedNodes) {
+        if (!(n instanceof Element)) continue
+        if (n.hasAttribute("title")) syncElement(n)
+        n.querySelectorAll?.("[title]").forEach(syncElement)
+      }
+    }
+  })
+
+  state.observer.observe(document.documentElement, {
+    subtree: true,
+    childList: true,
+    attributes: true,
+    attributeFilter: ["title"],
+  })
+}
+
+export function installTooltipPolyfillBridge() {
+  ideBridge.on((msg) => {
+    if (!msg || typeof msg !== "object") return
+    if (msg.type !== "setTooltipPolyfill") return
+    const enabled = msg.payload?.enabled
+    setTooltipPolyfill(enabled === true)
+  })
+}

+ 2 - 0
packages/opencode/webgui/src/main.tsx

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client"
 import "./index.css"
 import App from "./App.tsx"
 import { ideBridge } from "./lib/ideBridge"
+import { installTooltipPolyfillBridge } from "./lib/tooltipPolyfill"
 import { SessionProvider } from "./state/SessionContext.tsx"
 import { ToastProvider } from "./state/ToastContext.tsx"
 import { ErrorBoundary } from "./components/ErrorBoundary.tsx"
@@ -12,6 +13,7 @@ import { ProvidersProvider } from "./state/ProvidersContext"
 import { initGlobalDnD } from "./lib/dnd"
 
 ideBridge.init()
+installTooltipPolyfillBridge()
 initGlobalDnD()
 
 createRoot(document.getElementById("root")!).render(