├── public └── preview.png ├── extension ├── icons │ ├── icon128.png │ ├── icon16.png │ ├── icon32.png │ └── icon48.png ├── manifest.json ├── popup.js ├── popup.css ├── popup.html ├── styles-chatgpt.css ├── styles-deepseek.css ├── styles-gemini.css ├── content-gemini.js └── content-deepseek.js ├── LICENSE ├── README.zh-CN.md ├── README.md └── .github └── workflows └── release.yml /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reborn14/chatgpt-conversation-timeline/HEAD/public/preview.png -------------------------------------------------------------------------------- /extension/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reborn14/chatgpt-conversation-timeline/HEAD/extension/icons/icon128.png -------------------------------------------------------------------------------- /extension/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reborn14/chatgpt-conversation-timeline/HEAD/extension/icons/icon16.png -------------------------------------------------------------------------------- /extension/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reborn14/chatgpt-conversation-timeline/HEAD/extension/icons/icon32.png -------------------------------------------------------------------------------- /extension/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Reborn14/chatgpt-conversation-timeline/HEAD/extension/icons/icon48.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Reborn14 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /extension/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "ChatGPT Conversation Timeline", 4 | "version": "3.1.0", 5 | "description": "Adds a navigation timeline to your ChatGPT, DeepSeek, and Gemini conversations for easy navigation.", 6 | "action": { 7 | "default_popup": "popup.html", 8 | "default_title": "会话时间轴" 9 | }, 10 | "permissions": [ 11 | "storage" 12 | ], 13 | "icons": { 14 | "16": "icons/icon16.png", 15 | "32": "icons/icon32.png", 16 | "48": "icons/icon48.png", 17 | "128": "icons/icon128.png" 18 | }, 19 | "content_scripts": [ 20 | { 21 | "matches": [ 22 | "https://chatgpt.com/*", 23 | "https://chat.openai.com/*" 24 | ], 25 | "js": ["content-chatgpt.js"], 26 | "css": ["styles-chatgpt.css"] 27 | }, 28 | { 29 | "matches": [ 30 | "https://chat.deepseek.com/*" 31 | ], 32 | "js": ["content-deepseek.js"], 33 | "css": ["styles-deepseek.css"] 34 | }, 35 | { 36 | "matches": [ 37 | "https://gemini.google.com/*" 38 | ], 39 | "js": ["content-gemini.js"], 40 | "css": ["styles-gemini.css"] 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /README.zh-CN.md: -------------------------------------------------------------------------------- 1 |

2 | Plugin Preview 3 |

4 | 5 | # 🕰️ ChatGPT 对话时间轴插件 6 | 7 | > 🇺🇸 English version available in [here](./README.md) 8 | 9 | 为你的 AI 对话添加一个交互式的时间轴。**现已同时支持 ChatGPT、DeepSeek 与 Google Gemini!** 10 | 11 | 本插件为 ChatGPT、DeepSeek 与 Google Gemini 的对话页面添加了时间轴导航功能,让你能轻松地跳转至任意消息,提升浏览效率。 12 | 13 | --- 14 | 15 | ## ✨ 插件功能特色 16 | 17 | - **🌐 支持多平台**: 同时为 **ChatGPT**、**DeepSeek** 和 **Google Gemini** 提供无缝的时间轴导航体验。 18 | - **📍 一键跳转**: 为每条用户消息生成可点击锚点,支持一键跳转任意消息位置。 19 | - **⭐ 标记重点**: 支持长按标记重点内容,并在时间轴上高亮显示。标记将保存在本地,刷新后依然保留。 20 | - **🌗 自动主题**: 自动适应各个平台的深色/浅色主题。 21 | - **⚙️ 完全控制**: 通过简洁的弹窗菜单,你可以一键全局启用/禁用,或为每个网站单独设置。 22 | 23 | --- 24 | 25 | ## 🧩 安装方式(适用于 Chrome / Edge 浏览器) 26 | 27 | ### ✅ 推荐方式:通过 Chrome 应用商店安装 28 | 29 | 👉 [前往 Chrome 应用商店安装插件](https://chromewebstore.google.com/detail/ickndngbbabdllekmflaaogkpmnloalg?utm_source=item-share-cb) 30 | 31 | --- 32 | 33 | ### 🛠 手动安装方式(抢先体验新功能) 34 | 35 | 此方法可以让你立即使用最新版本,无需等待应用商店的审核。 36 | 37 | 1. 下载本项目并找到 `extension/` 文件夹。 38 | 2. 打开浏览器,访问:`chrome://extensions/` 39 | 3. 右上角开启「开发者模式」。 40 | 4. 点击「加载已解压的扩展程序」。 41 | 5. 选择项目中的 `extension/` 文件夹进行加载。 42 | 43 | > 安装成功后,打开任意 ChatGPT、DeepSeek 或 Gemini 的会话页面,即可看到页面右侧出现对话时间轴。 44 | 45 | 46 | ## 🙏 致谢 47 | 48 | 本项目的灵感来源于 Google AI Studio 中优雅直观的对话时间线。 49 | 我们希望将这种高效的时间轴式导航方式带给更多 AI 聊天平台的用户。 50 | 51 | ## 📄 开源协议 52 | 53 | 本项目采用 [MIT License](LICENSE) 协议。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Plugin Preview 3 |

4 | 5 | # 🕰 ChatGPT Conversation Timeline Extension 6 | 7 | > 🇨🇳 查看中文版:[README.zh-CN.md](./README.zh-CN.md) 8 | 9 | An extension that adds an interactive timeline to your AI chat conversations. **Now supports ChatGPT, DeepSeek, and Google Gemini!** 10 | 11 | This extension adds an interactive timeline bar to your conversation pages, helping you quickly understand the structure of the dialogue and jump to any message with a single click. 12 | 13 | --- 14 | 15 | ## ✨ Features 16 | 17 | - **🌐 Multi-Platform Support**: Works seamlessly on **ChatGPT**, **DeepSeek**, and **Google Gemini**. 18 | - **📍 Clickable Markers**: Instantly jump to any point in the conversation via clickable markers for each user message. 19 | - **⭐ Star Messages**: Long-press a message to star it, and see it highlighted on the timeline. Stars are saved locally and persist across sessions. 20 | - **🌗 Auto-Theming**: Automatically adapts to the light/dark theme of each platform. 21 | - **⚙️ Full Control**: A simple popup menu allows you to enable or disable the timeline globally or for each site individually. 22 | 23 | --- 24 | 25 | ## 🧩 How to Install (Chrome / Edge) 26 | 27 | ### ✅ Recommended: Install from Chrome Web Store 28 | 29 | 👉 [Install from Chrome Web Store](https://chromewebstore.google.com/detail/ickndngbbabdllekmflaaogkpmnloalg?utm_source=item-share-cb) 30 | 31 | --- 32 | 33 | ### 🛠 Manual Installation (Get new features faster) 34 | 35 | This method allows you to use the latest version immediately, without waiting for the Chrome Web Store review process. 36 | 37 | 1. Download this repository and locate the `extension/` folder. 38 | 2. In your browser, go to: `chrome://extensions/` 39 | 3. Enable “Developer Mode” (top right). 40 | 4. Click **“Load unpacked”**. 41 | 5. Select the `extension/` folder to install. 42 | 43 | > After installation, open any ChatGPT, DeepSeek, or Gemini conversation and the timeline will appear on the right. 44 | 45 | ## 🙏 Acknowledgement 46 | 47 | Inspired by the clean and efficient timeline navigation interface from **Google AI Studio**. 48 | We aim to bring the same intuitive experience to more AI chat platforms. 49 | 50 | --- 51 | 52 | ## 📄 License 53 | 54 | This project is open-sourced under the [MIT License](LICENSE). -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # 工作流的名称 2 | name: Release Extension 3 | 4 | # 1. 触发条件 5 | on: 6 | push: 7 | tags: 8 | - 'v*' # 只有当一个以 'v' 开头的标签(如 v1.0.0, v2.3.4)被推送到仓库时,此工作流才会被触发。 9 | workflow_dispatch: # 允许从 GitHub 界面手动触发一次发布(不会生成标签) 10 | 11 | # 任务定义 12 | jobs: 13 | release: 14 | name: Build and Release 15 | runs-on: ubuntu-latest 16 | 17 | # 2. 权限设置 18 | permissions: 19 | contents: write # 明确向 GitHub 申请对仓库内容的“写入”权限。这是创建 Release 所必需的,是一种安全最佳实践。 20 | 21 | steps: 22 | # 步骤 1: 检出代码 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | # 步骤 2: 确保工具存在 27 | - name: Ensure required tooling 28 | shell: bash 29 | run: | 30 | # 合并 apt 安装,减少重复的 update 步骤 31 | sudo apt-get update && sudo apt-get install -y jq zip 32 | 33 | # 步骤 3: 设置变量 34 | - name: Set variables 35 | id: vars # 给这个步骤一个 ID,方便后续引用它的输出 36 | shell: bash 37 | run: | 38 | # 使用内置的 GITHUB_REF_NAME(值为标签名,如 v1.0.0) 39 | TAG="$GITHUB_REF_NAME" 40 | # 从 TAG 中去掉 'v' 前缀,得到 '1.0.0' 41 | VERSION="${TAG#v}" 42 | # 拼接出最终的 zip 文件名 43 | ZIP_NAME="chatgpt-conversation-timeline-${TAG}.zip" 44 | # 使用 GITHUB_OUTPUT 将这些变量暴露给后续步骤使用 45 | echo "tag=$TAG" >> "$GITHUB_OUTPUT" 46 | echo "version=$VERSION" >> "$GITHUB_OUTPUT" 47 | echo "zip_name=$ZIP_NAME" >> "$GITHUB_OUTPUT" 48 | 49 | # 步骤 4: 验证 manifest.json 中的版本号 50 | - name: Validate manifest version 51 | shell: bash 52 | run: | 53 | # 使用 jq 工具从 manifest.json 文件中精确地读取 version 字段的值 54 | MANIFEST_VERSION=$(jq -r '.version' extension/manifest.json) 55 | echo "Manifest version: ${MANIFEST_VERSION}" 56 | echo "Tag version: ${{ steps.vars.outputs.version }}" # 从上一步获取版本号 57 | # 比较两个版本号是否一致 58 | if [ "$MANIFEST_VERSION" != "${{ steps.vars.outputs.version }}" ]; then 59 | echo "Error: manifest.json version ($MANIFEST_VERSION) does not match tag version (${{ steps.vars.outputs.version }})" 60 | exit 1 # 如果不一致,打印错误信息并立即退出,工作流失败。 61 | fi 62 | 63 | # 步骤 5: 打包扩展 64 | - name: Package extension 65 | shell: bash 66 | run: | 67 | cd extension # 进入 extension 目录 68 | # 将当前目录所有文件打包成 zip 69 | zip -r "../${{ steps.vars.outputs.zip_name }}" . 70 | 71 | # 步骤 6: 发布 GitHub Release 72 | - name: Publish GitHub Release 73 | uses: softprops/action-gh-release@v2 # 使用一个非常流行的第三方 Action 74 | with: 75 | tag_name: ${{ steps.vars.outputs.tag }} # Release 的标签名 76 | name: ${{ steps.vars.outputs.tag }} # Release 的标题 77 | files: ${{ steps.vars.outputs.zip_name }} # 要上传的产物文件 78 | generate_release_notes: true # 自动生成 Release Notes!它会收集两次发布之间的所有 commit 信息。 79 | # 判断标签名是否包含 '-',如果包含(如 v1.1.0-beta),就标记为预发布版本。 80 | prerelease: ${{ contains(steps.vars.outputs.tag, '-') }} 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # 授权 Action 操作仓库 83 | -------------------------------------------------------------------------------- /extension/popup.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const q = (s, r=document) => r.querySelector(s); 3 | document.addEventListener('DOMContentLoaded', () => { 4 | const globalToggle = q('#globalToggle'); 5 | const providerToggle = q('#provider-chatgpt-toggle'); 6 | const deepseekToggle = q('#provider-deepseek-toggle'); 7 | const geminiToggle = q('#provider-gemini-toggle'); 8 | if (!globalToggle || !providerToggle || !deepseekToggle || !geminiToggle) return; 9 | 10 | const applyGlobal = (val) => { 11 | globalToggle.checked = !!val; 12 | document.body.classList.toggle('global-off', !val); 13 | providerToggle.disabled = !val; 14 | deepseekToggle.disabled = !val; 15 | geminiToggle.disabled = !val; 16 | }; 17 | const applyProvider = (val) => { 18 | providerToggle.checked = !!val; 19 | }; 20 | const applyDeepseek = (val) => { 21 | deepseekToggle.checked = !!val; 22 | }; 23 | const applyGemini = (val) => { 24 | geminiToggle.checked = !!val; 25 | }; 26 | 27 | // Read stored state (new keys only) 28 | try { 29 | chrome.storage.local.get({ timelineActive: true, timelineProviders: {} }, (res) => { 30 | const active = !!res.timelineActive; 31 | const chatgptVal = (res.timelineProviders && typeof res.timelineProviders.chatgpt === 'boolean') ? !!res.timelineProviders.chatgpt : true; 32 | const deepseekVal = (res.timelineProviders && typeof res.timelineProviders.deepseek === 'boolean') ? !!res.timelineProviders.deepseek : true; 33 | const geminiVal = (res.timelineProviders && typeof res.timelineProviders.gemini === 'boolean') ? !!res.timelineProviders.gemini : true; 34 | applyGlobal(active); 35 | applyProvider(chatgptVal); 36 | applyDeepseek(deepseekVal); 37 | applyGemini(geminiVal); 38 | // Re-enable transitions after initial state is applied and painted 39 | requestAnimationFrame(() => { requestAnimationFrame(() => { try { document.body.classList.remove('boot'); } catch {} }); }); 40 | }); 41 | } catch {} 42 | 43 | // Write on change 44 | globalToggle.addEventListener('change', () => { 45 | const enabled = !!globalToggle.checked; 46 | try { chrome.storage.local.set({ timelineActive: enabled }); } catch {} 47 | document.body.classList.toggle('global-off', !enabled); 48 | providerToggle.disabled = !enabled; 49 | deepseekToggle.disabled = !enabled; 50 | geminiToggle.disabled = !enabled; 51 | }); 52 | 53 | providerToggle.addEventListener('change', () => { 54 | const enabled = !!providerToggle.checked; 55 | try { 56 | chrome.storage.local.get({ timelineProviders: {} }, (res) => { 57 | const map = res.timelineProviders || {}; 58 | map.chatgpt = enabled; 59 | try { chrome.storage.local.set({ timelineProviders: map }); } catch {} 60 | }); 61 | } catch {} 62 | }); 63 | 64 | deepseekToggle.addEventListener('change', () => { 65 | const enabled = !!deepseekToggle.checked; 66 | try { 67 | chrome.storage.local.get({ timelineProviders: {} }, (res) => { 68 | const map = res.timelineProviders || {}; 69 | map.deepseek = enabled; 70 | try { chrome.storage.local.set({ timelineProviders: map }); } catch {} 71 | }); 72 | } catch {} 73 | }); 74 | 75 | geminiToggle.addEventListener('change', () => { 76 | const enabled = !!geminiToggle.checked; 77 | try { 78 | chrome.storage.local.get({ timelineProviders: {} }, (res) => { 79 | const map = res.timelineProviders || {}; 80 | map.gemini = enabled; 81 | try { chrome.storage.local.set({ timelineProviders: map }); } catch {} 82 | }); 83 | } catch {} 84 | }); 85 | }); 86 | })(); 87 | -------------------------------------------------------------------------------- /extension/popup.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #10a37f; 3 | --surface: #ffffff; 4 | --surface-hover: #f7f7f8; 5 | --text: #202123; 6 | --text-secondary: #6e6e80; 7 | --border: #e5e5e5; 8 | --page-bg: #f7f7f8; 9 | --shadow: 0 2px 8px rgba(0, 0, 0, 0.08); 10 | } 11 | @media (prefers-color-scheme: dark){ 12 | :root{ 13 | --primary: #19C37D; 14 | --surface: #2B2B2B; 15 | --surface-hover: #333333; 16 | --text: #EAEAEA; 17 | --text-secondary: #A1A1AA; 18 | --border: #3A3A3A; 19 | --page-bg: #1F1F1F; 20 | --shadow: 0 2px 12px rgba(0,0,0,0.40); 21 | } 22 | } 23 | 24 | html, body { 25 | margin: 0; 26 | background: var(--page-bg); 27 | color: var(--text); 28 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; 29 | } 30 | 31 | /* Disable transitions during initial boot to avoid first-open slide-in */ 32 | body.boot .switch { transition: none !important; } 33 | body.boot .switch::after { transition: none !important; } 34 | 35 | .app { padding: 12px; width: 280px; box-sizing: border-box; } 36 | .card { background: var(--surface); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; } 37 | .header-bar { display:flex; align-items:center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); } 38 | .header-title { font-size: 13px; font-weight: 600; color: var(--text); } 39 | .content { padding: 12px 16px 16px; } 40 | .section-title { font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-bottom: 4px; } 41 | .providers { margin-top: 4px; } 42 | .provider-item { display:flex; align-items:center; justify-content: space-between; padding: 10px 0; } 43 | .provider-left { display:flex; align-items:center; gap:10px; } 44 | .provider-icon { width: 18px; height:18px; color: var(--text); } 45 | .provider-name { font-size: 14px; font-weight: 500; color: var(--text); } 46 | 47 | .switch-wrapper { display: flex; align-items: center; justify-content: space-between; gap: 12px; } 48 | .switch-label { font-size: 14px; font-weight: 500; color: var(--text); } 49 | 50 | /* Switch */ 51 | /* Path B: single input switch with pseudo-thumb */ 52 | .switch { 53 | appearance: none; 54 | -webkit-appearance: none; 55 | position: relative; 56 | display: inline-block; 57 | width: 50px; 58 | height: 28px; 59 | border-radius: 9999px; 60 | background-color: #e5e5e5; 61 | box-shadow: inset 0 0 0 1px var(--border); 62 | cursor: pointer; 63 | outline: none; 64 | transition: background-color 120ms ease, box-shadow 120ms ease; 65 | } 66 | .switch::after { 67 | content: ""; 68 | position: absolute; 69 | top: 3px; 70 | left: 3px; 71 | width: 22px; 72 | height: 22px; 73 | border-radius: 50%; 74 | background: var(--surface); 75 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 76 | transition: transform 120ms ease; 77 | } 78 | .switch:checked { background-color: var(--primary); box-shadow: none; } 79 | .switch:checked::after { transform: translateX(22px); } 80 | .switch:focus-visible { box-shadow: 0 0 0 2px rgba(16,163,127,0.2); } 81 | 82 | /* Disabled state */ 83 | .switch:disabled { background-color: #e5e5e5; opacity: .6; cursor: not-allowed; } 84 | .switch:disabled::after { box-shadow: none; } 85 | 86 | /* Global off visual (providers dimmed) */ 87 | body.global-off .providers { opacity: .6; } 88 | body.global-off .providers .switch { pointer-events: none; } 89 | 90 | /* GitHub card */ 91 | .github-link { text-decoration: none; color: inherit; display: block; } 92 | .github-card { display: flex; align-items: center; padding: 12px 16px; background: var(--surface); border-top: 1px solid var(--border); gap: 12px; transition: all 0.2s ease; } 93 | .github-card:hover { background: var(--surface-hover); } 94 | .github-card:hover .arrow-icon { transform: translateX(4px); color: var(--primary); } 95 | .github-icon { width: 20px; height: 20px; color: var(--text); flex-shrink: 0; } 96 | .github-info { flex-grow: 1; display: flex; flex-direction: column; gap: 2px; } 97 | .github-text { font-size: 14px; font-weight: 500; color: var(--text); } 98 | .github-desc { font-size: 12px; color: var(--text-secondary); } 99 | .arrow-icon { width: 16px; height: 16px; color: var(--text-secondary); flex-shrink: 0; transition: all 0.2s ease; } 100 | 101 | @media (prefers-reduced-motion: reduce) { 102 | *, ::before, ::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } 103 | } 104 | -------------------------------------------------------------------------------- /extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Conversation Timeline 7 | 8 | 9 | 10 |
11 |
12 |
13 |
会话时间轴
14 | 15 |
16 | 17 |
18 |
网站
19 |
20 |
21 |
22 | 25 | ChatGPT 26 |
27 |
28 | 29 |
30 |
31 |
32 |
33 | 43 | Gemini 44 |
45 |
46 | 47 |
48 |
49 |
50 |
51 | 54 | DeepSeek 55 |
56 |
57 | 58 |
59 |
60 |
61 |
62 | 63 | 64 |
65 | 68 |
69 | 开源项目 70 | 在 GitHub 上查看 71 |
72 | 76 |
77 |
78 |
79 |
80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /extension/styles-chatgpt.css: -------------------------------------------------------------------------------- 1 | /* 2 | ChatGPT Timeline Stylesheet 3 | This CSS is designed to be resilient and adapt to ChatGPT's light/dark themes. 4 | */ 5 | 6 | /* Use CSS variables for easy theme management. We sample ChatGPT's own colors. */ 7 | :root { 8 | --timeline-dot-color: #D1D5DB; /* Light mode gray */ 9 | --timeline-dot-active-color: #10A37F; /* ChatGPT's primary green */ 10 | --timeline-star-color: #F59E0B; /* amber-500 for starred */ 11 | 12 | /* AI Studio-like tooltip tokens (light) */ 13 | --timeline-tooltip-bg: #FFFFFF; 14 | --timeline-tooltip-text: #1F2937; /* slate-800 */ 15 | --timeline-tooltip-border: #E5E7EB; /* gray-200 */ 16 | --timeline-tooltip-radius: 12px; 17 | --timeline-tooltip-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06); 18 | /* geometry */ 19 | --timeline-tooltip-lh: 18px; 20 | --timeline-tooltip-pad-y: 10px; 21 | --timeline-tooltip-pad-x: 12px; 22 | --timeline-tooltip-border-w: 1px; 23 | --timeline-tooltip-arrow-size: 8px; /* square side before rotation */ 24 | --timeline-tooltip-arrow-outside: 4px; /* how much arrow protrudes outside */ 25 | --timeline-tooltip-anim-in: 120ms cubic-bezier(0, 0, 0.2, 1); 26 | --timeline-tooltip-anim-out: 100ms linear; 27 | 28 | --timeline-bar-bg: rgba(240, 240, 240, 0.8); 29 | --timeline-dot-size: 12px; 30 | --timeline-active-ring: 3px; 31 | --timeline-track-padding: 16px; 32 | --timeline-tooltip-max: 288px; 33 | --timeline-min-gap: 24px; 34 | /* enlarged hit area for easier clicking */ 35 | --timeline-hit-size: 30px; 36 | /* spacing model */ 37 | --timeline-tooltip-gap-visual: 8px; /* arrow tip to dot */ 38 | --timeline-tooltip-gap-box: 4px; /* rectangle edge to timeline */ 39 | --timeline-hold-ms: 550ms; /* long-press duration */ 40 | } 41 | 42 | /* Override variables when ChatGPT's dark mode is active */ 43 | html.dark, body.dark, html[data-theme="dark"], [data-theme="dark"], [data-color-mode="dark"] { 44 | --timeline-dot-color: #555555; 45 | --timeline-dot-active-color: #19C37D; 46 | --timeline-star-color: #F59E0B; 47 | /* Dark tooltip palette */ 48 | --timeline-tooltip-bg: #2B2B2B; 49 | --timeline-tooltip-text: #EAEAEA; 50 | --timeline-tooltip-border: #3A3A3A; 51 | --timeline-bar-bg: rgba(50, 50, 50, 0.8); 52 | } 53 | 54 | .chatgpt-timeline-bar { 55 | position: fixed; 56 | top: 60px; /* Position below the main header */ 57 | right: 15px; 58 | width: 24px; 59 | height: calc(100vh - 100px); /* Avoid overlapping the bottom input area */ 60 | z-index: 2147483646; /* ensure above site chrome */ 61 | display: flex; 62 | flex-direction: column; 63 | align-items: center; 64 | border-radius: 10px; 65 | background-color: var(--timeline-bar-bg); 66 | backdrop-filter: blur(4px); 67 | -webkit-backdrop-filter: blur(4px); 68 | transition: background-color 0.3s ease; 69 | overflow: visible; 70 | /* Isolate layout/paint to reduce reflow propagation during updates */ 71 | contain: layout paint; 72 | touch-action: pan-y; /* allow vertical gestures only */ 73 | } 74 | 75 | /* Scrollable track to host all dots (long canvas) */ 76 | .timeline-track { 77 | position: relative; 78 | width: 100%; 79 | height: 100%; 80 | overflow-y: auto; /* programmatic scroll in Linked mode */ 81 | overflow-x: hidden; /* prevent horizontal wobble */ 82 | } 83 | 84 | /* Inner content whose height reflects conversation length */ 85 | .timeline-track-content { 86 | position: relative; 87 | width: 100%; 88 | height: 100%; /* updated by JS when needed */ 89 | overflow-x: hidden; 90 | } 91 | 92 | .timeline-dot { 93 | position: absolute; 94 | left: 50%; 95 | /* Use normalized position `--n` mapped to track with padding */ 96 | top: calc( 97 | var(--timeline-track-padding) 98 | + (100% - 2 * var(--timeline-track-padding)) * var(--n, 0) 99 | ); 100 | transform: translate(-50%, -50%); 101 | width: var(--timeline-hit-size); /* enlarged hit area */ 102 | height: var(--timeline-hit-size); /* enlarged hit area */ 103 | background: transparent; /* visible dot drawn via ::after */ 104 | border: none; 105 | cursor: pointer; 106 | padding: 0; 107 | -webkit-user-drag: none; 108 | user-select: none; 109 | touch-action: manipulation; /* allow tap; prevent pan-x on dot */ 110 | } 111 | 112 | .timeline-dot::after { 113 | content: ''; 114 | position: absolute; 115 | left: 50%; 116 | top: 50%; 117 | width: var(--timeline-dot-size); 118 | height: var(--timeline-dot-size); 119 | transform: translate(-50%, -50%); 120 | border-radius: 50%; 121 | background-color: var(--timeline-dot-color); 122 | transition: transform 0.15s ease, box-shadow 0.15s ease; 123 | } 124 | 125 | /* .timeline-dot::before { 126 | content: ''; 127 | position: absolute; 128 | left: 50%; 129 | top: 50%; 130 | width: var(--timeline-dot-size); 131 | height: var(--timeline-dot-size); 132 | transform: translate(-50%, -50%); 133 | border-radius: 50%; 134 | background-color: var(--timeline-dot-color); 135 | box-shadow: inset 0 0 0 1px var(--timeline-dot-separator); 136 | } */ 137 | 138 | /* Enhance visibility and interaction feedback on hover */ 139 | .timeline-dot:hover::after { transform: translate(-50%, -50%) scale(1.15); } 140 | /* Keyboard accessibility: visible focus ring */ 141 | .timeline-dot:focus-visible::after { 142 | box-shadow: 0 0 6px var(--timeline-dot-active-color); 143 | } 144 | /* Style for the currently active dot */ 145 | .timeline-dot.active::after { 146 | box-shadow: 0 0 0 var(--timeline-active-ring) var(--timeline-dot-active-color), 0 0 6px var(--timeline-dot-active-color); 147 | } 148 | 149 | /* Starred state: filled with star color */ 150 | .timeline-dot.starred::after { 151 | background-color: var(--timeline-star-color); 152 | } 153 | 154 | /* Long-press visual: subtle ring fade-in (progress surrogate) */ 155 | .timeline-dot.holding::before { 156 | content: ''; 157 | position: absolute; 158 | left: 50%; 159 | top: 50%; 160 | width: calc(var(--timeline-dot-size) + 8px); 161 | height: calc(var(--timeline-dot-size) + 8px); 162 | transform: translate(-50%, -50%); 163 | border-radius: 50%; 164 | box-shadow: 0 0 0 2px var(--timeline-dot-active-color) inset; 165 | opacity: 0.15; 166 | animation: timeline-hold-fade var(--timeline-hold-ms) linear forwards; 167 | pointer-events: none; 168 | } 169 | 170 | @keyframes timeline-hold-fade { 171 | from { opacity: 0.15; } 172 | to { opacity: 0.85; } 173 | } 174 | 175 | /* Floating tooltip element */ 176 | .timeline-tooltip { 177 | position: fixed; 178 | max-width: var(--timeline-tooltip-max); 179 | background-color: var(--timeline-tooltip-bg); 180 | color: var(--timeline-tooltip-text); 181 | padding: var(--timeline-tooltip-pad-y) var(--timeline-tooltip-pad-x); 182 | border-radius: var(--timeline-tooltip-radius); 183 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 184 | font-size: 12px; 185 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 186 | line-height: var(--timeline-tooltip-lh); /* integer px to avoid half-line bleed */ 187 | /* exactly 3 lines + paddings + borders */ 188 | max-height: calc( 189 | 3 * var(--timeline-tooltip-lh) 190 | + 2 * var(--timeline-tooltip-pad-y) 191 | + 2 * var(--timeline-tooltip-border-w) 192 | ); 193 | box-shadow: var(--timeline-tooltip-shadow); 194 | pointer-events: none; 195 | z-index: 2147483647; 196 | /* Clamp to 3 lines; ellipsis only at end */ 197 | display: -webkit-box; 198 | -webkit-line-clamp: 3; 199 | -webkit-box-orient: vertical; 200 | overflow: hidden; 201 | word-break: break-word; 202 | /* Animation base */ 203 | opacity: 0; 204 | will-change: opacity, transform; 205 | transition: opacity var(--timeline-tooltip-anim-in); 206 | } 207 | 208 | /* Visible state toggled by JS */ 209 | .timeline-tooltip.visible { 210 | opacity: 1; 211 | animation: timeline-tooltip-in var(--timeline-tooltip-anim-in); 212 | } 213 | 214 | /* Respect reduced motion */ 215 | @media (prefers-reduced-motion: reduce) { 216 | .timeline-tooltip, 217 | .timeline-tooltip.visible { 218 | transition: opacity 120ms linear; 219 | transform: none !important; 220 | } 221 | } 222 | 223 | /* Placement-aware transform origin for subtle scale */ 224 | .timeline-tooltip[data-placement="left"] { transform-origin: right center; } 225 | .timeline-tooltip[data-placement="right"] { transform-origin: left center; } 226 | 227 | @keyframes timeline-tooltip-in { 228 | from { transform: scale(0.96); } 229 | to { transform: scale(1); } 230 | } 231 | 232 | /* Arrow (diamond) using rotated square; inherits colors */ 233 | .timeline-tooltip::after { 234 | content: ''; 235 | position: absolute; 236 | width: var(--timeline-tooltip-arrow-size); 237 | height: var(--timeline-tooltip-arrow-size); 238 | background: var(--timeline-tooltip-bg); 239 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 240 | transform: rotate(45deg); 241 | } 242 | 243 | .timeline-tooltip[data-placement="left"]::after { 244 | right: calc(-1 * var(--timeline-tooltip-arrow-outside)); 245 | top: 50%; 246 | transform: translateY(-50%) rotate(45deg); 247 | border-left: none; /* visually merge with tooltip border */ 248 | border-bottom: none; 249 | } 250 | 251 | .timeline-tooltip[data-placement="right"]::after { 252 | left: calc(-1 * var(--timeline-tooltip-arrow-outside)); 253 | top: 50%; 254 | transform: translateY(-50%) rotate(45deg); 255 | border-right: none; 256 | border-top: none; 257 | } 258 | 259 | /* Hide native scrollbar of the track */ 260 | .timeline-track::-webkit-scrollbar { width: 0; height: 0; } 261 | .timeline-track { scrollbar-width: none; } 262 | 263 | /* Left-side slider to control timeline scroll (visual, low-key) */ 264 | .timeline-slider { 265 | position: absolute; 266 | left: 2px; 267 | top: var(--timeline-track-padding); 268 | bottom: var(--timeline-track-padding); 269 | width: 6px; 270 | pointer-events: auto; /* allow handle to capture */ 271 | opacity: 0; 272 | transition: opacity 180ms ease; 273 | z-index: 10; 274 | } 275 | .timeline-slider.visible { opacity: 1; } 276 | .timeline-slider::before { 277 | content: ''; 278 | position: absolute; 279 | left: 2px; 280 | top: 0; bottom: 0; 281 | width: 2px; 282 | background: rgba(0,0,0,0.08); 283 | border-radius: 9999px; 284 | } 285 | html.dark .timeline-slider::before { background: rgba(255,255,255,0.10); } 286 | 287 | .timeline-slider .timeline-handle { 288 | position: absolute; 289 | left: 0; 290 | width: 6px; 291 | height: 24px; /* updated by JS */ 292 | background: rgba(16,163,127,0.28); /* ChatGPT green with low opacity */ 293 | border-radius: 9999px; 294 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 295 | pointer-events: auto; 296 | cursor: grab; 297 | transition: background-color 120ms ease, opacity 120ms ease; 298 | } 299 | .timeline-slider .timeline-handle:hover { background: rgba(16,163,127,0.45); } 300 | .timeline-slider .timeline-handle:active { cursor: grabbing; } 301 | /* External left-side slider (outside the timeline bar) */ 302 | .timeline-left-slider { 303 | position: fixed; 304 | /* left set by JS using bar's rect (to the left of the bar) */ 305 | top: 0; /* updated by JS */ 306 | width: 12px; /* hit area; visual line narrower */ 307 | height: 160px; /* updated by JS */ 308 | opacity: 0; 309 | transition: opacity 180ms ease; 310 | z-index: 2147483646; /* align with bar */ 311 | pointer-events: none; 312 | } 313 | .timeline-left-slider.visible { opacity: 1; pointer-events: auto; } 314 | .timeline-left-slider::before { 315 | content: ''; 316 | position: absolute; 317 | left: 5px; /* center thin track inside 12px hit area */ 318 | top: 0; bottom: 0; 319 | width: 2px; 320 | background: rgba(0,0,0,0.08); 321 | border-radius: 9999px; 322 | } 323 | html.dark .timeline-left-slider::before { background: rgba(255,255,255,0.10); } 324 | .timeline-left-slider .timeline-left-handle { 325 | position: absolute; 326 | left: 2px; /* centered for 8px width inside 12px hit area */ 327 | width: 8px; 328 | height: 22px; /* fixed, concise */ 329 | background: rgba(16,163,127,0.28); 330 | border-radius: 9999px; 331 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 332 | pointer-events: auto; 333 | cursor: grab; 334 | transition: background-color 120ms ease; 335 | } 336 | .timeline-left-slider .timeline-left-handle:hover { background: rgba(16,163,127,0.45); } 337 | .timeline-left-slider .timeline-left-handle:active { cursor: grabbing; } 338 | -------------------------------------------------------------------------------- /extension/styles-deepseek.css: -------------------------------------------------------------------------------- 1 | /* 2 | ChatGPT Timeline Stylesheet 3 | This CSS is designed to be resilient and adapt to ChatGPT's light/dark themes. 4 | */ 5 | 6 | /* Use CSS variables for easy theme management. We sample ChatGPT's own colors. */ 7 | :root { 8 | --timeline-dot-color: #C9CFDA; /* neutral blue-gray for default dots */ 9 | --timeline-dot-active-color: #586BFF; /* softened DeepSeek blue for active ring */ 10 | --timeline-star-color: #F2B84B; /* softer gold for starred */ 11 | --timeline-focus-outline: #8FA0FF; /* keyboard focus outline */ 12 | 13 | /* AI Studio-like tooltip tokens (light) */ 14 | --timeline-tooltip-bg: #FFFFFF; 15 | --timeline-tooltip-text: #1F2937; /* slate-800 */ 16 | --timeline-tooltip-border: #E5E7EB; /* gray-200 */ 17 | --timeline-tooltip-radius: 12px; 18 | --timeline-tooltip-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06); 19 | /* geometry */ 20 | --timeline-tooltip-lh: 18px; 21 | --timeline-tooltip-pad-y: 10px; 22 | --timeline-tooltip-pad-x: 12px; 23 | --timeline-tooltip-border-w: 1px; 24 | --timeline-tooltip-arrow-size: 8px; /* square side before rotation */ 25 | --timeline-tooltip-arrow-outside: 4px; /* how much arrow protrudes outside */ 26 | --timeline-tooltip-anim-in: 120ms cubic-bezier(0, 0, 0.2, 1); 27 | --timeline-tooltip-anim-out: 100ms linear; 28 | 29 | --timeline-bar-bg: rgba(243, 244, 246, 0.78); 30 | --timeline-dot-size: 12px; 31 | --timeline-active-ring: 3px; 32 | --timeline-track-padding: 16px; 33 | --timeline-tooltip-max: 288px; 34 | --timeline-min-gap: 24px; 35 | /* enlarged hit area for easier clicking */ 36 | --timeline-hit-size: 30px; 37 | /* spacing model */ 38 | --timeline-tooltip-gap-visual: 8px; /* arrow tip to dot */ 39 | --timeline-tooltip-gap-box: 4px; /* rectangle edge to timeline */ 40 | --timeline-hold-ms: 550ms; /* long-press duration */ 41 | } 42 | 43 | /* Override variables when ChatGPT's dark mode is active */ 44 | html.dark, body.dark, html[data-theme="dark"], [data-theme="dark"], [data-color-mode="dark"] { 45 | --timeline-dot-color: #6B7280; 46 | --timeline-dot-active-color: #7D8AFF; /* brighter for contrast */ 47 | --timeline-star-color: #F4C15C; 48 | /* Dark tooltip palette */ 49 | --timeline-tooltip-bg: #2B2B2B; 50 | --timeline-tooltip-text: #EAEAEA; 51 | --timeline-tooltip-border: #3A3A3A; 52 | --timeline-bar-bg: rgba(46, 48, 53, 0.78); 53 | } 54 | 55 | .chatgpt-timeline-bar { 56 | position: fixed; 57 | top: 60px; /* Position below the main header */ 58 | right: 20px; 59 | width: 24px; 60 | height: calc(100vh - 100px); /* Avoid overlapping the bottom input area */ 61 | z-index: 2147483646; /* ensure above site chrome */ 62 | display: flex; 63 | flex-direction: column; 64 | align-items: center; 65 | border-radius: 10px; 66 | background-color: var(--timeline-bar-bg); 67 | backdrop-filter: blur(4px); 68 | -webkit-backdrop-filter: blur(4px); 69 | transition: background-color 0.3s ease; 70 | overflow: visible; 71 | /* Isolate layout/paint to reduce reflow propagation during updates */ 72 | contain: layout paint; 73 | touch-action: pan-y; /* allow vertical gestures only */ 74 | } 75 | 76 | /* Scrollable track to host all dots (long canvas) */ 77 | .timeline-track { 78 | position: relative; 79 | width: 100%; 80 | height: 100%; 81 | overflow-y: auto; /* programmatic scroll in Linked mode */ 82 | overflow-x: hidden; /* prevent horizontal wobble */ 83 | } 84 | 85 | /* Inner content whose height reflects conversation length */ 86 | .timeline-track-content { 87 | position: relative; 88 | width: 100%; 89 | height: 100%; /* updated by JS when needed */ 90 | overflow-x: hidden; 91 | } 92 | 93 | .timeline-dot { 94 | position: absolute; 95 | left: 50%; 96 | /* Use normalized position `--n` mapped to track with padding */ 97 | top: calc( 98 | var(--timeline-track-padding) 99 | + (100% - 2 * var(--timeline-track-padding)) * var(--n, 0) 100 | ); 101 | transform: translate(-50%, -50%); 102 | width: var(--timeline-hit-size); /* enlarged hit area */ 103 | height: var(--timeline-hit-size); /* enlarged hit area */ 104 | background: transparent; /* visible dot drawn via ::after */ 105 | border: none; 106 | cursor: pointer; 107 | padding: 0; 108 | -webkit-user-drag: none; 109 | user-select: none; 110 | touch-action: manipulation; /* allow tap; prevent pan-x on dot */ 111 | } 112 | 113 | .timeline-dot::after { 114 | content: ''; 115 | position: absolute; 116 | left: 50%; 117 | top: 50%; 118 | width: var(--timeline-dot-size); 119 | height: var(--timeline-dot-size); 120 | transform: translate(-50%, -50%); 121 | border-radius: 50%; 122 | background-color: var(--timeline-dot-color); 123 | transition: transform 0.15s ease, box-shadow 0.15s ease; 124 | } 125 | 126 | /* .timeline-dot::before { 127 | content: ''; 128 | position: absolute; 129 | left: 50%; 130 | top: 50%; 131 | width: var(--timeline-dot-size); 132 | height: var(--timeline-dot-size); 133 | transform: translate(-50%, -50%); 134 | border-radius: 50%; 135 | background-color: var(--timeline-dot-color); 136 | box-shadow: inset 0 0 0 1px var(--timeline-dot-separator); 137 | } */ 138 | 139 | /* Enhance visibility and interaction feedback on hover */ 140 | /* Softer hover scale to avoid visual noise */ 141 | .timeline-dot:hover::after { transform: translate(-50%, -50%) scale(1.12); } 142 | /* Keyboard accessibility: visible focus ring */ 143 | /* Clear keyboard focus with outline instead of glow */ 144 | .timeline-dot:focus-visible::after { 145 | box-shadow: 0 0 0 2px var(--timeline-focus-outline); 146 | } 147 | /* Style for the currently active dot */ 148 | /* Active ring only; remove strong outer glow */ 149 | .timeline-dot.active::after { 150 | box-shadow: 0 0 0 var(--timeline-active-ring) var(--timeline-dot-active-color); 151 | } 152 | 153 | /* Starred state: filled with star color */ 154 | .timeline-dot.starred::after { 155 | background-color: var(--timeline-star-color); 156 | } 157 | 158 | /* Long-press visual: subtle ring fade-in (progress surrogate) */ 159 | .timeline-dot.holding::before { 160 | content: ''; 161 | position: absolute; 162 | left: 50%; 163 | top: 50%; 164 | width: calc(var(--timeline-dot-size) + 8px); 165 | height: calc(var(--timeline-dot-size) + 8px); 166 | transform: translate(-50%, -50%); 167 | border-radius: 50%; 168 | box-shadow: 0 0 0 2px var(--timeline-dot-active-color) inset; 169 | opacity: 0.15; 170 | animation: timeline-hold-fade var(--timeline-hold-ms) linear forwards; 171 | pointer-events: none; 172 | } 173 | 174 | @keyframes timeline-hold-fade { 175 | from { opacity: 0.15; } 176 | to { opacity: 0.85; } 177 | } 178 | 179 | /* Floating tooltip element */ 180 | .timeline-tooltip { 181 | position: fixed; 182 | max-width: var(--timeline-tooltip-max); 183 | background-color: var(--timeline-tooltip-bg); 184 | color: var(--timeline-tooltip-text); 185 | padding: var(--timeline-tooltip-pad-y) var(--timeline-tooltip-pad-x); 186 | border-radius: var(--timeline-tooltip-radius); 187 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 188 | font-size: 12px; 189 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 190 | line-height: var(--timeline-tooltip-lh); /* integer px to avoid half-line bleed */ 191 | /* exactly 3 lines + paddings + borders */ 192 | max-height: calc( 193 | 3 * var(--timeline-tooltip-lh) 194 | + 2 * var(--timeline-tooltip-pad-y) 195 | + 2 * var(--timeline-tooltip-border-w) 196 | ); 197 | box-shadow: var(--timeline-tooltip-shadow); 198 | pointer-events: none; 199 | z-index: 2147483647; 200 | /* Clamp to 3 lines; ellipsis only at end */ 201 | display: -webkit-box; 202 | -webkit-line-clamp: 3; 203 | -webkit-box-orient: vertical; 204 | overflow: hidden; 205 | word-break: break-word; 206 | /* Animation base */ 207 | opacity: 0; 208 | will-change: opacity, transform; 209 | transition: opacity var(--timeline-tooltip-anim-in); 210 | } 211 | 212 | /* Visible state toggled by JS */ 213 | .timeline-tooltip.visible { 214 | opacity: 1; 215 | animation: timeline-tooltip-in var(--timeline-tooltip-anim-in); 216 | } 217 | 218 | /* Respect reduced motion */ 219 | @media (prefers-reduced-motion: reduce) { 220 | .timeline-tooltip, 221 | .timeline-tooltip.visible { 222 | transition: opacity 120ms linear; 223 | transform: none !important; 224 | } 225 | } 226 | 227 | /* Placement-aware transform origin for subtle scale */ 228 | .timeline-tooltip[data-placement="left"] { transform-origin: right center; } 229 | .timeline-tooltip[data-placement="right"] { transform-origin: left center; } 230 | 231 | @keyframes timeline-tooltip-in { 232 | from { transform: scale(0.96); } 233 | to { transform: scale(1); } 234 | } 235 | 236 | /* Arrow (diamond) using rotated square; inherits colors */ 237 | .timeline-tooltip::after { 238 | content: ''; 239 | position: absolute; 240 | width: var(--timeline-tooltip-arrow-size); 241 | height: var(--timeline-tooltip-arrow-size); 242 | background: var(--timeline-tooltip-bg); 243 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 244 | transform: rotate(45deg); 245 | } 246 | 247 | .timeline-tooltip[data-placement="left"]::after { 248 | right: calc(-1 * var(--timeline-tooltip-arrow-outside)); 249 | top: 50%; 250 | transform: translateY(-50%) rotate(45deg); 251 | border-left: none; /* visually merge with tooltip border */ 252 | border-bottom: none; 253 | } 254 | 255 | .timeline-tooltip[data-placement="right"]::after { 256 | left: calc(-1 * var(--timeline-tooltip-arrow-outside)); 257 | top: 50%; 258 | transform: translateY(-50%) rotate(45deg); 259 | border-right: none; 260 | border-top: none; 261 | } 262 | 263 | /* Hide native scrollbar of the track */ 264 | .timeline-track::-webkit-scrollbar { width: 0; height: 0; } 265 | .timeline-track { scrollbar-width: none; } 266 | 267 | /* Left-side slider to control timeline scroll (visual, low-key) */ 268 | .timeline-slider { 269 | position: absolute; 270 | left: 2px; 271 | top: var(--timeline-track-padding); 272 | bottom: var(--timeline-track-padding); 273 | width: 6px; 274 | pointer-events: auto; /* allow handle to capture */ 275 | opacity: 0; 276 | transition: opacity 180ms ease; 277 | z-index: 10; 278 | } 279 | .timeline-slider.visible { opacity: 1; } 280 | .timeline-slider::before { 281 | content: ''; 282 | position: absolute; 283 | left: 2px; 284 | top: 0; bottom: 0; 285 | width: 2px; 286 | background: rgba(0,0,0,0.08); 287 | border-radius: 9999px; 288 | } 289 | html.dark .timeline-slider::before { background: rgba(255,255,255,0.10); } 290 | 291 | .timeline-slider .timeline-handle { 292 | position: absolute; 293 | left: 0; 294 | width: 6px; 295 | height: 24px; /* updated by JS */ 296 | background: rgba(88,107,255,0.28); /* softened brand blue */ 297 | border-radius: 9999px; 298 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 299 | pointer-events: auto; 300 | cursor: grab; 301 | transition: background-color 120ms ease, opacity 120ms ease; 302 | } 303 | .timeline-slider .timeline-handle:hover { background: rgba(88,107,255,0.45); } 304 | .timeline-slider .timeline-handle:active { cursor: grabbing; } 305 | /* External left-side slider (outside the timeline bar) */ 306 | .timeline-left-slider { 307 | position: fixed; 308 | /* left set by JS using bar's rect (to the left of the bar) */ 309 | top: 0; /* updated by JS */ 310 | width: 12px; /* hit area; visual line narrower */ 311 | height: 160px; /* updated by JS */ 312 | opacity: 0; 313 | transition: opacity 180ms ease; 314 | z-index: 2147483646; /* align with bar */ 315 | pointer-events: none; 316 | } 317 | .timeline-left-slider.visible { opacity: 1; pointer-events: auto; } 318 | .timeline-left-slider::before { 319 | content: ''; 320 | position: absolute; 321 | left: 5px; /* center thin track inside 12px hit area */ 322 | top: 0; bottom: 0; 323 | width: 2px; 324 | background: rgba(0,0,0,0.08); /* neutral rail */ 325 | border-radius: 9999px; 326 | } 327 | html.dark .timeline-left-slider::before, 328 | body.dark .timeline-left-slider::before, 329 | [data-theme="dark"] .timeline-left-slider::before, 330 | [data-color-mode="dark"] .timeline-left-slider::before { background: rgba(255,255,255,0.10); } 331 | .timeline-left-slider .timeline-left-handle { /* light default updated above */ } 332 | /* Dark theme handle colors for better contrast */ 333 | html.dark .timeline-slider .timeline-handle, 334 | body.dark .timeline-slider .timeline-handle, 335 | [data-theme="dark"] .timeline-slider .timeline-handle, 336 | [data-color-mode="dark"] .timeline-slider .timeline-handle { background: rgba(125,138,255,0.32); } 337 | html.dark .timeline-slider .timeline-handle:hover, 338 | body.dark .timeline-slider .timeline-handle:hover, 339 | [data-theme="dark"] .timeline-slider .timeline-handle:hover, 340 | [data-color-mode="dark"] .timeline-slider .timeline-handle:hover { background: rgba(125,138,255,0.50); } 341 | 342 | html.dark .timeline-left-slider .timeline-left-handle, 343 | body.dark .timeline-left-slider .timeline-left-handle, 344 | [data-theme="dark"] .timeline-left-slider .timeline-left-handle, 345 | [data-color-mode="dark"] .timeline-left-slider .timeline-left-handle { background: rgba(125,138,255,0.32); } 346 | html.dark .timeline-left-slider .timeline-left-handle:hover, 347 | body.dark .timeline-left-slider .timeline-left-handle:hover, 348 | [data-theme="dark"] .timeline-left-slider .timeline-left-handle:hover, 349 | [data-color-mode="dark"] .timeline-left-slider .timeline-left-handle:hover { background: rgba(125,138,255,0.50); } 350 | .timeline-left-slider .timeline-left-handle { 351 | position: absolute; 352 | left: 2px; /* centered for 8px width inside 12px hit area */ 353 | width: 8px; 354 | height: 22px; /* fixed, concise */ 355 | background: rgba(88,107,255,0.28); 356 | border-radius: 9999px; 357 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 358 | pointer-events: auto; 359 | cursor: grab; 360 | transition: background-color 120ms ease; 361 | } 362 | .timeline-left-slider .timeline-left-handle:hover { background: rgba(88,107,255,0.45); } 363 | .timeline-left-slider .timeline-left-handle:active { cursor: grabbing; } 364 | -------------------------------------------------------------------------------- /extension/styles-gemini.css: -------------------------------------------------------------------------------- 1 | /* 2 | ChatGPT Timeline Stylesheet 3 | This CSS is designed to be resilient and adapt to ChatGPT's light/dark themes. 4 | */ 5 | 6 | /* Use CSS variables for easy theme management. We sample ChatGPT's own colors. */ 7 | :root { 8 | /* Gemini brand-tuned tokens (light) */ 9 | --timeline-dot-color: #D7DCE4; /* neutral blue-gray */ 10 | --timeline-dot-active-color: #5684D1; /* Gemini blue for active ring */ 11 | --timeline-star-color: #F5C84B; /* softer gold for starred */ 12 | --timeline-focus-outline: #6FA8FF; /* keyboard focus outline */ 13 | 14 | /* Tooltip tokens (light) */ 15 | --timeline-tooltip-bg: #FFFFFF; 16 | --timeline-tooltip-text: #1F2937; /* slate-800 */ 17 | --timeline-tooltip-border: #E5E7EB; /* gray-200 */ 18 | --timeline-tooltip-radius: 12px; 19 | --timeline-tooltip-shadow: 0 4px 14px rgba(0,0,0,0.10), 0 1px 2px rgba(0,0,0,0.06); 20 | /* geometry */ 21 | --timeline-tooltip-lh: 18px; 22 | --timeline-tooltip-pad-y: 10px; 23 | --timeline-tooltip-pad-x: 12px; 24 | --timeline-tooltip-border-w: 1px; 25 | --timeline-tooltip-arrow-size: 8px; /* square side before rotation */ 26 | --timeline-tooltip-arrow-outside: 6px; /* how much arrow protrudes outside */ 27 | --timeline-tooltip-anim-in: 120ms cubic-bezier(0, 0, 0.2, 1); 28 | --timeline-tooltip-anim-out: 100ms linear; 29 | 30 | --timeline-bar-bg: rgba(245, 247, 250, 0.78); 31 | --timeline-dot-size: 12px; 32 | --timeline-active-ring: 3px; 33 | --timeline-track-padding: 16px; 34 | --timeline-tooltip-max: 288px; 35 | --timeline-min-gap: 24px; 36 | /* enlarged hit area for easier clicking */ 37 | --timeline-hit-size: 30px; 38 | /* spacing model */ 39 | --timeline-tooltip-gap-visual: 12px; /* arrow tip to dot */ 40 | --timeline-tooltip-gap-box: 6px; /* rectangle edge to timeline */ 41 | --timeline-hold-ms: 550ms; /* long-press duration */ 42 | } 43 | 44 | /* Override variables when ChatGPT's dark mode is active */ 45 | html.dark, 46 | body.dark, 47 | html[data-theme="dark"], 48 | [data-theme="dark"], 49 | [data-color-mode="dark"], 50 | .dark-theme, 51 | body.dark-theme, 52 | .theme-host.dark-theme { 53 | --timeline-dot-color: #6B7280; 54 | --timeline-dot-active-color: #7DA8FF; /* brighter for contrast */ 55 | --timeline-star-color: #F6CF5F; 56 | --timeline-focus-outline: #95BFFF; 57 | /* Dark tooltip palette */ 58 | --timeline-tooltip-bg: #2B2F3A; 59 | --timeline-tooltip-text: #EDF2F7; 60 | --timeline-tooltip-border: #3C4452; 61 | --timeline-tooltip-shadow: 0 6px 18px rgba(0,0,0,0.28), 0 1px 2px rgba(0,0,0,0.18); 62 | --timeline-bar-bg: rgba(44, 47, 54, 0.78); 63 | } 64 | 65 | .chatgpt-timeline-bar { 66 | position: fixed; 67 | top: 60px; /* Position below the main header */ 68 | right: 15px; 69 | width: 24px; 70 | height: calc(100vh - 100px); /* Avoid overlapping the bottom input area */ 71 | z-index: 2147483646; /* ensure above site chrome */ 72 | display: flex; 73 | flex-direction: column; 74 | align-items: center; 75 | border-radius: 10px; 76 | background-color: var(--timeline-bar-bg); 77 | backdrop-filter: blur(4px); 78 | -webkit-backdrop-filter: blur(4px); 79 | transition: background-color 0.3s ease; 80 | overflow: visible; 81 | /* Isolate layout/paint to reduce reflow propagation during updates */ 82 | contain: layout paint; 83 | touch-action: pan-y; /* allow vertical gestures only */ 84 | } 85 | 86 | /* Scrollable track to host all dots (long canvas) */ 87 | .timeline-track { 88 | position: relative; 89 | width: 100%; 90 | height: 100%; 91 | overflow-y: auto; /* programmatic scroll in Linked mode */ 92 | overflow-x: hidden; /* prevent horizontal wobble */ 93 | } 94 | 95 | /* Inner content whose height reflects conversation length */ 96 | .timeline-track-content { 97 | position: relative; 98 | width: 100%; 99 | height: 100%; /* updated by JS when needed */ 100 | overflow-x: hidden; 101 | } 102 | 103 | .timeline-dot { 104 | position: absolute; 105 | left: 50%; 106 | /* Use normalized position `--n` mapped to track with padding */ 107 | top: calc( 108 | var(--timeline-track-padding) 109 | + (100% - 2 * var(--timeline-track-padding)) * var(--n, 0) 110 | ); 111 | transform: translate(-50%, -50%); 112 | width: var(--timeline-hit-size); /* enlarged hit area */ 113 | height: var(--timeline-hit-size); /* enlarged hit area */ 114 | background: transparent; /* visible dot drawn via ::after */ 115 | border: none; 116 | cursor: pointer; 117 | padding: 0; 118 | -webkit-user-drag: none; 119 | user-select: none; 120 | touch-action: manipulation; /* allow tap; prevent pan-x on dot */ 121 | } 122 | 123 | .timeline-dot::after { 124 | content: ''; 125 | position: absolute; 126 | left: 50%; 127 | top: 50%; 128 | width: var(--timeline-dot-size); 129 | height: var(--timeline-dot-size); 130 | transform: translate(-50%, -50%); 131 | border-radius: 50%; 132 | background-color: var(--timeline-dot-color); 133 | transition: transform 0.15s ease, box-shadow 0.15s ease; 134 | } 135 | 136 | /* .timeline-dot::before { 137 | content: ''; 138 | position: absolute; 139 | left: 50%; 140 | top: 50%; 141 | width: var(--timeline-dot-size); 142 | height: var(--timeline-dot-size); 143 | transform: translate(-50%, -50%); 144 | border-radius: 50%; 145 | background-color: var(--timeline-dot-color); 146 | box-shadow: inset 0 0 0 1px var(--timeline-dot-separator); 147 | } */ 148 | 149 | /* Enhance visibility and interaction feedback on hover */ 150 | .timeline-dot:hover::after { transform: translate(-50%, -50%) scale(1.12); } 151 | /* Keyboard accessibility: visible focus ring */ 152 | .timeline-dot:focus-visible::after { box-shadow: 0 0 0 2px var(--timeline-focus-outline); } 153 | /* Style for the currently active dot */ 154 | .timeline-dot.active::after { box-shadow: 0 0 0 var(--timeline-active-ring) var(--timeline-dot-active-color); } 155 | 156 | /* Starred state: filled with star color */ 157 | .timeline-dot.starred::after { 158 | background-color: var(--timeline-star-color); 159 | } 160 | 161 | /* Long-press visual: subtle ring fade-in (progress surrogate) */ 162 | .timeline-dot.holding::before { 163 | content: ''; 164 | position: absolute; 165 | left: 50%; 166 | top: 50%; 167 | width: calc(var(--timeline-dot-size) + 8px); 168 | height: calc(var(--timeline-dot-size) + 8px); 169 | transform: translate(-50%, -50%); 170 | border-radius: 50%; 171 | box-shadow: 0 0 0 2px var(--timeline-dot-active-color) inset; 172 | opacity: 0.15; 173 | animation: timeline-hold-fade var(--timeline-hold-ms) linear forwards; 174 | pointer-events: none; 175 | } 176 | 177 | @keyframes timeline-hold-fade { 178 | from { opacity: 0.15; } 179 | to { opacity: 0.85; } 180 | } 181 | 182 | /* Floating tooltip element */ 183 | .timeline-tooltip { 184 | position: fixed; 185 | box-sizing: border-box; /* Match measurer to avoid height drift */ 186 | max-width: var(--timeline-tooltip-max); 187 | background-color: var(--timeline-tooltip-bg); 188 | color: var(--timeline-tooltip-text); 189 | padding: var(--timeline-tooltip-pad-y) var(--timeline-tooltip-pad-x); 190 | border-radius: var(--timeline-tooltip-radius); 191 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 192 | font-size: 12px; 193 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 194 | line-height: var(--timeline-tooltip-lh); /* integer px to avoid half-line bleed */ 195 | /* exactly 3 lines + paddings + borders */ 196 | max-height: calc( 197 | 3 * var(--timeline-tooltip-lh) 198 | + 2 * var(--timeline-tooltip-pad-y) 199 | + 2 * var(--timeline-tooltip-border-w) 200 | ); 201 | box-shadow: var(--timeline-tooltip-shadow); 202 | pointer-events: none; 203 | z-index: 2147483647; 204 | /* Clamp to 3 lines; ellipsis only at end */ 205 | display: -webkit-box; 206 | -webkit-line-clamp: 3; 207 | -webkit-box-orient: vertical; 208 | overflow: hidden; 209 | word-break: break-word; 210 | /* Animation base */ 211 | opacity: 0; 212 | will-change: opacity, transform; 213 | transition: opacity var(--timeline-tooltip-anim-in); 214 | } 215 | 216 | /* Visible state toggled by JS */ 217 | .timeline-tooltip.visible { 218 | opacity: 1; 219 | animation: timeline-tooltip-in var(--timeline-tooltip-anim-in); 220 | } 221 | 222 | /* Respect reduced motion */ 223 | @media (prefers-reduced-motion: reduce) { 224 | .timeline-tooltip, 225 | .timeline-tooltip.visible { 226 | transition: opacity 120ms linear; 227 | transform: none !important; 228 | } 229 | } 230 | 231 | /* Placement-aware transform origin for subtle scale */ 232 | .timeline-tooltip[data-placement="left"] { transform-origin: right center; } 233 | .timeline-tooltip[data-placement="right"] { transform-origin: left center; } 234 | 235 | @keyframes timeline-tooltip-in { 236 | from { transform: scale(0.96); } 237 | to { transform: scale(1); } 238 | } 239 | 240 | /* Arrow (diamond) using rotated square; inherits colors */ 241 | .timeline-tooltip::after { 242 | content: ''; 243 | position: absolute; 244 | width: var(--timeline-tooltip-arrow-size); 245 | height: var(--timeline-tooltip-arrow-size); 246 | background: var(--timeline-tooltip-bg); 247 | border: var(--timeline-tooltip-border-w) solid var(--timeline-tooltip-border); 248 | transform: rotate(45deg); 249 | } 250 | 251 | .timeline-tooltip[data-placement="left"]::after { 252 | right: calc(-1 * var(--timeline-tooltip-arrow-outside)); 253 | top: 50%; 254 | transform: translateY(-50%) rotate(45deg); 255 | border-left: none; /* visually merge with tooltip border */ 256 | border-bottom: none; 257 | } 258 | 259 | .timeline-tooltip[data-placement="right"]::after { 260 | left: calc(-1 * var(--timeline-tooltip-arrow-outside)); 261 | top: 50%; 262 | transform: translateY(-50%) rotate(45deg); 263 | border-right: none; 264 | border-top: none; 265 | } 266 | 267 | /* Hide native scrollbar of the track */ 268 | .timeline-track::-webkit-scrollbar { width: 0; height: 0; } 269 | .timeline-track { scrollbar-width: none; } 270 | 271 | /* Left-side slider to control timeline scroll (visual, low-key) */ 272 | .timeline-slider { 273 | position: absolute; 274 | left: 2px; 275 | top: var(--timeline-track-padding); 276 | bottom: var(--timeline-track-padding); 277 | width: 6px; 278 | pointer-events: auto; /* allow handle to capture */ 279 | opacity: 0; 280 | transition: opacity 180ms ease; 281 | z-index: 10; 282 | } 283 | .timeline-slider.visible { opacity: 1; } 284 | .timeline-slider::before { 285 | content: ''; 286 | position: absolute; 287 | left: 2px; 288 | top: 0; bottom: 0; 289 | width: 2px; 290 | background: rgba(0,0,0,0.08); 291 | border-radius: 9999px; 292 | } 293 | html.dark .timeline-slider::before, 294 | body.dark .timeline-slider::before, 295 | [data-theme="dark"] .timeline-slider::before, 296 | [data-color-mode="dark"] .timeline-slider::before, 297 | .dark-theme .timeline-slider::before, 298 | body.dark-theme .timeline-slider::before, 299 | .theme-host.dark-theme .timeline-slider::before { background: rgba(255,255,255,0.10); } 300 | 301 | .timeline-slider .timeline-handle { 302 | position: absolute; 303 | left: 0; 304 | width: 6px; 305 | height: 24px; /* updated by JS */ 306 | background: rgba(86,132,209,0.28); /* Gemini blue */ 307 | border-radius: 9999px; 308 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 309 | pointer-events: auto; 310 | cursor: grab; 311 | transition: background-color 120ms ease, opacity 120ms ease; 312 | } 313 | .timeline-slider .timeline-handle:hover { background: rgba(86,132,209,0.45); } 314 | .timeline-slider .timeline-handle:active { cursor: grabbing; } 315 | /* External left-side slider (outside the timeline bar) */ 316 | .timeline-left-slider { 317 | position: fixed; 318 | /* left set by JS using bar's rect (to the left of the bar) */ 319 | top: 0; /* updated by JS */ 320 | width: 12px; /* hit area; visual line narrower */ 321 | height: 160px; /* updated by JS */ 322 | opacity: 0; 323 | transition: opacity 180ms ease; 324 | z-index: 2147483646; /* align with bar */ 325 | pointer-events: none; 326 | } 327 | .timeline-left-slider.visible { opacity: 1; pointer-events: auto; } 328 | .timeline-left-slider::before { 329 | content: ''; 330 | position: absolute; 331 | left: 5px; /* center thin track inside 12px hit area */ 332 | top: 0; bottom: 0; 333 | width: 2px; 334 | background: rgba(0,0,0,0.08); 335 | border-radius: 9999px; 336 | } 337 | html.dark .timeline-left-slider::before, 338 | body.dark .timeline-left-slider::before, 339 | [data-theme="dark"] .timeline-left-slider::before, 340 | [data-color-mode="dark"] .timeline-left-slider::before, 341 | .dark-theme .timeline-left-slider::before, 342 | body.dark-theme .timeline-left-slider::before, 343 | .theme-host.dark-theme .timeline-left-slider::before { background: rgba(255,255,255,0.10); } 344 | .timeline-left-slider .timeline-left-handle { 345 | position: absolute; 346 | left: 2px; /* centered for 8px width inside 12px hit area */ 347 | width: 8px; 348 | height: 22px; /* fixed, concise */ 349 | background: rgba(86,132,209,0.28); 350 | border-radius: 9999px; 351 | box-shadow: 0 1px 2px rgba(0,0,0,0.15); 352 | pointer-events: auto; 353 | cursor: grab; 354 | transition: background-color 120ms ease; 355 | } 356 | .timeline-left-slider .timeline-left-handle:hover { background: rgba(86,132,209,0.45); } 357 | .timeline-left-slider .timeline-left-handle:active { cursor: grabbing; } 358 | 359 | /* Dark theme handle colors for better contrast */ 360 | html.dark .timeline-slider .timeline-handle, 361 | body.dark .timeline-slider .timeline-handle, 362 | [data-theme="dark"] .timeline-slider .timeline-handle, 363 | [data-color-mode="dark"] .timeline-slider .timeline-handle, 364 | .dark-theme .timeline-slider .timeline-handle, 365 | body.dark-theme .timeline-slider .timeline-handle, 366 | .theme-host.dark-theme .timeline-slider .timeline-handle { background: rgba(125,168,255,0.32); } 367 | html.dark .timeline-slider .timeline-handle:hover, 368 | body.dark .timeline-slider .timeline-handle:hover, 369 | [data-theme="dark"] .timeline-slider .timeline-handle:hover, 370 | [data-color-mode="dark"] .timeline-slider .timeline-handle:hover, 371 | .dark-theme .timeline-slider .timeline-handle:hover, 372 | body.dark-theme .timeline-slider .timeline-handle:hover, 373 | .theme-host.dark-theme .timeline-slider .timeline-handle:hover { background: rgba(125,168,255,0.50); } 374 | 375 | html.dark .timeline-left-slider .timeline-left-handle, 376 | body.dark .timeline-left-slider .timeline-left-handle, 377 | [data-theme="dark"] .timeline-left-slider .timeline-left-handle, 378 | [data-color-mode="dark"] .timeline-left-slider .timeline-left-handle, 379 | .dark-theme .timeline-left-slider .timeline-left-handle, 380 | body.dark-theme .timeline-left-slider .timeline-left-handle, 381 | .theme-host.dark-theme .timeline-left-slider .timeline-left-handle { background: rgba(125,168,255,0.32); } 382 | html.dark .timeline-left-slider .timeline-left-handle:hover, 383 | body.dark .timeline-left-slider .timeline-left-handle:hover, 384 | [data-theme="dark"] .timeline-left-slider .timeline-left-handle:hover, 385 | [data-color-mode="dark"] .timeline-left-slider .timeline-left-handle:hover, 386 | .dark-theme .timeline-left-slider .timeline-left-handle:hover, 387 | body.dark-theme .timeline-left-slider .timeline-left-handle:hover, 388 | .theme-host.dark-theme .timeline-left-slider .timeline-left-handle:hover { background: rgba(125,168,255,0.50); } 389 | -------------------------------------------------------------------------------- /extension/content-gemini.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | // --- Stable selectors for Gemini --- 3 | // User message anchors (broadened to handle variants without data-turn-id) 4 | // Prefer bubble class, fall back to right-aligned container or custom element root 5 | const SEL_USER_BUBBLE = [ 6 | '.user-query-bubble-with-background', 7 | '.user-query-container.right-align-content', 8 | 'user-query' 9 | ].join(','); 10 | // Known scroll areas on Gemini 11 | const SEL_SCROLL_PRIMARY = '#chat-history.chat-history-scroll-container'; 12 | const SEL_SCROLL_ALT = '[data-test-id="chat-history-container"].chat-history'; 13 | 14 | // --- Phase 1: route and toggles --- 15 | function isConversationRouteGemini(pathname = location.pathname) { 16 | try { 17 | const segs = String(pathname || '').split('/').filter(Boolean); 18 | // Support /app/ 19 | const iApp = segs.indexOf('app'); 20 | if (iApp !== -1) { 21 | const slug = segs[iApp + 1]; 22 | return typeof slug === 'string' && slug.length > 0 && /^[A-Za-z0-9_-]+$/.test(slug); 23 | } 24 | // Support /gem/.../ 25 | const iGem = segs.indexOf('gem'); 26 | if (iGem !== -1 && segs.length > iGem + 1) { 27 | const last = segs[segs.length - 1]; 28 | return typeof last === 'string' && last.length > 0 && /^[A-Za-z0-9_-]+$/.test(last); 29 | } 30 | return false; 31 | } catch { return false; } 32 | } 33 | 34 | function extractConversationIdFromPath(pathname = location.pathname) { 35 | try { 36 | const segs = String(pathname || '').split('/').filter(Boolean); 37 | // /app/ 38 | const iApp = segs.indexOf('app'); 39 | if (iApp !== -1) { 40 | const slug = segs[iApp + 1]; 41 | return (slug && /^[A-Za-z0-9_-]+$/.test(slug)) ? slug : null; 42 | } 43 | // /gem/.../ → take the last segment after 'gem' 44 | const iGem = segs.indexOf('gem'); 45 | if (iGem !== -1 && segs.length > iGem + 1) { 46 | const tail = segs.slice(iGem + 1); 47 | let last = tail[tail.length - 1]; 48 | if (last && /^[A-Za-z0-9_-]+$/.test(last)) return last; 49 | // Fallback: scan from end for an id-like slug 50 | for (let j = tail.length - 1; j >= 0; j--) { 51 | if (/^[A-Za-z0-9_-]+$/.test(tail[j])) return tail[j]; 52 | } 53 | return tail[tail.length - 1] || null; 54 | } 55 | return null; 56 | } catch { return null; } 57 | } 58 | 59 | function waitForElement(selector, timeoutMs = 5000) { 60 | return new Promise((resolve) => { 61 | const el = document.querySelector(selector); 62 | if (el) return resolve(el); 63 | const obs = new MutationObserver(() => { 64 | const n = document.querySelector(selector); 65 | if (n) { 66 | try { obs.disconnect(); } catch {} 67 | resolve(n); 68 | } 69 | }); 70 | try { obs.observe(document.body, { childList: true, subtree: true }); } catch {} 71 | setTimeout(() => { try { obs.disconnect(); } catch {} resolve(null); }, timeoutMs); 72 | }); 73 | } 74 | 75 | // --- Phase 2: scrollable detection & binding helpers --- 76 | function isElementScrollable(el) { 77 | if (!el) return false; 78 | try { 79 | const cs = getComputedStyle(el); 80 | const oy = (cs.overflowY || '').toLowerCase(); 81 | const ok = oy === 'auto' || oy === 'scroll' || oy === 'overlay'; 82 | if (!ok && el !== document.scrollingElement && el !== document.documentElement && el !== document.body) return false; 83 | if ((el.scrollHeight - el.clientHeight) > 4) return true; 84 | const prev = el.scrollTop; 85 | el.scrollTop = prev + 1; 86 | const changed = el.scrollTop !== prev; 87 | el.scrollTop = prev; 88 | return changed; 89 | } catch { return false; } 90 | } 91 | 92 | function getScrollableAncestor(startEl) { 93 | // Prefer site-provided containers if they actually scroll and relate to conversation 94 | try { 95 | const primary = document.querySelector(SEL_SCROLL_PRIMARY); 96 | if (primary && (primary.contains(startEl) || startEl.contains(primary)) && isElementScrollable(primary)) return primary; 97 | } catch {} 98 | try { 99 | const alt = document.querySelector(SEL_SCROLL_ALT); 100 | if (alt && (alt.contains(startEl) || startEl.contains(alt)) && isElementScrollable(alt)) return alt; 101 | } catch {} 102 | // Then climb ancestors 103 | let el = startEl; 104 | while (el && el !== document.body) { 105 | if (isElementScrollable(el)) return el; 106 | el = el.parentElement; 107 | } 108 | const docScroll = document.scrollingElement || document.documentElement || document.body; 109 | return isElementScrollable(docScroll) ? docScroll : (document.documentElement || document.body); 110 | } 111 | 112 | // Find the lowest common ancestor that contains all user bubbles (robust root) 113 | function findConversationRootFromFirst(firstMsg) { 114 | if (!firstMsg) return null; 115 | try { 116 | const all = Array.from(document.querySelectorAll(SEL_USER_BUBBLE)); 117 | let node = firstMsg.parentElement; 118 | while (node && node !== document.body) { 119 | let allInside = true; 120 | for (let i = 0; i < all.length; i++) { 121 | if (!node.contains(all[i])) { allInside = false; break; } 122 | } 123 | if (allInside) return node; 124 | node = node.parentElement; 125 | } 126 | } catch {} 127 | return firstMsg.parentElement || null; 128 | } 129 | 130 | // --- Phase 3: minimal timeline UI manager (scaffold only) --- 131 | class GeminiTimelineScaffold { 132 | constructor() { 133 | this.conversationContainer = null; 134 | this.scrollContainer = null; 135 | this.timelineBar = null; 136 | this.track = null; 137 | this.trackContent = null; 138 | this.ui = { slider: null, sliderHandle: null, tooltip: null }; 139 | this.conversationId = null; 140 | // Phase 4: markers + endpoint mapping state 141 | this.markers = []; 142 | this.firstOffset = 0; 143 | this.spanPx = 1; 144 | // Phase 5: long canvas + virtualization 145 | this.contentHeight = 0; 146 | this.yPositions = []; 147 | this.visibleRange = { start: 0, end: -1 }; 148 | this.usePixelTop = false; 149 | this._cssVarTopSupported = null; 150 | // Phase 6: interactions + linking 151 | this.onScroll = null; 152 | this.scrollRafId = null; 153 | this.activeIdx = -1; 154 | this.lastActiveChangeTime = 0; 155 | this.minActiveChangeInterval = 120; 156 | this.pendingActiveIdx = null; 157 | this.activeChangeTimer = null; 158 | // Slider interaction state 159 | this.sliderDragging = false; 160 | this.sliderFadeTimer = null; 161 | this.sliderFadeDelay = 1000; 162 | this.sliderAlwaysVisible = false; 163 | this.sliderStartClientY = 0; 164 | this.sliderStartTop = 0; 165 | // Delegated handlers (stable refs for add/remove) 166 | this.onTimelineBarClick = null; 167 | this.onTimelineWheel = null; 168 | this.onBarEnter = null; 169 | this.onBarLeave = null; 170 | this.onSliderEnter = null; 171 | this.onSliderLeave = null; 172 | this.onSliderDown = null; 173 | this.onSliderMove = null; 174 | this.onSliderUp = null; 175 | this.onWindowResize = null; 176 | // Phase 7: tooltip + truncation 177 | this.measureEl = null; 178 | this.tooltipHideDelay = 100; 179 | this.tooltipHideTimer = null; 180 | this.showRafId = null; 181 | this.truncateCache = new Map(); 182 | // Phase 8: stars + long-press 183 | this.starred = new Set(); 184 | this.onStorage = null; 185 | this.longPressDuration = 550; 186 | this.longPressMoveTolerance = 6; 187 | this.longPressTimer = null; 188 | this.pressStartPos = null; 189 | this.pressTargetDot = null; 190 | this.suppressClickUntil = 0; 191 | // Phase 9: theme/viewport/resize observers 192 | this.themeObserver = null; 193 | this.resizeObserver = null; 194 | this.onVisualViewportResize = null; 195 | // Visibility optimization 196 | this.intersectionObserver = null; 197 | this.visibleUserTurns = new Set(); 198 | this.markerIndexByEl = new Map(); 199 | // Phase 9+: content mutation + debounced rebuild 200 | this.mutationObserver = null; 201 | this.rebuildTimer = null; 202 | } 203 | 204 | async init() { 205 | // Wait until we see at least one user bubble before wiring 206 | const first = await waitForElement(SEL_USER_BUBBLE, 5000); 207 | if (!first) return; 208 | // Bind conversation root & scroll container 209 | const root = findConversationRootFromFirst(first); 210 | this.conversationContainer = root || first.parentElement || document.body; 211 | this.scrollContainer = getScrollableAncestor(this.conversationContainer); 212 | this.conversationId = extractConversationIdFromPath(location.pathname); 213 | // Inject UI scaffold (no logic yet) 214 | this.injectUI(); 215 | // Load stars for this conversation (Phase 8) 216 | try { this.loadStars(); } catch {} 217 | // Build initial markers and compute geometry + virtualization (Phase 4–5) 218 | try { this.rebuildMarkersPhase4(); } catch {} 219 | try { this.updateTimelineGeometry(); } catch {} 220 | try { this.updateVirtualRangeAndRender(); } catch {} 221 | // Keep virtual window updated when timeline track scrolls 222 | try { this.track.addEventListener('scroll', () => this.updateVirtualRangeAndRender(), { passive: true }); } catch {} 223 | // Phase 6: wire linking + interactions 224 | try { this.attachScrollSync(); } catch {} 225 | try { this.attachInteractions(); } catch {} 226 | // Visibility observer (IntersectionObserver) 227 | try { this.attachIntersectionObserver(); } catch {} 228 | try { window.addEventListener('resize', this.onWindowResize = () => { 229 | // Reposition tooltip if visible 230 | try { 231 | if (this.ui?.tooltip?.classList.contains('visible')) { 232 | const activeDot = this.timelineBar?.querySelector?.('.timeline-dot:hover, .timeline-dot:focus'); 233 | if (activeDot) { 234 | const tip = this.ui.tooltip; 235 | tip.classList.remove('visible'); 236 | const p = this.computePlacementInfo(activeDot); 237 | const text = (activeDot.getAttribute('aria-label') || '').trim(); 238 | const layout = this.truncateToThreeLines(text, p.width, true); 239 | tip.textContent = layout.text; 240 | this.placeTooltipAt(activeDot, p.placement, p.width, layout.height); 241 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 242 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 243 | } 244 | } 245 | } catch {} 246 | this.updateTimelineGeometry(); 247 | this.updateVirtualRangeAndRender(); 248 | this.syncTimelineTrackToMain(); 249 | this.updateSlider(); 250 | try { this.truncateCache?.clear(); } catch {} 251 | }); } catch {} 252 | // Phase 9: observe theme attributes on html/body 253 | try { 254 | if (!this.themeObserver) { 255 | this.themeObserver = new MutationObserver(() => { 256 | try { 257 | // Reposition tooltip if visible 258 | if (this.ui?.tooltip?.classList.contains('visible')) { 259 | const activeDot = this.timelineBar?.querySelector?.('.timeline-dot:hover, .timeline-dot:focus'); 260 | if (activeDot) { 261 | const tip = this.ui.tooltip; tip.classList.remove('visible'); 262 | const p = this.computePlacementInfo(activeDot); 263 | const text = (activeDot.getAttribute('aria-label') || '').trim(); 264 | const layout = this.truncateToThreeLines(text, p.width, true); 265 | tip.textContent = layout.text; 266 | this.placeTooltipAt(activeDot, p.placement, p.width, layout.height); 267 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 268 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 269 | } 270 | } 271 | } catch {} 272 | this.updateTimelineGeometry(); 273 | this.updateVirtualRangeAndRender(); 274 | this.syncTimelineTrackToMain(); 275 | this.updateSlider(); 276 | try { this.truncateCache?.clear(); } catch {} 277 | }); 278 | } 279 | const attrs = ['class','data-theme','data-color-mode','data-color-scheme']; 280 | try { this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: attrs }); } catch {} 281 | try { this.themeObserver.observe(document.body, { attributes: true, attributeFilter: attrs }); } catch {} 282 | } catch {} 283 | // Phase 9: ResizeObserver on timeline bar 284 | try { 285 | if (!this.resizeObserver && this.timelineBar) { 286 | this.resizeObserver = new ResizeObserver(() => { 287 | this.updateTimelineGeometry(); 288 | this.updateVirtualRangeAndRender(); 289 | this.syncTimelineTrackToMain(); 290 | this.updateSlider(); 291 | }); 292 | try { this.resizeObserver.observe(this.timelineBar); } catch {} 293 | } 294 | } catch {} 295 | // Phase 9: visual viewport resize 296 | try { 297 | if (window.visualViewport && !this.onVisualViewportResize) { 298 | this.onVisualViewportResize = () => { 299 | this.updateTimelineGeometry(); 300 | this.updateVirtualRangeAndRender(); 301 | this.syncTimelineTrackToMain(); 302 | this.updateSlider(); 303 | try { this.truncateCache?.clear(); } catch {} 304 | // Reposition tooltip if visible 305 | try { 306 | if (this.ui?.tooltip?.classList.contains('visible')) { 307 | const activeDot = this.timelineBar?.querySelector?.('.timeline-dot:hover, .timeline-dot:focus'); 308 | if (activeDot) { 309 | const tip = this.ui.tooltip; tip.classList.remove('visible'); 310 | const p = this.computePlacementInfo(activeDot); 311 | const text = (activeDot.getAttribute('aria-label') || '').trim(); 312 | const layout = this.truncateToThreeLines(text, p.width, true); 313 | tip.textContent = layout.text; 314 | this.placeTooltipAt(activeDot, p.placement, p.width, layout.height); 315 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 316 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 317 | } 318 | } 319 | } catch {} 320 | }; 321 | try { window.visualViewport.addEventListener('resize', this.onVisualViewportResize); } catch {} 322 | } 323 | } catch {} 324 | try { console.debug('[GeminiTimeline] Phase 3 injected UI scaffold'); } catch {} 325 | // Phase 8: cross-tab star sync 326 | this.onStorage = (e) => { 327 | try { 328 | if (!e || e.storageArea !== localStorage) return; 329 | const cid = this.conversationId; 330 | if (!cid) return; 331 | const expectedKey = `geminiTimelineStars:${cid}`; 332 | if (e.key !== expectedKey) return; 333 | let nextArr = []; 334 | try { nextArr = JSON.parse(e.newValue || '[]') || []; } catch { nextArr = []; } 335 | const nextSet = new Set(nextArr.map(x => String(x))); 336 | if (nextSet.size === this.starred.size) { 337 | let same = true; for (const id of this.starred) { if (!nextSet.has(id)) { same = false; break; } } 338 | if (same) return; 339 | } 340 | this.starred = nextSet; 341 | for (let i = 0; i < this.markers.length; i++) { 342 | const m = this.markers[i]; 343 | const want = this.starred.has(m.id); 344 | if (m.starred !== want) { 345 | m.starred = want; 346 | if (m.dotElement) { 347 | try { m.dotElement.classList.toggle('starred', m.starred); m.dotElement.setAttribute('aria-pressed', m.starred ? 'true' : 'false'); } catch {} 348 | } 349 | } 350 | } 351 | try { 352 | if (this.ui.tooltip?.classList.contains('visible')) { 353 | const currentDot = this.timelineBar.querySelector('.timeline-dot:hover, .timeline-dot:focus'); 354 | if (currentDot) this.refreshTooltipForDot(currentDot); 355 | } 356 | } catch {} 357 | } catch {} 358 | }; 359 | try { window.addEventListener('storage', this.onStorage); } catch {} 360 | 361 | // Content mutation observer (append new messages, container swaps) 362 | try { this.attachContentObserver(); } catch {} 363 | } 364 | 365 | injectUI() { 366 | // Bar 367 | let bar = document.querySelector('.chatgpt-timeline-bar'); 368 | if (!bar) { 369 | bar = document.createElement('div'); 370 | bar.className = 'chatgpt-timeline-bar'; 371 | document.body.appendChild(bar); 372 | } 373 | this.timelineBar = bar; 374 | // Track and content 375 | let track = this.timelineBar.querySelector('.timeline-track'); 376 | if (!track) { 377 | track = document.createElement('div'); 378 | track.className = 'timeline-track'; 379 | this.timelineBar.appendChild(track); 380 | } 381 | let content = track.querySelector('.timeline-track-content'); 382 | if (!content) { 383 | content = document.createElement('div'); 384 | content.className = 'timeline-track-content'; 385 | track.appendChild(content); 386 | } 387 | this.track = track; 388 | this.trackContent = content; 389 | // External left slider (visual-only at this phase) 390 | let slider = document.querySelector('.timeline-left-slider'); 391 | if (!slider) { 392 | slider = document.createElement('div'); 393 | slider.className = 'timeline-left-slider'; 394 | const handle = document.createElement('div'); 395 | handle.className = 'timeline-left-handle'; 396 | slider.appendChild(handle); 397 | document.body.appendChild(slider); 398 | } 399 | this.ui.slider = slider; 400 | this.ui.sliderHandle = slider.querySelector('.timeline-left-handle'); 401 | // Tooltip element (shared id for a11y) 402 | if (!this.ui.tooltip) { 403 | const tip = document.createElement('div'); 404 | tip.className = 'timeline-tooltip'; 405 | tip.setAttribute('role', 'tooltip'); 406 | tip.id = 'chatgpt-timeline-tooltip'; 407 | tip.setAttribute('aria-hidden', 'true'); 408 | try { tip.style.boxSizing = 'border-box'; } catch {} 409 | document.body.appendChild(tip); 410 | this.ui.tooltip = tip; 411 | // Create hidden measurer for truncation 412 | try { 413 | const m = document.createElement('div'); 414 | m.setAttribute('aria-hidden', 'true'); 415 | m.style.position = 'fixed'; 416 | m.style.left = '-9999px'; 417 | m.style.top = '0px'; 418 | m.style.visibility = 'hidden'; 419 | m.style.pointerEvents = 'none'; 420 | m.style.boxSizing = 'border-box'; 421 | const cs = getComputedStyle(tip); 422 | Object.assign(m.style, { 423 | backgroundColor: cs.backgroundColor, 424 | color: cs.color, 425 | fontFamily: cs.fontFamily, 426 | fontSize: cs.fontSize, 427 | lineHeight: cs.lineHeight, 428 | padding: cs.padding, 429 | border: cs.border, 430 | borderRadius: cs.borderRadius, 431 | whiteSpace: 'normal', 432 | wordBreak: 'break-word', 433 | maxWidth: 'none', 434 | display: 'block', 435 | transform: 'none', 436 | transition: 'none' 437 | }); 438 | try { m.style.webkitLineClamp = 'unset'; } catch {} 439 | document.body.appendChild(m); 440 | this.measureEl = m; 441 | } catch {} 442 | } 443 | } 444 | 445 | destroy() { 446 | // Remove listeners 447 | try { this.timelineBar?.removeEventListener('click', this.onTimelineBarClick); } catch {} 448 | try { this.timelineBar?.removeEventListener('wheel', this.onTimelineWheel); } catch {} 449 | try { this.timelineBar?.removeEventListener('pointerenter', this.onBarEnter); } catch {} 450 | try { this.timelineBar?.removeEventListener('pointerleave', this.onBarLeave); } catch {} 451 | try { this.ui.slider?.removeEventListener('pointerenter', this.onSliderEnter); } catch {} 452 | try { this.ui.slider?.removeEventListener('pointerleave', this.onSliderLeave); } catch {} 453 | try { this.ui.sliderHandle?.removeEventListener('pointerdown', this.onSliderDown); } catch {} 454 | try { this.timelineBar?.removeEventListener('pointerdown', this.onPointerDown); } catch {} 455 | try { this.timelineBar?.removeEventListener('pointerleave', this.onPointerLeave); } catch {} 456 | try { window.removeEventListener('pointermove', this.onPointerMove); } catch {} 457 | try { window.removeEventListener('pointerup', this.onPointerUp); } catch {} 458 | try { window.removeEventListener('pointercancel', this.onPointerCancel); } catch {} 459 | try { window.removeEventListener('resize', this.onWindowResize); } catch {} 460 | try { this.scrollContainer?.removeEventListener('scroll', this.onScroll); } catch {} 461 | try { window.removeEventListener('scroll', this.onScroll); } catch {} 462 | try { window.removeEventListener('storage', this.onStorage); } catch {} 463 | try { this.mutationObserver?.disconnect(); } catch {} 464 | this.mutationObserver = null; 465 | if (this.rebuildTimer) { try { clearTimeout(this.rebuildTimer); } catch {} this.rebuildTimer = null; } 466 | try { this.timelineBar?.remove(); } catch {} 467 | try { this.ui.slider?.remove(); } catch {} 468 | try { this.ui.tooltip?.remove(); } catch {} 469 | this.timelineBar = null; 470 | this.track = null; 471 | this.trackContent = null; 472 | this.ui.slider = null; 473 | this.ui.sliderHandle = null; 474 | this.ui.tooltip = null; 475 | this.conversationContainer = null; 476 | this.scrollContainer = null; 477 | if (this.tooltipHideTimer) { try { clearTimeout(this.tooltipHideTimer); } catch {} this.tooltipHideTimer = null; } 478 | if (this.sliderFadeTimer) { try { clearTimeout(this.sliderFadeTimer); } catch {} this.sliderFadeTimer = null; } 479 | if (this.longPressTimer) { try { clearTimeout(this.longPressTimer); } catch {} this.longPressTimer = null; } 480 | if (this.activeChangeTimer) { try { clearTimeout(this.activeChangeTimer); } catch {} this.activeChangeTimer = null; } 481 | if (this.scrollRafId !== null) { try { cancelAnimationFrame(this.scrollRafId); } catch {} this.scrollRafId = null; } 482 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 483 | try { this.measureEl?.remove(); } catch {} 484 | } 485 | 486 | // --- Phase 9+: content observer & rebind --- 487 | attachContentObserver() { 488 | if (!this.conversationContainer) return; 489 | try { this.mutationObserver?.disconnect(); } catch {} 490 | this.mutationObserver = new MutationObserver(() => { 491 | try { this.ensureContainersUpToDate(); } catch {} 492 | if (this.rebuildTimer) { try { clearTimeout(this.rebuildTimer); } catch {} } 493 | this.rebuildTimer = setTimeout(() => { this.rebuildAndRefresh(); }, 250); 494 | }); 495 | try { this.mutationObserver.observe(this.conversationContainer, { childList: true, subtree: true }); } catch {} 496 | } 497 | 498 | rebuildAndRefresh() { 499 | try { this.rebuildMarkersPhase4(); } catch {} 500 | try { this.updateTimelineGeometry(); } catch {} 501 | try { this.updateVirtualRangeAndRender(); } catch {} 502 | try { this.syncTimelineTrackToMain(); } catch {} 503 | try { this.updateSlider(); } catch {} 504 | try { this.updateIntersectionObserverTargets(); } catch {} 505 | // Ensure active index and UI are applied after a rebuild 506 | try { this.computeActiveByScroll(); } catch {} 507 | try { this.updateActiveDotUI(); } catch {} 508 | } 509 | 510 | ensureContainersUpToDate() { 511 | try { 512 | const first = document.querySelector(SEL_USER_BUBBLE); 513 | if (!first) return; 514 | const newRoot = findConversationRootFromFirst(first); 515 | if (newRoot && newRoot !== this.conversationContainer) { 516 | this.rebindConversationContainer(newRoot); 517 | } 518 | } catch {} 519 | } 520 | 521 | rebindConversationContainer(newConv) { 522 | // Detach old listeners bound to old containers 523 | try { this.scrollContainer?.removeEventListener('scroll', this.onScroll); } catch {} 524 | try { window.removeEventListener('scroll', this.onScroll); } catch {} 525 | try { this.mutationObserver?.disconnect(); } catch {} 526 | 527 | // Bind new containers 528 | this.conversationContainer = newConv; 529 | this.scrollContainer = getScrollableAncestor(this.conversationContainer); 530 | 531 | // Re-attach scroll sync & observer 532 | this.attachScrollSync(); 533 | this.attachContentObserver(); 534 | // Rebuild markers and refresh geometry 535 | this.rebuildAndRefresh(); 536 | } 537 | 538 | // --- Phase 4: markers + endpoint mapping --- 539 | clamp01(x) { return Math.max(0, Math.min(1, x)); } 540 | 541 | extractUserSummary(el) { 542 | try { 543 | const line = el.querySelector('.query-text .query-text-line'); 544 | if (line && line.textContent) return String(line.textContent).replace(/\s+/g, ' ').trim(); 545 | } catch {} 546 | try { return String(el.textContent || '').replace(/\s+/g, ' ').trim(); } catch { return ''; } 547 | } 548 | 549 | buildStableHashFromUser(el) { 550 | try { 551 | const t = this.extractUserSummary(el) || ''; 552 | let h = 2166136261 >>> 0; // FNV-1a like 553 | for (let i = 0; i < t.length; i++) { h ^= t.charCodeAt(i); h = Math.imul(h, 0x01000193); } 554 | return (h >>> 0).toString(36); 555 | } catch { return Math.random().toString(36).slice(2, 8); } 556 | } 557 | 558 | hasUserText(el) { 559 | try { 560 | const line = el.querySelector('.query-text .query-text-line'); 561 | if (line && typeof line.textContent === 'string' && line.textContent.trim().length > 0) return true; 562 | } catch {} 563 | try { 564 | const t = (el.textContent || '').replace(/\s+/g, ' ').trim(); 565 | return t.length > 0; 566 | } catch { return false; } 567 | } 568 | 569 | collectUserNodes() { 570 | const root = this.conversationContainer || document; 571 | // Priority 1: bubble itself 572 | try { 573 | const bubbles = Array.from(root.querySelectorAll('.user-query-bubble-with-background')).filter(n => this.hasUserText(n)); 574 | if (bubbles.length) return bubbles; 575 | } catch {} 576 | // Priority 2: right-aligned user container 577 | try { 578 | const rights = Array.from(root.querySelectorAll('.user-query-container.right-align-content')).filter(n => this.hasUserText(n)); 579 | if (rights.length) return rights; 580 | } catch {} 581 | // Priority 3: custom element 582 | try { 583 | const tags = Array.from(root.querySelectorAll('user-query')).filter(n => this.hasUserText(n)); 584 | return tags; 585 | } catch { return []; } 586 | } 587 | 588 | rebuildMarkersPhase4() { 589 | if (!this.conversationContainer || !this.trackContent || !this.scrollContainer) return; 590 | // Clear previous dots 591 | try { this.trackContent.querySelectorAll('.timeline-dot').forEach(n => n.remove()); } catch {} 592 | 593 | const nodes = this.collectUserNodes(); 594 | if (nodes.length === 0) return; 595 | 596 | // Compute absolute Y relative to scroll container 597 | const cRect = this.scrollContainer.getBoundingClientRect(); 598 | const st = this.scrollContainer.scrollTop; 599 | const ys = nodes.map(el => { 600 | const r = el.getBoundingClientRect(); 601 | return (r.top - cRect.top) + st; 602 | }); 603 | const firstY = ys[0]; 604 | const lastY = (ys.length > 1) ? ys[ys.length - 1] : (firstY + 1); 605 | const span = Math.max(1, lastY - firstY); 606 | this.firstOffset = firstY; 607 | this.spanPx = span; 608 | 609 | const seen = new Map(); 610 | try { this.markerIndexByEl?.clear(); } catch {} 611 | this.markers = nodes.map((el, i) => { 612 | const y = ys[i]; 613 | const n0 = this.clamp01((y - firstY) / span); 614 | let id = null; 615 | try { id = el.getAttribute('data-turn-id') || null; } catch {} 616 | if (!id) { 617 | const base = this.buildStableHashFromUser(el); 618 | const cnt = (seen.get(base) || 0) + 1; seen.set(base, cnt); 619 | id = `${base}-${cnt}`; 620 | try { el.setAttribute('data-turn-id', id); } catch {} 621 | } 622 | const starred = this.starred.has(String(id)); 623 | const marker = { id, el, n: n0, baseN: n0, dotElement: null, starred }; 624 | try { this.markerIndexByEl?.set(el, i); } catch {} 625 | return marker; 626 | }); 627 | 628 | try { console.debug(`[GeminiTimeline] Phase 4 markers=${this.markers.length}, spanPx=${this.spanPx}`); } catch {} 629 | } 630 | 631 | // --- Phase 5: long canvas geometry + virtualization --- 632 | getCSSVarNumber(el, name, fallback) { 633 | try { 634 | const v = getComputedStyle(el).getPropertyValue(name).trim(); 635 | const n = parseFloat(v); 636 | return Number.isFinite(n) ? n : fallback; 637 | } catch { return fallback; } 638 | } 639 | 640 | applyMinGap(positions, minTop, maxTop, gap) { 641 | const n = positions.length; 642 | if (n === 0) return positions; 643 | const out = positions.slice(); 644 | out[0] = Math.max(minTop, Math.min(positions[0], maxTop)); 645 | for (let i = 1; i < n; i++) { 646 | const minAllowed = out[i - 1] + gap; 647 | out[i] = Math.max(positions[i], minAllowed); 648 | } 649 | if (out[n - 1] > maxTop) { 650 | out[n - 1] = maxTop; 651 | for (let i = n - 2; i >= 0; i--) { 652 | const maxAllowed = out[i + 1] - gap; 653 | out[i] = Math.min(out[i], maxAllowed); 654 | } 655 | if (out[0] < minTop) { 656 | out[0] = minTop; 657 | for (let i = 1; i < n; i++) { 658 | const minAllowed = out[i - 1] + gap; 659 | out[i] = Math.max(out[i], minAllowed); 660 | } 661 | } 662 | } 663 | for (let i = 0; i < n; i++) { 664 | if (out[i] < minTop) out[i] = minTop; 665 | if (out[i] > maxTop) out[i] = maxTop; 666 | } 667 | return out; 668 | } 669 | 670 | detectCssVarTopSupport(pad, usableC) { 671 | try { 672 | if (!this.trackContent) return false; 673 | const test = document.createElement('button'); 674 | test.className = 'timeline-dot'; 675 | test.style.visibility = 'hidden'; 676 | test.style.pointerEvents = 'none'; 677 | test.setAttribute('aria-hidden', 'true'); 678 | const expected = pad + 0.5 * usableC; 679 | test.style.setProperty('--n', '0.5'); 680 | this.trackContent.appendChild(test); 681 | const cs = getComputedStyle(test); 682 | const px = parseFloat(cs.top || ''); 683 | test.remove(); 684 | if (!Number.isFinite(px)) return false; 685 | return Math.abs(px - expected) <= 2; 686 | } catch { return false; } 687 | } 688 | 689 | updateTimelineGeometry() { 690 | if (!this.timelineBar || !this.trackContent) return; 691 | const H = this.timelineBar.clientHeight || 0; 692 | const pad = this.getCSSVarNumber(this.timelineBar, '--timeline-track-padding', 16); 693 | const minGap = this.getCSSVarNumber(this.timelineBar, '--timeline-min-gap', 24); 694 | const N = this.markers.length; 695 | const desired = Math.max(H, (N > 0 ? (2 * pad + Math.max(0, N - 1) * minGap) : H)); 696 | this.contentHeight = Math.ceil(desired); 697 | try { this.trackContent.style.height = `${this.contentHeight}px`; } catch {} 698 | 699 | const usableC = Math.max(1, this.contentHeight - 2 * pad); 700 | const desiredY = this.markers.map(m => pad + this.clamp01(m.baseN ?? m.n ?? 0) * usableC); 701 | const adjusted = this.applyMinGap(desiredY, pad, pad + usableC, minGap); 702 | this.yPositions = adjusted; 703 | for (let i = 0; i < N; i++) { 704 | const n = this.clamp01((adjusted[i] - pad) / usableC); 705 | this.markers[i].n = n; 706 | if (this.markers[i].dotElement && !this.usePixelTop) { 707 | try { this.markers[i].dotElement.style.setProperty('--n', String(n)); } catch {} 708 | } 709 | } 710 | if (this._cssVarTopSupported === null) { 711 | this._cssVarTopSupported = this.detectCssVarTopSupport(pad, usableC); 712 | this.usePixelTop = !this._cssVarTopSupported; 713 | } 714 | // Slider visibility hint (appear when scrollable) 715 | const barH = this.timelineBar?.clientHeight || 0; 716 | this.sliderAlwaysVisible = this.contentHeight > barH + 1; 717 | this.updateSlider(); 718 | } 719 | 720 | lowerBound(arr, x) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] < x) lo = mid + 1; else hi = mid; } return lo; } 721 | upperBound(arr, x) { let lo = 0, hi = arr.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] <= x) lo = mid + 1; else hi = mid; } return lo - 1; } 722 | 723 | updateVirtualRangeAndRender() { 724 | if (!this.track || !this.trackContent || this.markers.length === 0) return; 725 | const st = this.track.scrollTop || 0; 726 | const vh = this.track.clientHeight || 0; 727 | const buffer = Math.max(100, vh); 728 | const minY = st - buffer; 729 | const maxY = st + vh + buffer; 730 | const start = this.lowerBound(this.yPositions, minY); 731 | const end = Math.max(start - 1, this.upperBound(this.yPositions, maxY)); 732 | 733 | let prevStart = this.visibleRange.start; 734 | let prevEnd = this.visibleRange.end; 735 | const len = this.markers.length; 736 | if (len > 0) { prevStart = Math.max(0, Math.min(prevStart, len - 1)); prevEnd = Math.max(-1, Math.min(prevEnd, len - 1)); } 737 | if (prevEnd >= prevStart) { 738 | for (let i = prevStart; i < Math.min(start, prevEnd + 1); i++) { 739 | const m = this.markers[i]; 740 | if (m && m.dotElement) { try { m.dotElement.remove(); } catch {} m.dotElement = null; } 741 | } 742 | for (let i = Math.max(end + 1, prevStart); i <= prevEnd; i++) { 743 | const m = this.markers[i]; 744 | if (m && m.dotElement) { try { m.dotElement.remove(); } catch {} m.dotElement = null; } 745 | } 746 | } else { 747 | try { this.trackContent.querySelectorAll('.timeline-dot').forEach(n => n.remove()); } catch {} 748 | this.markers.forEach(m => { m.dotElement = null; }); 749 | } 750 | 751 | const frag = document.createDocumentFragment(); 752 | for (let i = start; i <= end; i++) { 753 | const marker = this.markers[i]; 754 | if (!marker) continue; 755 | if (!marker.dotElement) { 756 | const dot = document.createElement('button'); 757 | dot.className = 'timeline-dot'; 758 | dot.dataset.targetIdx = marker.id; 759 | try { dot.setAttribute('tabindex', '0'); } catch {} 760 | try { dot.setAttribute('aria-label', this.extractUserSummary(marker.el)); } catch {} 761 | try { dot.setAttribute('aria-describedby', 'chatgpt-timeline-tooltip'); } catch {} 762 | if (this.usePixelTop) { dot.style.top = `${Math.round(this.yPositions[i])}px`; } 763 | else { try { dot.style.setProperty('--n', String(marker.n || 0)); } catch {} } 764 | // Apply current active state immediately on creation 765 | try { dot.classList.toggle('active', i === this.activeIdx); } catch {} 766 | try { dot.classList.toggle('starred', !!marker.starred); dot.setAttribute('aria-pressed', marker.starred ? 'true' : 'false'); } catch {} 767 | marker.dotElement = dot; 768 | frag.appendChild(dot); 769 | } else { 770 | if (this.usePixelTop) { marker.dotElement.style.top = `${Math.round(this.yPositions[i])}px`; } 771 | else { try { marker.dotElement.style.setProperty('--n', String(marker.n || 0)); } catch {} } 772 | // Keep active state in sync for already mounted dots 773 | try { marker.dotElement.classList.toggle('active', i === this.activeIdx); } catch {} 774 | try { marker.dotElement.classList.toggle('starred', !!marker.starred); marker.dotElement.setAttribute('aria-pressed', marker.starred ? 'true' : 'false'); } catch {} 775 | } 776 | } 777 | if (frag.childNodes.length) this.trackContent.appendChild(frag); 778 | this.visibleRange = { start, end }; 779 | } 780 | 781 | // --- Phase 6: linking + interactions --- 782 | attachScrollSync() { 783 | if (!this.scrollContainer) return; 784 | this.onScroll = () => this.scheduleScrollSync(); 785 | try { this.scrollContainer.addEventListener('scroll', this.onScroll, { passive: true }); } catch {} 786 | const docScroll = document.scrollingElement || document.documentElement || document.body; 787 | if (this.scrollContainer === docScroll || this.scrollContainer === document.body || this.scrollContainer === document.documentElement) { 788 | try { window.addEventListener('scroll', this.onScroll, { passive: true }); } catch {} 789 | } 790 | this.scheduleScrollSync(); 791 | } 792 | 793 | scheduleScrollSync() { 794 | if (this.scrollRafId !== null) return; 795 | this.scrollRafId = requestAnimationFrame(() => { 796 | this.scrollRafId = null; 797 | this.syncTimelineTrackToMain(); 798 | this.updateVirtualRangeAndRender(); 799 | this.computeActiveByScroll(); 800 | this.updateSlider(); 801 | }); 802 | } 803 | 804 | syncTimelineTrackToMain() { 805 | if (!this.track || !this.scrollContainer || !this.contentHeight) return; 806 | const scrollTop = this.scrollContainer.scrollTop; 807 | const ref = scrollTop + this.scrollContainer.clientHeight * 0.45; 808 | const span = Math.max(1, this.spanPx || 1); 809 | const r = this.clamp01((ref - (this.firstOffset || 0)) / span); 810 | const maxScroll = Math.max(0, this.contentHeight - (this.track.clientHeight || 0)); 811 | const target = Math.round(r * maxScroll); 812 | if (Math.abs((this.track.scrollTop || 0) - target) > 1) this.track.scrollTop = target; 813 | } 814 | 815 | computeActiveByScroll() { 816 | if (!this.scrollContainer || this.markers.length === 0) return; 817 | const containerRect = this.scrollContainer.getBoundingClientRect(); 818 | const scrollTop = this.scrollContainer.scrollTop; 819 | const ref = scrollTop + this.scrollContainer.clientHeight * 0.45; 820 | let active = 0; 821 | if (this.visibleUserTurns && this.visibleUserTurns.size > 0) { 822 | let bestIdx = -1; 823 | let bestScore = Infinity; 824 | for (const el of this.visibleUserTurns) { 825 | const idx = this.markerIndexByEl?.get(el); 826 | if (typeof idx !== 'number') continue; 827 | const m = this.markers[idx]; if (!m) continue; 828 | const top = m.el.getBoundingClientRect().top - containerRect.top + scrollTop; 829 | const dy = ref - top; 830 | const score = (dy >= 0) ? dy : Math.abs(dy) + 10000; 831 | if (score < bestScore) { bestScore = score; bestIdx = idx; } 832 | } 833 | if (bestIdx >= 0) active = bestIdx; else { 834 | for (let i = 0; i < this.markers.length; i++) { 835 | const m = this.markers[i]; 836 | const top = m.el.getBoundingClientRect().top - containerRect.top + scrollTop; 837 | if (top <= ref) active = i; else break; 838 | } 839 | } 840 | } else { 841 | for (let i = 0; i < this.markers.length; i++) { 842 | const m = this.markers[i]; 843 | const top = m.el.getBoundingClientRect().top - containerRect.top + scrollTop; 844 | if (top <= ref) active = i; else break; 845 | } 846 | } 847 | if (this.activeIdx !== active) { 848 | const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 849 | const elapsed = now - this.lastActiveChangeTime; 850 | if (elapsed < this.minActiveChangeInterval) { 851 | this.pendingActiveIdx = active; 852 | if (!this.activeChangeTimer) { 853 | const delay = Math.max(this.minActiveChangeInterval - elapsed, 0); 854 | this.activeChangeTimer = setTimeout(() => { 855 | this.activeChangeTimer = null; 856 | if (typeof this.pendingActiveIdx === 'number' && this.pendingActiveIdx !== this.activeIdx) { 857 | this.activeIdx = this.pendingActiveIdx; 858 | this.updateActiveDotUI(); 859 | this.lastActiveChangeTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 860 | } 861 | this.pendingActiveIdx = null; 862 | }, delay); 863 | } 864 | } else { 865 | this.activeIdx = active; 866 | this.updateActiveDotUI(); 867 | this.lastActiveChangeTime = now; 868 | } 869 | } 870 | } 871 | 872 | // --- Visibility observer helpers --- 873 | attachIntersectionObserver() { 874 | try { this.intersectionObserver?.disconnect(); } catch {} 875 | try { this.visibleUserTurns?.clear(); } catch {} 876 | const opts = { root: this.scrollContainer || null, rootMargin: "-40% 0px -59% 0px", threshold: 0.0 }; 877 | try { 878 | this.intersectionObserver = new IntersectionObserver((entries) => { 879 | for (const entry of entries) { 880 | const el = entry.target; 881 | if (entry.isIntersecting) this.visibleUserTurns.add(el); else this.visibleUserTurns.delete(el); 882 | } 883 | this.scheduleScrollSync(); 884 | }, opts); 885 | } catch { this.intersectionObserver = null; } 886 | this.updateIntersectionObserverTargets(); 887 | } 888 | 889 | updateIntersectionObserverTargets() { 890 | if (!this.intersectionObserver) return; 891 | try { this.intersectionObserver.disconnect(); } catch {} 892 | try { this.visibleUserTurns.clear(); } catch {} 893 | for (let i = 0; i < this.markers.length; i++) { 894 | const el = this.markers[i]?.el; 895 | if (el) { try { this.intersectionObserver.observe(el); } catch {} } 896 | } 897 | } 898 | 899 | updateActiveDotUI() { 900 | for (let i = 0; i < this.markers.length; i++) { 901 | const m = this.markers[i]; 902 | if (m?.dotElement) { try { m.dotElement.classList.toggle('active', i === this.activeIdx); } catch {} } 903 | } 904 | } 905 | 906 | scrollToMessage(targetEl) { 907 | if (!this.scrollContainer || !targetEl) return; 908 | const containerRect = this.scrollContainer.getBoundingClientRect(); 909 | const targetRect = targetEl.getBoundingClientRect(); 910 | const to = targetRect.top - containerRect.top + this.scrollContainer.scrollTop; 911 | const from = this.scrollContainer.scrollTop; 912 | const dist = to - from; 913 | const dur = 500; 914 | let t0 = null; 915 | const ease = (t, b, c, d) => { t /= d/2; if (t < 1) return c/2*t*t + b; t--; return -c/2*(t*(t-2)-1)+b; }; 916 | const step = (ts) => { 917 | if (t0 === null) t0 = ts; 918 | const dt = ts - t0; 919 | const v = ease(dt, from, dist, dur); 920 | this.scrollContainer.scrollTop = v; 921 | if (dt < dur) requestAnimationFrame(step); else this.scrollContainer.scrollTop = to; 922 | }; 923 | requestAnimationFrame(step); 924 | } 925 | 926 | attachInteractions() { 927 | if (!this.timelineBar) return; 928 | // Click: jump to message 929 | this.onTimelineBarClick = (e) => { 930 | const dot = e.target.closest?.('.timeline-dot'); 931 | if (!dot) return; 932 | const now = Date.now(); 933 | if (now < (this.suppressClickUntil || 0)) { try { e.preventDefault(); e.stopPropagation(); } catch {} return; } 934 | const id = dot.dataset.targetIdx; 935 | const m = this.markers.find(x => x.id === id); 936 | if (m?.el) this.scrollToMessage(m.el); 937 | }; 938 | try { this.timelineBar.addEventListener('click', this.onTimelineBarClick); } catch {} 939 | 940 | // Wheel: control main scroll 941 | this.onTimelineWheel = (e) => { 942 | try { e.preventDefault(); } catch {} 943 | const delta = e.deltaY || 0; 944 | this.scrollContainer.scrollTop += delta; 945 | this.scheduleScrollSync(); 946 | this.showSlider(); 947 | }; 948 | try { this.timelineBar.addEventListener('wheel', this.onTimelineWheel, { passive: false }); } catch {} 949 | 950 | // Tooltip interactions 951 | this.onTimelineBarOver = (e) => { const dot = e.target.closest?.('.timeline-dot'); if (dot) this.showTooltipForDot(dot); }; 952 | this.onTimelineBarOut = (e) => { 953 | const fromDot = e.target.closest?.('.timeline-dot'); 954 | const toDot = e.relatedTarget?.closest?.('.timeline-dot'); 955 | if (fromDot && !toDot) this.hideTooltip(); 956 | }; 957 | this.onTimelineBarFocusIn = (e) => { const dot = e.target.closest?.('.timeline-dot'); if (dot) this.showTooltipForDot(dot); }; 958 | this.onTimelineBarFocusOut = (e) => { const dot = e.target.closest?.('.timeline-dot'); if (dot) this.hideTooltip(); }; 959 | try { 960 | this.timelineBar.addEventListener('mouseover', this.onTimelineBarOver); 961 | this.timelineBar.addEventListener('mouseout', this.onTimelineBarOut); 962 | this.timelineBar.addEventListener('focusin', this.onTimelineBarFocusIn); 963 | this.timelineBar.addEventListener('focusout', this.onTimelineBarFocusOut); 964 | } catch {} 965 | 966 | // Slider hover visibility 967 | this.onBarEnter = () => this.showSlider(); 968 | this.onBarLeave = () => this.hideSliderDeferred(); 969 | this.onSliderEnter = () => this.showSlider(); 970 | this.onSliderLeave = () => this.hideSliderDeferred(); 971 | try { 972 | this.timelineBar.addEventListener('pointerenter', this.onBarEnter); 973 | this.timelineBar.addEventListener('pointerleave', this.onBarLeave); 974 | this.ui.slider?.addEventListener('pointerenter', this.onSliderEnter); 975 | this.ui.slider?.addEventListener('pointerleave', this.onSliderLeave); 976 | } catch {} 977 | 978 | // Slider drag 979 | this.onSliderDown = (e) => { 980 | if (!this.ui.sliderHandle || (typeof e.button === 'number' && e.button !== 0)) return; 981 | this.sliderDragging = true; 982 | this.sliderStartClientY = e.clientY; 983 | const rect = this.ui.sliderHandle.getBoundingClientRect(); 984 | this.sliderStartTop = rect.top; 985 | try { window.addEventListener('pointermove', this.onSliderMove = (ev) => this.handleSliderDrag(ev)); } catch {} 986 | this.onSliderUp = () => this.endSliderDrag(); 987 | try { window.addEventListener('pointerup', this.onSliderUp, { passive: true }); } catch {} 988 | this.showSlider(); 989 | }; 990 | try { this.ui.sliderHandle?.addEventListener('pointerdown', this.onSliderDown); } catch {} 991 | 992 | // Long-press star (Phase 8) 993 | this.onPointerDown = (ev) => { 994 | const dot = ev.target.closest?.('.timeline-dot'); 995 | if (!dot) return; 996 | if (typeof ev.button === 'number' && ev.button !== 0) return; 997 | this.cancelLongPress(); 998 | this.pressTargetDot = dot; 999 | this.pressStartPos = { x: ev.clientX, y: ev.clientY }; 1000 | try { dot.classList.add('holding'); } catch {} 1001 | this.longPressTimer = setTimeout(() => { 1002 | this.longPressTimer = null; 1003 | if (!this.pressTargetDot) return; 1004 | const id = this.pressTargetDot.dataset.targetIdx; 1005 | this.toggleStar(id); 1006 | this.suppressClickUntil = Date.now() + 350; 1007 | try { this.refreshTooltipForDot(this.pressTargetDot); } catch {} 1008 | try { this.pressTargetDot.classList.remove('holding'); } catch {} 1009 | }, this.longPressDuration); 1010 | }; 1011 | this.onPointerMove = (ev) => { 1012 | if (!this.pressTargetDot || !this.pressStartPos) return; 1013 | const dx = ev.clientX - this.pressStartPos.x; 1014 | const dy = ev.clientY - this.pressStartPos.y; 1015 | if ((dx*dx + dy*dy) > (this.longPressMoveTolerance*this.longPressMoveTolerance)) this.cancelLongPress(); 1016 | }; 1017 | this.onPointerUp = () => { this.cancelLongPress(); }; 1018 | this.onPointerCancel = () => { this.cancelLongPress(); }; 1019 | this.onPointerLeave = (ev) => { const dot = ev.target.closest?.('.timeline-dot'); if (dot && dot === this.pressTargetDot) this.cancelLongPress(); }; 1020 | try { 1021 | this.timelineBar.addEventListener('pointerdown', this.onPointerDown); 1022 | window.addEventListener('pointermove', this.onPointerMove, { passive: true }); 1023 | window.addEventListener('pointerup', this.onPointerUp, { passive: true }); 1024 | window.addEventListener('pointercancel', this.onPointerCancel, { passive: true }); 1025 | this.timelineBar.addEventListener('pointerleave', this.onPointerLeave); 1026 | } catch {} 1027 | } 1028 | 1029 | updateSlider() { 1030 | if (!this.ui.slider || !this.ui.sliderHandle) return; 1031 | if (!this.contentHeight || !this.timelineBar || !this.track) return; 1032 | const barRect = this.timelineBar.getBoundingClientRect(); 1033 | const barH = barRect.height || 0; 1034 | const pad = this.getCSSVarNumber(this.timelineBar, '--timeline-track-padding', 16); 1035 | const innerH = Math.max(0, barH - 2 * pad); 1036 | if (this.contentHeight <= barH + 1 || innerH <= 0) { 1037 | this.sliderAlwaysVisible = false; 1038 | try { this.ui.slider.classList.remove('visible'); this.ui.slider.style.opacity = ''; } catch {} 1039 | return; 1040 | } 1041 | this.sliderAlwaysVisible = true; 1042 | const railLen = Math.max(120, Math.min(240, Math.floor(barH * 0.45))); 1043 | const railTop = Math.round(barRect.top + pad + (innerH - railLen) / 2); 1044 | const railLeftGap = 8; 1045 | const sliderWidth = 12; 1046 | const left = Math.round(barRect.left - railLeftGap - sliderWidth); 1047 | this.ui.slider.style.left = `${left}px`; 1048 | this.ui.slider.style.top = `${railTop}px`; 1049 | this.ui.slider.style.height = `${railLen}px`; 1050 | 1051 | const handleH = 22; 1052 | const maxTop = Math.max(0, railLen - handleH); 1053 | const range = Math.max(1, this.contentHeight - barH); 1054 | const st = this.track.scrollTop || 0; 1055 | const r = Math.max(0, Math.min(1, st / range)); 1056 | const top = Math.round(r * maxTop); 1057 | this.ui.sliderHandle.style.height = `${handleH}px`; 1058 | this.ui.sliderHandle.style.top = `${top}px`; 1059 | try { this.ui.slider.classList.add('visible'); this.ui.slider.style.opacity = ''; } catch {} 1060 | } 1061 | 1062 | showSlider() { 1063 | if (!this.ui.slider) return; 1064 | this.ui.slider.classList.add('visible'); 1065 | if (this.sliderFadeTimer) { try { clearTimeout(this.sliderFadeTimer); } catch {} this.sliderFadeTimer = null; } 1066 | this.updateSlider(); 1067 | } 1068 | 1069 | hideSliderDeferred() { 1070 | if (this.sliderDragging || this.sliderAlwaysVisible) return; 1071 | if (this.sliderFadeTimer) { try { clearTimeout(this.sliderFadeTimer); } catch {} } 1072 | this.sliderFadeTimer = setTimeout(() => { 1073 | this.sliderFadeTimer = null; 1074 | try { this.ui.slider?.classList.remove('visible'); } catch {} 1075 | }, this.sliderFadeDelay); 1076 | } 1077 | 1078 | handleSliderDrag(e) { 1079 | if (!this.sliderDragging || !this.timelineBar || !this.track) return; 1080 | const barRect = this.timelineBar.getBoundingClientRect(); 1081 | const barH = barRect.height || 0; 1082 | const railLen = parseFloat(this.ui.slider.style.height || '0') || Math.max(120, Math.min(240, Math.floor(barH * 0.45))); 1083 | const handleH = this.ui.sliderHandle.getBoundingClientRect().height || 22; 1084 | const maxTop = Math.max(0, railLen - handleH); 1085 | const delta = e.clientY - this.sliderStartClientY; 1086 | let top = Math.max(0, Math.min(maxTop, (this.sliderStartTop + delta) - (parseFloat(this.ui.slider.style.top) || 0))); 1087 | const r = (maxTop > 0) ? (top / maxTop) : 0; 1088 | const range = Math.max(1, this.contentHeight - barH); 1089 | this.track.scrollTop = Math.round(r * range); 1090 | this.updateVirtualRangeAndRender(); 1091 | this.showSlider(); 1092 | this.updateSlider(); 1093 | } 1094 | 1095 | endSliderDrag() { 1096 | this.sliderDragging = false; 1097 | try { window.removeEventListener('pointermove', this.onSliderMove); } catch {} 1098 | this.onSliderMove = null; 1099 | this.onSliderUp = null; 1100 | this.hideSliderDeferred(); 1101 | } 1102 | 1103 | // --- Phase 7: Tooltip helpers --- 1104 | computePlacementInfo(dot) { 1105 | const tip = this.ui.tooltip || document.body; 1106 | const dotRect = dot.getBoundingClientRect(); 1107 | const vw = window.innerWidth; 1108 | const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6); 1109 | const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12); 1110 | const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8); 1111 | const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap); 1112 | const viewportPad = 8; 1113 | const maxW = this.getCSSVarNumber(tip, '--timeline-tooltip-max', 288); 1114 | const minW = 160; 1115 | const leftAvail = Math.max(0, dotRect.left - gap - viewportPad); 1116 | const rightAvail = Math.max(0, vw - dotRect.right - gap - viewportPad); 1117 | let placement = (rightAvail > leftAvail) ? 'right' : 'left'; 1118 | let avail = placement === 'right' ? rightAvail : leftAvail; 1119 | const tiers = [280, 240, 200, 160]; 1120 | const hardMax = Math.max(minW, Math.min(maxW, Math.floor(avail))); 1121 | let width = tiers.find(t => t <= hardMax) || Math.max(minW, Math.min(hardMax, 160)); 1122 | if (width < minW) { 1123 | if (placement === 'left' && rightAvail > leftAvail) { placement = 'right'; avail = rightAvail; } 1124 | else if (placement === 'right' && leftAvail >= rightAvail) { placement = 'left'; avail = leftAvail; } 1125 | const hardMax2 = Math.max(minW, Math.min(maxW, Math.floor(avail))); 1126 | width = tiers.find(t => t <= hardMax2) || Math.max(120, Math.min(hardMax2, minW)); 1127 | } 1128 | width = Math.max(120, Math.min(width, maxW)); 1129 | return { placement, width }; 1130 | } 1131 | 1132 | truncateToThreeLines(text, targetWidth, wantLayout = false) { 1133 | try { 1134 | if (!this.measureEl || !this.ui.tooltip) return wantLayout ? { text, height: 0 } : text; 1135 | const tip = this.ui.tooltip; 1136 | const lineH = this.getCSSVarNumber(tip, '--timeline-tooltip-lh', 18); 1137 | const padY = this.getCSSVarNumber(tip, '--timeline-tooltip-pad-y', 10); 1138 | const borderW = this.getCSSVarNumber(tip, '--timeline-tooltip-border-w', 1); 1139 | const maxH = Math.round(3 * lineH + 2 * padY + 2 * borderW); 1140 | const ell = '…'; 1141 | const el = this.measureEl; 1142 | const widthInt = Math.max(0, Math.floor(targetWidth)); 1143 | const rawAll = String(text || '').replace(/\s+/g, ' ').trim(); 1144 | const cacheKey = `${widthInt}|${rawAll}`; 1145 | if (this.truncateCache && this.truncateCache.has(cacheKey)) { 1146 | const cached = this.truncateCache.get(cacheKey); 1147 | return wantLayout ? { text: cached, height: maxH } : cached; 1148 | } 1149 | el.style.width = `${widthInt}px`; 1150 | el.textContent = rawAll; 1151 | let h = el.offsetHeight; 1152 | if (h <= maxH) return wantLayout ? { text: el.textContent, height: h } : el.textContent; 1153 | const raw = el.textContent; 1154 | let lo = 0, hi = raw.length, ans = 0; 1155 | while (lo <= hi) { 1156 | const mid = (lo + hi) >> 1; 1157 | el.textContent = raw.slice(0, mid).trimEnd() + ell; 1158 | h = el.offsetHeight; 1159 | if (h <= maxH) { ans = mid; lo = mid + 1; } else { hi = mid - 1; } 1160 | } 1161 | const out = (ans >= raw.length) ? raw : (raw.slice(0, ans).trimEnd() + ell); 1162 | el.textContent = out; 1163 | h = el.offsetHeight; 1164 | try { this.truncateCache?.set(cacheKey, out); } catch {} 1165 | return wantLayout ? { text: out, height: Math.min(h, maxH) } : out; 1166 | } catch { return wantLayout ? { text, height: 0 } : text; } 1167 | } 1168 | 1169 | placeTooltipAt(dot, placement, width, height) { 1170 | if (!this.ui.tooltip) return; 1171 | const tip = this.ui.tooltip; 1172 | const dotRect = dot.getBoundingClientRect(); 1173 | const vw = window.innerWidth; 1174 | const vh = window.innerHeight; 1175 | const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6); 1176 | const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12); 1177 | const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8); 1178 | const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap); 1179 | const viewportPad = 8; 1180 | let left; 1181 | if (placement === 'left') { 1182 | left = Math.round(dotRect.left - gap - width); 1183 | if (left < viewportPad) { 1184 | const altLeft = Math.round(dotRect.right + gap); 1185 | if (altLeft + width <= vw - viewportPad) { placement = 'right'; left = altLeft; } 1186 | else { const fitWidth = Math.max(120, vw - viewportPad - altLeft); left = altLeft; width = fitWidth; } 1187 | } 1188 | } else { 1189 | left = Math.round(dotRect.right + gap); 1190 | if (left + width > vw - viewportPad) { 1191 | const altLeft = Math.round(dotRect.left - gap - width); 1192 | if (altLeft >= viewportPad) { placement = 'left'; left = altLeft; } 1193 | else { const fitWidth = Math.max(120, vw - viewportPad - left); width = fitWidth; } 1194 | } 1195 | } 1196 | let top = Math.round(dotRect.top + dotRect.height / 2 - height / 2); 1197 | top = Math.max(viewportPad, Math.min(vh - height - viewportPad, top)); 1198 | tip.style.width = `${Math.floor(width)}px`; 1199 | tip.style.height = `${Math.floor(height)}px`; 1200 | tip.style.left = `${left}px`; 1201 | tip.style.top = `${top}px`; 1202 | tip.setAttribute('data-placement', placement); 1203 | } 1204 | 1205 | showTooltipForDot(dot) { 1206 | if (!this.ui.tooltip) return; 1207 | try { if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); this.tooltipHideTimer = null; } } catch {} 1208 | const tip = this.ui.tooltip; 1209 | tip.classList.remove('visible'); 1210 | let fullText = (dot.getAttribute('aria-label') || '').trim(); 1211 | try { const id = dot.dataset.targetIdx; if (id && this.starred.has(id)) fullText = `★ ${fullText}`; } catch {} 1212 | const p = this.computePlacementInfo(dot); 1213 | const layout = this.truncateToThreeLines(fullText, p.width, true); 1214 | tip.textContent = layout.text; 1215 | this.placeTooltipAt(dot, p.placement, p.width, layout.height); 1216 | tip.setAttribute('aria-hidden', 'false'); 1217 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 1218 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 1219 | } 1220 | 1221 | hideTooltip(immediate = false) { 1222 | if (!this.ui.tooltip) return; 1223 | const doHide = () => { this.ui.tooltip.classList.remove('visible'); this.ui.tooltip.setAttribute('aria-hidden', 'true'); this.tooltipHideTimer = null; }; 1224 | if (immediate) return doHide(); 1225 | try { if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); } } catch {} 1226 | this.tooltipHideTimer = setTimeout(doHide, this.tooltipHideDelay); 1227 | } 1228 | 1229 | refreshTooltipForDot(dot) { 1230 | const tip = this.ui.tooltip; 1231 | if (!tip || !tip.classList.contains('visible')) return; 1232 | let fullText = (dot.getAttribute('aria-label') || '').trim(); 1233 | try { const id = dot.dataset.targetIdx; if (id && this.starred.has(id)) fullText = `★ ${fullText}`; } catch {} 1234 | const p = this.computePlacementInfo(dot); 1235 | const layout = this.truncateToThreeLines(fullText, p.width, true); 1236 | tip.textContent = layout.text; 1237 | this.placeTooltipAt(dot, p.placement, p.width, layout.height); 1238 | } 1239 | 1240 | // Phase 8: star persistence 1241 | loadStars() { 1242 | this.starred.clear(); 1243 | const cid = this.conversationId; 1244 | if (!cid) return; 1245 | try { 1246 | const raw = localStorage.getItem(`geminiTimelineStars:${cid}`); 1247 | if (!raw) return; 1248 | const arr = JSON.parse(raw); 1249 | if (Array.isArray(arr)) arr.forEach(id => this.starred.add(String(id))); 1250 | } catch {} 1251 | } 1252 | saveStars() { 1253 | const cid = this.conversationId; 1254 | if (!cid) return; 1255 | try { localStorage.setItem(`geminiTimelineStars:${cid}`, JSON.stringify(Array.from(this.starred))); } catch {} 1256 | } 1257 | toggleStar(turnId) { 1258 | const id = String(turnId || ''); 1259 | if (!id) return; 1260 | if (this.starred.has(id)) this.starred.delete(id); else this.starred.add(id); 1261 | this.saveStars(); 1262 | const m = this.markers.find(mm => mm.id === id); 1263 | if (m && m.dotElement) { 1264 | m.starred = this.starred.has(id); 1265 | try { m.dotElement.classList.toggle('starred', m.starred); m.dotElement.setAttribute('aria-pressed', m.starred ? 'true' : 'false'); } catch {} 1266 | try { this.refreshTooltipForDot(m.dotElement); } catch {} 1267 | } 1268 | } 1269 | 1270 | cancelLongPress() { 1271 | if (this.longPressTimer) { try { clearTimeout(this.longPressTimer); } catch {} this.longPressTimer = null; } 1272 | if (this.pressTargetDot) { try { this.pressTargetDot.classList.remove('holding'); } catch {} } 1273 | this.pressTargetDot = null; 1274 | this.pressStartPos = null; 1275 | } 1276 | } 1277 | 1278 | // --- Bootstrap wiring (Phase 1) --- 1279 | let timelineActive = true; // global switch 1280 | let providerEnabled = true; // gemini provider switch 1281 | let manager = null; 1282 | let currentUrl = location.href; 1283 | let routeListenersAttached = false; 1284 | let routeCheckIntervalId = null; 1285 | let initialObserver = null; 1286 | let pageObserver = null; 1287 | let initTimerId = null; 1288 | 1289 | function initializeTimeline() { 1290 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1291 | // Clean any leftovers 1292 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1293 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1294 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1295 | manager = new GeminiTimelineScaffold(); 1296 | manager.init().catch(err => console.debug('[GeminiTimeline] init failed (Phase 1–3):', err)); 1297 | } 1298 | 1299 | function handleUrlChange() { 1300 | if (location.href === currentUrl) return; 1301 | try { if (initTimerId) { clearTimeout(initTimerId); initTimerId = null; } } catch {} 1302 | currentUrl = location.href; 1303 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1304 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1305 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1306 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1307 | const enabled = (timelineActive && providerEnabled); 1308 | if (isConversationRouteGemini() && enabled) { 1309 | initTimerId = setTimeout(() => { 1310 | initTimerId = null; 1311 | // Only init when we do see a user bubble (guarded in init too) 1312 | if (isConversationRouteGemini() && (timelineActive && providerEnabled)) initializeTimeline(); 1313 | }, 300); 1314 | } else { 1315 | // keep observers minimal; nothing else to do off-route 1316 | } 1317 | } 1318 | 1319 | function attachRouteListenersOnce() { 1320 | if (routeListenersAttached) return; 1321 | routeListenersAttached = true; 1322 | try { window.addEventListener('popstate', handleUrlChange); } catch {} 1323 | try { window.addEventListener('hashchange', handleUrlChange); } catch {} 1324 | try { 1325 | routeCheckIntervalId = setInterval(() => { 1326 | if (location.href !== currentUrl) handleUrlChange(); 1327 | }, 800); 1328 | } catch {} 1329 | } 1330 | 1331 | function detachRouteListeners() { 1332 | if (!routeListenersAttached) return; 1333 | routeListenersAttached = false; 1334 | try { window.removeEventListener('popstate', handleUrlChange); } catch {} 1335 | try { window.removeEventListener('hashchange', handleUrlChange); } catch {} 1336 | try { if (routeCheckIntervalId) { clearInterval(routeCheckIntervalId); routeCheckIntervalId = null; } } catch {} 1337 | } 1338 | 1339 | // Bootstrap when first user bubble appears; then manage SPA transitions 1340 | try { 1341 | initialObserver = new MutationObserver(() => { 1342 | if (document.querySelector(SEL_USER_BUBBLE)) { 1343 | if (isConversationRouteGemini() && (timelineActive && providerEnabled)) initializeTimeline(); 1344 | try { initialObserver.disconnect(); } catch {} 1345 | pageObserver = new MutationObserver(handleUrlChange); 1346 | try { pageObserver.observe(document.body, { childList: true, subtree: true }); } catch {} 1347 | attachRouteListenersOnce(); 1348 | } 1349 | }); 1350 | initialObserver.observe(document.body, { childList: true, subtree: true }); 1351 | } catch {} 1352 | 1353 | attachRouteListenersOnce(); 1354 | 1355 | // Storage toggles (global and per-provider) 1356 | try { 1357 | if (chrome?.storage?.local) { 1358 | chrome.storage.local.get({ timelineActive: true, timelineProviders: {} }, (res) => { 1359 | try { timelineActive = !!res.timelineActive; } catch { timelineActive = true; } 1360 | try { 1361 | const map = res.timelineProviders || {}; 1362 | providerEnabled = (typeof map.gemini === 'boolean') ? map.gemini : true; 1363 | } catch { providerEnabled = true; } 1364 | const enabled = timelineActive && providerEnabled; 1365 | if (!enabled) { 1366 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1367 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1368 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1369 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1370 | } else if (isConversationRouteGemini() && document.querySelector(SEL_USER_BUBBLE)) { 1371 | initializeTimeline(); 1372 | } 1373 | }); 1374 | chrome.storage.onChanged.addListener((changes, area) => { 1375 | if (area !== 'local' || !changes) return; 1376 | let changed = false; 1377 | if ('timelineActive' in changes) { timelineActive = !!changes.timelineActive.newValue; changed = true; } 1378 | if ('timelineProviders' in changes) { 1379 | try { 1380 | const map = changes.timelineProviders.newValue || {}; 1381 | providerEnabled = (typeof map.gemini === 'boolean') ? map.gemini : true; 1382 | changed = true; 1383 | } catch {} 1384 | } 1385 | if (!changed) return; 1386 | const enabled = timelineActive && providerEnabled; 1387 | if (!enabled) { 1388 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1389 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1390 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1391 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1392 | } else if (isConversationRouteGemini() && document.querySelector(SEL_USER_BUBBLE)) { 1393 | initializeTimeline(); 1394 | } 1395 | }); 1396 | } 1397 | } catch {} 1398 | 1399 | try { console.debug('[GeminiTimeline] content-gemini.js loaded (Phase 1–3)'); } catch {} 1400 | })(); 1401 | 1402 | -------------------------------------------------------------------------------- /extension/content-deepseek.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | const SEL_MSG = '.ds-message'; 3 | const SEL_SCROLL = '.ds-scroll-area'; 4 | 5 | const nowTs = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 6 | 7 | // --- Route helpers (Stage 3) --- 8 | function isConversationRouteDeepseek(pathname = location.pathname) { 9 | try { 10 | const segs = String(pathname || '').split('/').filter(Boolean); 11 | // Primary app route: /s/ 12 | const i = segs.indexOf('s'); 13 | if (i !== -1) { 14 | const slug = segs[i + 1]; 15 | if (typeof slug === 'string' && slug.length > 0 && /^[A-Za-z0-9_-]+$/.test(slug)) return true; 16 | } 17 | // Share route: /share/ 18 | const ishr = segs.indexOf('share'); 19 | if (ishr !== -1) { 20 | const slug = segs[ishr + 1]; 21 | if (typeof slug === 'string' && slug.length > 0 && /^[A-Za-z0-9_-]+$/.test(slug)) return true; 22 | } 23 | return false; 24 | } catch { return false; } 25 | } 26 | 27 | function extractConversationIdFromPath(pathname = location.pathname) { 28 | try { 29 | const segs = String(pathname || '').split('/').filter(Boolean); 30 | // /s/ 31 | const i = segs.indexOf('s'); 32 | if (i !== -1) { 33 | const slug = segs[i + 1]; 34 | if (slug && /^[A-Za-z0-9_-]+$/.test(slug)) return slug; 35 | } 36 | // /share/ → namespace to avoid collision with /s/ 37 | const ishr = segs.indexOf('share'); 38 | if (ishr !== -1) { 39 | const slug = segs[ishr + 1]; 40 | if (slug && /^[A-Za-z0-9_-]+$/.test(slug)) return `share:${slug}`; 41 | } 42 | return null; 43 | } catch { return null; } 44 | } 45 | 46 | // Detect DeepSeek share route (/share/) explicitly 47 | function isShareRouteDeepseek(pathname = location.pathname) { 48 | try { 49 | const segs = String(pathname || '').split('/').filter(Boolean); 50 | const i = segs.indexOf('share'); 51 | if (i === -1) return false; 52 | const slug = segs[i + 1]; 53 | return typeof slug === 'string' && slug.length > 0 && /^[A-Za-z0-9_-]+$/.test(slug); 54 | } catch { return false; } 55 | } 56 | 57 | // --- DOM utilities (Stage 2) --- 58 | function waitForElement(selector, timeoutMs = 5000) { 59 | return new Promise((resolve) => { 60 | const el = document.querySelector(selector); 61 | if (el) return resolve(el); 62 | const obs = new MutationObserver(() => { 63 | const n = document.querySelector(selector); 64 | if (n) { 65 | try { obs.disconnect(); } catch {} 66 | resolve(n); 67 | } 68 | }); 69 | try { obs.observe(document.body, { childList: true, subtree: true }); } catch {} 70 | setTimeout(() => { try { obs.disconnect(); } catch {} resolve(null); }, timeoutMs); 71 | }); 72 | } 73 | 74 | function isElementScrollable(el) { 75 | if (!el) return false; 76 | try { 77 | const cs = getComputedStyle(el); 78 | const oy = (cs.overflowY || '').toLowerCase(); 79 | const overflowOk = oy === 'auto' || oy === 'scroll' || oy === 'overlay'; 80 | if (!overflowOk && el !== document.scrollingElement && el !== document.documentElement && el !== document.body) return false; 81 | // content larger than viewport 82 | if ((el.scrollHeight - el.clientHeight) > 4) return true; 83 | // try programmatic scroll 84 | const prev = el.scrollTop; 85 | el.scrollTop = prev + 1; 86 | const changed = el.scrollTop !== prev; 87 | el.scrollTop = prev; 88 | return changed; 89 | } catch { return false; } 90 | } 91 | 92 | function getScrollableAncestor(startEl) { 93 | // 1) Prefer nearest scrollable ancestor in the chain 94 | let el = startEl; 95 | while (el && el !== document.body) { 96 | if (isElementScrollable(el)) return el; 97 | el = el.parentElement; 98 | } 99 | // 2) Site-provided scroll area if it actually scrolls and contains the conversation 100 | try { 101 | const siteScroll = document.querySelector(SEL_SCROLL); 102 | if (siteScroll && (siteScroll.contains(startEl) || startEl.contains(siteScroll)) && isElementScrollable(siteScroll)) { 103 | return siteScroll; 104 | } 105 | } catch {} 106 | // 3) Fallback to document scroller if scrollable 107 | const docScroll = document.scrollingElement || document.documentElement || document.body; 108 | return isElementScrollable(docScroll) ? docScroll : (document.documentElement || document.body); 109 | } 110 | 111 | function normalizeText(text) { 112 | try { 113 | let s = String(text || '').replace(/\s+/g, ' ').trim(); 114 | s = s.replace(/^\s*(you\s*said\s*[::]?\s*)/i, ''); 115 | s = s.replace(/^\s*((你说|您说|你說|您說)\s*[::]?\s*)/, ''); 116 | return s; 117 | } catch { return ''; } 118 | } 119 | 120 | // Heuristic user-message detector 121 | // Priority 0: if the message is inside a container marked with data-um-id (user message block), treat as user 122 | // Primary: user messages are followed by an actions toolbar (next sibling) 123 | // Fallback: user bubbles are right-aligned relative to the conversation container 124 | function detectIsUserMessage(el, conversationContainer) { 125 | // data-um-id block observed on both regular chat and share pages 126 | try { 127 | const owner = el?.closest?.('[data-um-id]'); 128 | if (owner) return true; 129 | } catch {} 130 | 131 | try { 132 | const next = el?.nextElementSibling; 133 | if (next) { 134 | // Strong signals of the user action toolbar 135 | if (next.querySelector('.ds-icon-button, .ds-icon-button__hover-bg')) return true; 136 | // Generic action buttons that contain ds-icon 137 | const btnLike = Array.from(next.querySelectorAll('[role="button"], button, [tabindex]')); 138 | if (btnLike.some(b => b.querySelector('.ds-icon'))) return true; 139 | // Layout container often uses ds-flex; presence together with icons is also a hint 140 | if (next.querySelector('.ds-flex .ds-icon')) return true; 141 | } 142 | } catch {} 143 | 144 | // Geometry fallback: right-aligned bubbles treated as user messages 145 | try { 146 | const cRect = conversationContainer?.getBoundingClientRect?.(); 147 | const r = el?.getBoundingClientRect?.(); 148 | if (cRect && r) { 149 | const centerX = r.left + (r.width || 0) / 2; 150 | const midX = cRect.left + (cRect.width || 0) / 2; 151 | if ((centerX - midX) >= 16) return true; // threshold 16px to avoid jitter 152 | } 153 | } catch {} 154 | return false; 155 | } 156 | 157 | // --- Timeline Manager (Stage 2,5,6 minimal) --- 158 | class DeepseekTimeline { 159 | constructor() { 160 | this.conversationContainer = null; 161 | this.scrollContainer = null; 162 | this.timelineBar = null; 163 | this.track = null; 164 | this.trackContent = null; 165 | this.markers = []; 166 | this.firstOffset = 0; 167 | this.spanPx = 1; 168 | this.onScroll = null; 169 | this.mutationObserver = null; 170 | this.resizeObserver = null; 171 | // Stage 7: virtualization + min-gap state 172 | this.contentHeight = 0; 173 | this.yPositions = []; 174 | this.visibleRange = { start: 0, end: -1 }; 175 | this.markersVersion = 0; 176 | this.usePixelTop = false; 177 | this._cssVarTopSupported = null; 178 | this.scrollRafId = null; 179 | 180 | // Stage 8: active + tooltip + star 181 | this.activeIdx = -1; 182 | this.ui = { tooltip: null }; 183 | this.measureEl = null; // hidden measurer for tooltip truncation 184 | this.tooltipHideDelay = 100; 185 | this.tooltipHideTimer = null; 186 | this.showRafId = null; 187 | 188 | // Long-press star 189 | this.longPressDuration = 550; // ms 190 | this.longPressMoveTolerance = 6; // px 191 | this.longPressTimer = null; 192 | this.pressStartPos = null; 193 | this.pressTargetDot = null; 194 | this.suppressClickUntil = 0; 195 | this.starred = new Set(); 196 | this.conversationId = null; 197 | this.onStorage = null; // cross-tab star sync via localStorage 198 | 199 | // P1: debounce active + theme/viewport observers + tooltip cache + idle correction 200 | this.lastActiveChangeTime = 0; 201 | this.minActiveChangeInterval = 120; // ms 202 | this.pendingActiveIdx = null; 203 | this.activeChangeTimer = null; 204 | this.themeObserver = null; 205 | this.onVisualViewportResize = null; 206 | this.truncateCache = new Map(); 207 | this.resizeIdleTimer = null; 208 | this.resizeIdleDelay = 140; // ms 209 | this.resizeIdleRICId = null; 210 | 211 | // P1: debounce active + observers + caches 212 | this.lastActiveChangeTime = 0; 213 | this.minActiveChangeInterval = 120; // ms 214 | this.pendingActiveIdx = null; 215 | this.activeChangeTimer = null; 216 | this.themeObserver = null; 217 | this.onVisualViewportResize = null; 218 | this.truncateCache = new Map(); 219 | this.resizeIdleTimer = null; 220 | this.resizeIdleDelay = 140; 221 | this.resizeIdleRICId = null; 222 | } 223 | 224 | async init() { 225 | const firstMsg = await waitForElement(SEL_MSG, 5000); 226 | if (!firstMsg) return; 227 | 228 | // Find a conversation root that contains ALL messages (lowest common ancestor) 229 | let root = null; 230 | try { 231 | const allMsgs = Array.from(document.querySelectorAll(SEL_MSG)); 232 | if (allMsgs.length > 0) { 233 | // climb ancestors of the first message until an ancestor contains all messages 234 | let node = firstMsg.parentElement; 235 | while (node && node !== document.body) { 236 | let allInside = true; 237 | for (let i = 0; i < allMsgs.length; i++) { 238 | if (!node.contains(allMsgs[i])) { allInside = false; break; } 239 | } 240 | if (allInside) { root = node; break; } 241 | node = node.parentElement; 242 | } 243 | if (!root) root = firstMsg.parentElement; 244 | } 245 | } catch {} 246 | this.conversationContainer = root || firstMsg.parentElement || document.body; 247 | this.scrollContainer = getScrollableAncestor(this.conversationContainer); 248 | 249 | this.injectUI(); 250 | // prepare tooltip + measurer 251 | this.ensureTooltip(); 252 | 253 | // stars per conversation 254 | this.conversationId = extractConversationIdFromPath(location.pathname); 255 | this.loadStars(); 256 | 257 | this.rebuildMarkers(); 258 | // Share -> Chat handoff adoption or snapshot publish 259 | try { 260 | if (isShareRouteDeepseek()) { 261 | this.updateShareHandoffSnapshot(); 262 | } else { 263 | this.tryAdoptShareHandoff(); 264 | } 265 | } catch {} 266 | this.attachObservers(); 267 | this.attachScrollSync(); 268 | this.attachInteractions(); 269 | 270 | // Cross-tab star sync via localStorage 'storage' event (ChatGPT同款) 271 | this.onStorage = (e) => { 272 | try { 273 | if (!e || e.storageArea !== localStorage) return; 274 | const cid = this.conversationId; 275 | if (!cid) return; 276 | const expectedKey = `deepseekTimelineStars:${cid}`; 277 | if (e.key !== expectedKey) return; 278 | 279 | // Parse new star set 280 | let nextArr = []; 281 | try { nextArr = JSON.parse(e.newValue || '[]') || []; } catch { nextArr = []; } 282 | const nextSet = new Set(nextArr.map(x => String(x))); 283 | 284 | // Fast no-op check 285 | if (nextSet.size === this.starred.size) { 286 | let same = true; 287 | for (const id of this.starred) { if (!nextSet.has(id)) { same = false; break; } } 288 | if (same) return; 289 | } 290 | this.starred = nextSet; 291 | // Update markers 292 | for (let i = 0; i < this.markers.length; i++) { 293 | const m = this.markers[i]; 294 | const want = this.starred.has(m.id); 295 | if (m.starred !== want) { 296 | m.starred = want; 297 | if (m.dotElement) { 298 | try { 299 | m.dotElement.classList.toggle('starred', m.starred); 300 | m.dotElement.setAttribute('aria-pressed', m.starred ? 'true' : 'false'); 301 | } catch {} 302 | } 303 | } 304 | } 305 | // Refresh tooltip if visible 306 | try { 307 | if (this.ui.tooltip?.classList.contains('visible')) { 308 | const currentDot = this.timelineBar.querySelector('.timeline-dot:hover, .timeline-dot:focus'); 309 | if (currentDot) this.refreshTooltipForDot(currentDot); 310 | } 311 | } catch {} 312 | } catch {} 313 | }; 314 | try { window.addEventListener('storage', this.onStorage); } catch {} 315 | } 316 | 317 | injectUI() { 318 | let bar = document.querySelector('.chatgpt-timeline-bar'); 319 | if (!bar) { 320 | bar = document.createElement('div'); 321 | bar.className = 'chatgpt-timeline-bar'; 322 | document.body.appendChild(bar); 323 | } 324 | this.timelineBar = bar; 325 | let track = this.timelineBar.querySelector('.timeline-track'); 326 | if (!track) { 327 | track = document.createElement('div'); 328 | track.className = 'timeline-track'; 329 | this.timelineBar.appendChild(track); 330 | } 331 | let content = track.querySelector('.timeline-track-content'); 332 | if (!content) { 333 | content = document.createElement('div'); 334 | content.className = 'timeline-track-content'; 335 | track.appendChild(content); 336 | } 337 | this.track = track; 338 | this.trackContent = content; 339 | // Ensure external left-side slider exists (outside the bar) 340 | let slider = document.querySelector('.timeline-left-slider'); 341 | if (!slider) { 342 | slider = document.createElement('div'); 343 | slider.className = 'timeline-left-slider'; 344 | const handle = document.createElement('div'); 345 | handle.className = 'timeline-left-handle'; 346 | slider.appendChild(handle); 347 | document.body.appendChild(slider); 348 | } 349 | this.ui.slider = slider; 350 | this.ui.sliderHandle = slider.querySelector('.timeline-left-handle'); 351 | } 352 | 353 | ensureTooltip() { 354 | if (!this.ui.tooltip) { 355 | const tip = document.createElement('div'); 356 | tip.className = 'timeline-tooltip'; 357 | tip.setAttribute('role', 'tooltip'); 358 | // Align id with ChatGPT for a11y consistency 359 | tip.id = 'chatgpt-timeline-tooltip'; 360 | tip.setAttribute('aria-hidden', 'true'); 361 | try { tip.style.boxSizing = 'border-box'; } catch {} 362 | document.body.appendChild(tip); 363 | this.ui.tooltip = tip; 364 | } 365 | if (!this.measureEl) { 366 | const m = document.createElement('div'); 367 | m.setAttribute('aria-hidden', 'true'); 368 | m.style.position = 'fixed'; 369 | m.style.left = '-9999px'; 370 | m.style.top = '0px'; 371 | m.style.visibility = 'hidden'; 372 | m.style.pointerEvents = 'none'; 373 | m.style.boxSizing = 'border-box'; 374 | const cs = getComputedStyle(this.ui.tooltip); 375 | Object.assign(m.style, { 376 | backgroundColor: cs.backgroundColor, 377 | color: cs.color, 378 | fontFamily: cs.fontFamily, 379 | fontSize: cs.fontSize, 380 | lineHeight: cs.lineHeight, 381 | padding: cs.padding, 382 | border: cs.border, 383 | borderRadius: cs.borderRadius, 384 | whiteSpace: 'normal', 385 | wordBreak: 'break-word', 386 | maxWidth: 'none', 387 | display: 'block', 388 | transform: 'none', 389 | transition: 'none' 390 | }); 391 | try { m.style.webkitLineClamp = 'unset'; } catch {} 392 | document.body.appendChild(m); 393 | this.measureEl = m; 394 | } 395 | } 396 | 397 | rebuildMarkers() { 398 | if (!this.conversationContainer || !this.trackContent) return; 399 | // Clear dots 400 | try { this.trackContent.querySelectorAll('.timeline-dot').forEach(n => n.remove()); } catch {} 401 | 402 | const all = Array.from(this.conversationContainer.querySelectorAll(SEL_MSG)); 403 | const list = all.filter(el => detectIsUserMessage(el, this.conversationContainer)); 404 | if (list.length === 0) return; 405 | 406 | // Compute absolute Y positions relative to the scroll container (more robust than offsetTop) 407 | const cRect = this.scrollContainer.getBoundingClientRect(); 408 | const st = this.scrollContainer.scrollTop; 409 | const yPositions = list.map(el => { 410 | const r = el.getBoundingClientRect(); 411 | return (r.top - cRect.top) + st; 412 | }); 413 | const firstY = yPositions[0]; 414 | const lastY = (yPositions.length > 1) ? yPositions[yPositions.length - 1] : (firstY + 1); 415 | const span = Math.max(1, lastY - firstY); 416 | this.firstOffset = firstY; 417 | this.spanPx = span; 418 | 419 | // Build ids with priority: data-turn-id > data-um-id > legacy (text-hash + ordinal) 420 | const seen = new Map(); 421 | let migrated = false; 422 | this.markers = list.map((el) => { 423 | const r = el.getBoundingClientRect(); 424 | const y = (r.top - cRect.top) + st; 425 | const n = Math.max(0, Math.min(1, (y - firstY) / span)); 426 | 427 | // Candidates 428 | const turnId = el?.dataset?.turnId || null; 429 | let umId = null; 430 | try { 431 | const owner = el.closest('[data-um-id]'); 432 | const v = owner?.getAttribute('data-um-id'); 433 | if (v) umId = `um:${v}`; 434 | } catch {} 435 | // Legacy hash id (previous fallback behavior) 436 | const base = this.buildStableHashFromUser(el); 437 | const cnt = (seen.get(base) || 0) + 1; seen.set(base, cnt); 438 | const legacyId = `${base}-${cnt}`; 439 | 440 | // Choose preferred id 441 | let id = turnId || umId || legacyId; 442 | 443 | // Migration: if new preferred id differs from legacy/um and an old star exists, migrate it 444 | if (!this.starred.has(id)) { 445 | const oldCandidates = []; 446 | if (legacyId && legacyId !== id) oldCandidates.push(legacyId); 447 | if (umId && umId !== id) oldCandidates.push(umId); 448 | for (let k = 0; k < oldCandidates.length; k++) { 449 | const oldId = oldCandidates[k]; 450 | if (this.starred.has(oldId)) { 451 | try { this.starred.delete(oldId); } catch {} 452 | try { this.starred.add(id); } catch {} 453 | migrated = true; 454 | break; 455 | } 456 | } 457 | } 458 | 459 | // For pure legacy fallback within this session, keep dataset turnId to stabilize incremental passes 460 | if (!turnId && !umId && legacyId === id) { 461 | try { el.dataset.turnId = id; } catch {} 462 | } 463 | 464 | return { id, el, n, baseN: n, dotElement: null, summary: normalizeText(el.textContent || ''), starred: this.starred.has(id) }; 465 | }); 466 | 467 | // Persist migrated stars once after rebuild 468 | if (migrated) { try { this.saveStars(); } catch {} } 469 | 470 | // bump version and compute geometry + virtual render 471 | this.markersVersion++; 472 | this.updateTimelineGeometry(); 473 | this.syncTimelineTrackToMain(); 474 | this.updateVirtualRangeAndRender(); 475 | // ensure first active highlight reflects current viewport 476 | this.computeActiveByScroll(); 477 | this.updateActiveDotUI(); 478 | } 479 | 480 | scrollToMessage(targetEl) { 481 | if (!this.scrollContainer || !targetEl) return; 482 | const containerRect = this.scrollContainer.getBoundingClientRect(); 483 | const targetRect = targetEl.getBoundingClientRect(); 484 | const to = targetRect.top - containerRect.top + this.scrollContainer.scrollTop; 485 | const from = this.scrollContainer.scrollTop; 486 | const dist = to - from; 487 | const dur = 500; 488 | let t0 = null; 489 | const ease = (t, b, c, d) => { 490 | t /= d / 2; if (t < 1) return c / 2 * t * t + b; t--; return -c / 2 * (t * (t - 2) - 1) + b; 491 | }; 492 | const step = (ts) => { 493 | if (t0 === null) t0 = ts; 494 | const dt = ts - t0; 495 | const v = ease(dt, from, dist, dur); 496 | this.scrollContainer.scrollTop = v; 497 | if (dt < dur) requestAnimationFrame(step); else this.scrollContainer.scrollTop = to; 498 | }; 499 | requestAnimationFrame(step); 500 | } 501 | 502 | attachObservers() { 503 | // Rebuild when messages change (debounced lightly) 504 | let timer = null; 505 | this.mutationObserver = new MutationObserver(() => { 506 | try { this.ensureContainersUpToDate(); } catch {} 507 | if (timer) { clearTimeout(timer); } 508 | timer = setTimeout(() => { this.rebuildMarkers(); }, 200); 509 | }); 510 | try { this.mutationObserver.observe(this.conversationContainer, { childList: true, subtree: true }); } catch {} 511 | 512 | this.resizeObserver = new ResizeObserver(() => { 513 | // recompute geometry and keep virtual range updated to avoid jank 514 | this.updateTimelineGeometry(); 515 | this.syncTimelineTrackToMain(); 516 | this.updateVirtualRangeAndRender(); 517 | this.updateSlider(); 518 | try { this.truncateCache?.clear(); } catch {} 519 | // NOTE: Keep min-gap computation within updateTimelineGeometry (long-canvas) 520 | // this.scheduleMinGapCorrection(); 521 | }); 522 | if (this.timelineBar) { 523 | try { this.resizeObserver.observe(this.timelineBar); } catch {} 524 | } 525 | 526 | // Theme observer: watch html/body class and common theme attributes 527 | try { 528 | if (!this.themeObserver) { 529 | this.themeObserver = new MutationObserver(() => { 530 | this.updateTimelineGeometry(); 531 | this.syncTimelineTrackToMain(); 532 | this.updateVirtualRangeAndRender(); 533 | this.updateSlider(); 534 | try { this.truncateCache?.clear(); } catch {} 535 | // NOTE: Avoid reapplying min-gap on short-canvas during theme toggles 536 | // this.scheduleMinGapCorrection(); 537 | }); 538 | } 539 | const attrs = ['class','data-theme','data-color-mode','data-color-scheme']; 540 | this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: attrs }); 541 | try { this.themeObserver.observe(document.body, { attributes: true, attributeFilter: attrs }); } catch {} 542 | } catch {} 543 | 544 | // Visual viewport (zoom) listener 545 | if (window.visualViewport && !this.onVisualViewportResize) { 546 | this.onVisualViewportResize = () => { 547 | this.updateTimelineGeometry(); 548 | this.syncTimelineTrackToMain(); 549 | this.updateVirtualRangeAndRender(); 550 | this.updateSlider(); 551 | try { this.truncateCache?.clear(); } catch {} 552 | // NOTE: Keep geometry consistent; do not reapply min-gap here 553 | // this.scheduleMinGapCorrection(); 554 | }; 555 | try { window.visualViewport.addEventListener('resize', this.onVisualViewportResize); } catch {} 556 | } 557 | } 558 | 559 | attachScrollSync() { 560 | if (!this.scrollContainer) return; 561 | this.onScroll = () => this.scheduleScrollSync(); 562 | try { this.scrollContainer.addEventListener('scroll', this.onScroll, { passive: true }); } catch {} 563 | // If using the document scroller, also listen on window to be safe 564 | const docScroll = document.scrollingElement || document.documentElement || document.body; 565 | if (this.scrollContainer === docScroll || this.scrollContainer === document.body || this.scrollContainer === document.documentElement) { 566 | try { window.addEventListener('scroll', this.onScroll, { passive: true }); } catch {} 567 | } 568 | // initial sync so first paint already aligned/highlighted 569 | this.scheduleScrollSync(); 570 | } 571 | 572 | destroy() { 573 | try { this.mutationObserver?.disconnect(); } catch {} 574 | try { this.resizeObserver?.disconnect(); } catch {} 575 | if (this.scrollContainer && this.onScroll) { 576 | try { this.scrollContainer.removeEventListener('scroll', this.onScroll); } catch {} 577 | } 578 | try { window.removeEventListener('scroll', this.onScroll); } catch {} 579 | this.onScroll = null; 580 | // remove interactions 581 | try { this.timelineBar?.removeEventListener('mouseover', this.onTimelineBarOver); } catch {} 582 | try { this.timelineBar?.removeEventListener('mouseout', this.onTimelineBarOut); } catch {} 583 | try { this.timelineBar?.removeEventListener('focusin', this.onTimelineBarFocusIn); } catch {} 584 | try { this.timelineBar?.removeEventListener('focusout', this.onTimelineBarFocusOut); } catch {} 585 | try { this.timelineBar?.removeEventListener('pointerdown', this.onPointerDown); } catch {} 586 | try { this.timelineBar?.removeEventListener('pointerleave', this.onPointerLeave); } catch {} 587 | try { window.removeEventListener('pointermove', this.onPointerMove); } catch {} 588 | try { window.removeEventListener('pointerup', this.onPointerUp); } catch {} 589 | try { window.removeEventListener('pointercancel', this.onPointerCancel); } catch {} 590 | try { window.removeEventListener('resize', this.onWindowResize); } catch {} 591 | try { this.themeObserver?.disconnect(); } catch {} 592 | if (this.onVisualViewportResize && window.visualViewport) { 593 | try { window.visualViewport.removeEventListener('resize', this.onVisualViewportResize); } catch {} 594 | this.onVisualViewportResize = null; 595 | } 596 | if (this.resizeIdleTimer) { try { clearTimeout(this.resizeIdleTimer); } catch {} this.resizeIdleTimer = null; } 597 | try { if (this.resizeIdleRICId && typeof cancelIdleCallback === 'function') cancelIdleCallback(this.resizeIdleRICId); } catch {} 598 | if (this.activeChangeTimer) { try { clearTimeout(this.activeChangeTimer); } catch {} this.activeChangeTimer = null; } 599 | try { this.themeObserver?.disconnect(); } catch {} 600 | this.onTimelineBarOver = this.onTimelineBarOut = this.onTimelineBarFocusIn = this.onTimelineBarFocusOut = null; 601 | this.onPointerDown = this.onPointerMove = this.onPointerUp = this.onPointerCancel = this.onPointerLeave = null; 602 | this.onWindowResize = null; 603 | 604 | // remove UI elements 605 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 606 | // remove slider and listeners 607 | try { this.timelineBar?.removeEventListener('pointerenter', this.onBarEnter); } catch {} 608 | try { this.timelineBar?.removeEventListener('pointerleave', this.onBarLeave); } catch {} 609 | try { this.ui.slider?.removeEventListener('pointerenter', this.onSliderEnter); } catch {} 610 | try { this.ui.slider?.removeEventListener('pointerleave', this.onSliderLeave); } catch {} 611 | try { this.ui.sliderHandle?.removeEventListener('pointerdown', this.onSliderDown); } catch {} 612 | try { this.ui.slider?.remove(); } catch {} 613 | this.ui.slider = null; 614 | this.ui.sliderHandle = null; 615 | try { this.ui.tooltip?.remove(); } catch {} 616 | try { this.measureEl?.remove(); } catch {} 617 | try { window.removeEventListener('storage', this.onStorage); } catch {} 618 | this.timelineBar = null; 619 | this.track = null; 620 | this.trackContent = null; 621 | this.markers = []; 622 | } 623 | } 624 | 625 | // --- Stage 7 helpers (min-gap + virtualization) --- 626 | function clamp01(x) { return Math.max(0, Math.min(1, x)); } 627 | 628 | DeepseekTimeline.prototype.getCSSVarNumber = function(el, name, fallback) { 629 | try { 630 | const v = getComputedStyle(el).getPropertyValue(name).trim(); 631 | const n = parseFloat(v); 632 | return Number.isFinite(n) ? n : fallback; 633 | } catch { return fallback; } 634 | }; 635 | 636 | DeepseekTimeline.prototype.applyMinGap = function(positions, minTop, maxTop, gap) { 637 | const n = positions.length; 638 | if (n === 0) return positions; 639 | const out = positions.slice(); 640 | out[0] = Math.max(minTop, Math.min(positions[0], maxTop)); 641 | for (let i = 1; i < n; i++) { 642 | const minAllowed = out[i - 1] + gap; 643 | out[i] = Math.max(positions[i], minAllowed); 644 | } 645 | if (out[n - 1] > maxTop) { 646 | out[n - 1] = maxTop; 647 | for (let i = n - 2; i >= 0; i--) { 648 | const maxAllowed = out[i + 1] - gap; 649 | out[i] = Math.min(out[i], maxAllowed); 650 | } 651 | if (out[0] < minTop) { 652 | out[0] = minTop; 653 | for (let i = 1; i < n; i++) { 654 | const minAllowed = out[i - 1] + gap; 655 | out[i] = Math.max(out[i], minAllowed); 656 | } 657 | } 658 | } 659 | for (let i = 0; i < n; i++) { 660 | if (out[i] < minTop) out[i] = minTop; 661 | if (out[i] > maxTop) out[i] = maxTop; 662 | } 663 | return out; 664 | }; 665 | 666 | DeepseekTimeline.prototype.detectCssVarTopSupport = function(pad, usableC) { 667 | try { 668 | if (!this.trackContent) return false; 669 | const test = document.createElement('button'); 670 | test.className = 'timeline-dot'; 671 | test.style.visibility = 'hidden'; 672 | test.style.pointerEvents = 'none'; 673 | const expected = pad + 0.5 * usableC; 674 | test.style.setProperty('--n', '0.5'); 675 | this.trackContent.appendChild(test); 676 | const cs = getComputedStyle(test); 677 | const px = parseFloat(cs.top || ''); 678 | test.remove(); 679 | if (!Number.isFinite(px)) return false; 680 | return Math.abs(px - expected) <= 2; 681 | } catch { return false; } 682 | }; 683 | 684 | DeepseekTimeline.prototype.updateTimelineGeometry = function() { 685 | if (!this.timelineBar || !this.trackContent) return; 686 | const H = this.timelineBar.clientHeight || 0; 687 | const pad = this.getCSSVarNumber(this.timelineBar, '--timeline-track-padding', 16); 688 | const minGap = this.getCSSVarNumber(this.timelineBar, '--timeline-min-gap', 24); 689 | const N = this.markers.length; 690 | const desiredHeight = Math.max(H, (N > 0) ? (2 * pad + Math.max(0, N - 1) * minGap) : H); 691 | this.contentHeight = Math.ceil(desiredHeight); 692 | try { this.trackContent.style.height = `${this.contentHeight}px`; } catch {} 693 | 694 | const usableC = Math.max(1, this.contentHeight - 2 * pad); 695 | const desiredY = this.markers.map(m => pad + clamp01(m.baseN ?? m.n ?? 0) * usableC); 696 | const adjusted = this.applyMinGap(desiredY, pad, pad + usableC, minGap); 697 | this.yPositions = adjusted; 698 | for (let i = 0; i < N; i++) { 699 | const n = clamp01((adjusted[i] - pad) / usableC); 700 | this.markers[i].n = n; 701 | if (this.markers[i].dotElement && !this.usePixelTop) { 702 | try { this.markers[i].dotElement.style.setProperty('--n', String(n)); } catch {} 703 | } 704 | } 705 | if (this._cssVarTopSupported === null) { 706 | this._cssVarTopSupported = this.detectCssVarTopSupport(pad, usableC); 707 | this.usePixelTop = !this._cssVarTopSupported; 708 | } 709 | // Reveal slider if scrollable 710 | const barH = this.timelineBar?.clientHeight || 0; 711 | if (this.contentHeight > barH + 1) { 712 | this.sliderAlwaysVisible = true; 713 | this.showSlider(); 714 | } else { 715 | this.sliderAlwaysVisible = false; 716 | } 717 | }; 718 | 719 | DeepseekTimeline.prototype.lowerBound = function(arr, x) { 720 | let lo = 0, hi = arr.length; 721 | while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] < x) lo = mid + 1; else hi = mid; } 722 | return lo; 723 | }; 724 | DeepseekTimeline.prototype.upperBound = function(arr, x) { 725 | let lo = 0, hi = arr.length; 726 | while (lo < hi) { const mid = (lo + hi) >> 1; if (arr[mid] <= x) lo = mid + 1; else hi = mid; } 727 | return lo - 1; 728 | }; 729 | 730 | DeepseekTimeline.prototype.updateVirtualRangeAndRender = function() { 731 | const localVer = this.markersVersion; 732 | if (!this.track || !this.trackContent || this.markers.length === 0) return; 733 | const st = this.track.scrollTop || 0; 734 | const vh = this.track.clientHeight || 0; 735 | const buffer = Math.max(100, vh); 736 | const minY = st - buffer; 737 | const maxY = st + vh + buffer; 738 | const start = this.lowerBound(this.yPositions, minY); 739 | const end = Math.max(start - 1, this.upperBound(this.yPositions, maxY)); 740 | 741 | // cleanup out-of-range 742 | let prevStart = this.visibleRange.start; 743 | let prevEnd = this.visibleRange.end; 744 | const len = this.markers.length; 745 | if (len > 0) { 746 | prevStart = Math.max(0, Math.min(prevStart, len - 1)); 747 | prevEnd = Math.max(-1, Math.min(prevEnd, len - 1)); 748 | } 749 | if (prevEnd >= prevStart) { 750 | for (let i = prevStart; i < Math.min(start, prevEnd + 1); i++) { 751 | const m = this.markers[i]; 752 | if (m && m.dotElement) { try { m.dotElement.remove(); } catch {} m.dotElement = null; } 753 | } 754 | for (let i = Math.max(end + 1, prevStart); i <= prevEnd; i++) { 755 | const m = this.markers[i]; 756 | if (m && m.dotElement) { try { m.dotElement.remove(); } catch {} m.dotElement = null; } 757 | } 758 | } else { 759 | try { this.trackContent.querySelectorAll('.timeline-dot').forEach(n => n.remove()); } catch {} 760 | this.markers.forEach(m => { m.dotElement = null; }); 761 | } 762 | 763 | // create in-range 764 | const frag = document.createDocumentFragment(); 765 | for (let i = start; i <= end; i++) { 766 | const marker = this.markers[i]; 767 | if (!marker) continue; 768 | if (!marker.dotElement) { 769 | const dot = document.createElement('button'); 770 | dot.className = 'timeline-dot'; 771 | dot.dataset.targetIdx = marker.id; 772 | if (marker.summary) dot.setAttribute('aria-label', marker.summary); 773 | try { dot.setAttribute('tabindex', '0'); } catch {} 774 | try { dot.setAttribute('aria-describedby', 'chatgpt-timeline-tooltip'); } catch {} 775 | if (this.usePixelTop) { 776 | dot.style.top = `${Math.round(this.yPositions[i])}px`; 777 | } else { 778 | try { dot.style.setProperty('--n', String(marker.n || 0)); } catch {} 779 | } 780 | // reflect active + star state 781 | try { dot.classList.toggle('active', i === this.activeIdx); } catch {} 782 | try { 783 | dot.classList.toggle('starred', !!marker.starred); 784 | dot.setAttribute('aria-pressed', marker.starred ? 'true' : 'false'); 785 | } catch {} 786 | dot.addEventListener('click', (e) => { 787 | const now = Date.now(); 788 | if (now < (this.suppressClickUntil || 0)) { try { e.preventDefault(); e.stopPropagation(); } catch {} return; } 789 | try { this.scrollToMessage(marker.el); } catch {} 790 | }); 791 | marker.dotElement = dot; 792 | frag.appendChild(dot); 793 | } else { 794 | if (this.usePixelTop) { 795 | marker.dotElement.style.top = `${Math.round(this.yPositions[i])}px`; 796 | } else { 797 | try { marker.dotElement.style.setProperty('--n', String(marker.n || 0)); } catch {} 798 | } 799 | try { marker.dotElement.setAttribute('aria-describedby', 'chatgpt-timeline-tooltip'); } catch {} 800 | try { marker.dotElement.classList.toggle('active', i === this.activeIdx); } catch {} 801 | try { 802 | marker.dotElement.classList.toggle('starred', !!marker.starred); 803 | marker.dotElement.setAttribute('aria-pressed', marker.starred ? 'true' : 'false'); 804 | } catch {} 805 | } 806 | } 807 | // Abort stale pass if markers changed during work 808 | if (localVer !== this.markersVersion) return; 809 | if (frag.childNodes.length) this.trackContent.appendChild(frag); 810 | this.visibleRange = { start, end }; 811 | }; 812 | 813 | DeepseekTimeline.prototype.syncTimelineTrackToMain = function() { 814 | if (!this.track || !this.scrollContainer || !this.contentHeight) return; 815 | const scrollTop = this.scrollContainer.scrollTop; 816 | const ref = scrollTop + this.scrollContainer.clientHeight * 0.45; 817 | const span = Math.max(1, this.spanPx || 1); 818 | const r = clamp01((ref - (this.firstOffset || 0)) / span); 819 | const maxScroll = Math.max(0, this.contentHeight - (this.track.clientHeight || 0)); 820 | const target = Math.round(r * maxScroll); 821 | if (Math.abs((this.track.scrollTop || 0) - target) > 1) { 822 | this.track.scrollTop = target; 823 | } 824 | }; 825 | 826 | DeepseekTimeline.prototype.scheduleScrollSync = function() { 827 | if (this.scrollRafId !== null) return; 828 | this.scrollRafId = requestAnimationFrame(() => { 829 | this.scrollRafId = null; 830 | this.syncTimelineTrackToMain(); 831 | this.updateVirtualRangeAndRender(); 832 | this.computeActiveByScroll(); 833 | this.updateActiveDotUI(); 834 | this.updateSlider(); 835 | }); 836 | }; 837 | 838 | // --- Stage 8: active + tooltip + star --- 839 | DeepseekTimeline.prototype.computeActiveByScroll = function() { 840 | if (!this.scrollContainer || this.markers.length === 0) return; 841 | const containerRect = this.scrollContainer.getBoundingClientRect(); 842 | const scrollTop = this.scrollContainer.scrollTop; 843 | const ref = scrollTop + this.scrollContainer.clientHeight * 0.45; 844 | let active = 0; 845 | for (let i = 0; i < this.markers.length; i++) { 846 | const m = this.markers[i]; 847 | const top = m.el.getBoundingClientRect().top - containerRect.top + scrollTop; 848 | if (top <= ref) active = i; else break; 849 | } 850 | if (this.activeIdx !== active) { 851 | const now = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 852 | const elapsed = now - this.lastActiveChangeTime; 853 | if (elapsed < this.minActiveChangeInterval) { 854 | this.pendingActiveIdx = active; 855 | if (!this.activeChangeTimer) { 856 | const delay = Math.max(this.minActiveChangeInterval - elapsed, 0); 857 | this.activeChangeTimer = setTimeout(() => { 858 | this.activeChangeTimer = null; 859 | if (typeof this.pendingActiveIdx === 'number' && this.pendingActiveIdx !== this.activeIdx) { 860 | this.activeIdx = this.pendingActiveIdx; 861 | this.updateActiveDotUI(); 862 | this.lastActiveChangeTime = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); 863 | } 864 | this.pendingActiveIdx = null; 865 | }, delay); 866 | } 867 | } else { 868 | this.activeIdx = active; 869 | this.updateActiveDotUI(); 870 | this.lastActiveChangeTime = now; 871 | } 872 | } 873 | }; 874 | 875 | DeepseekTimeline.prototype.updateActiveDotUI = function() { 876 | for (let i = 0; i < this.markers.length; i++) { 877 | const m = this.markers[i]; 878 | if (m?.dotElement) { 879 | try { m.dotElement.classList.toggle('active', i === this.activeIdx); } catch {} 880 | } 881 | } 882 | }; 883 | 884 | DeepseekTimeline.prototype.attachInteractions = function() { 885 | if (!this.timelineBar) return; 886 | // Tooltip events 887 | this.onTimelineBarOver = (e) => { 888 | const dot = e.target.closest?.('.timeline-dot'); 889 | if (dot) this.showTooltipForDot(dot); 890 | }; 891 | this.onTimelineBarOut = (e) => { 892 | const fromDot = e.target.closest?.('.timeline-dot'); 893 | const toDot = e.relatedTarget?.closest?.('.timeline-dot'); 894 | if (fromDot && !toDot) this.hideTooltip(); 895 | }; 896 | this.onTimelineBarFocusIn = (e) => { 897 | const dot = e.target.closest?.('.timeline-dot'); 898 | if (dot) this.showTooltipForDot(dot); 899 | }; 900 | this.onTimelineBarFocusOut = (e) => { 901 | const dot = e.target.closest?.('.timeline-dot'); 902 | if (dot) this.hideTooltip(); 903 | }; 904 | try { 905 | this.timelineBar.addEventListener('mouseover', this.onTimelineBarOver); 906 | this.timelineBar.addEventListener('mouseout', this.onTimelineBarOut); 907 | this.timelineBar.addEventListener('focusin', this.onTimelineBarFocusIn); 908 | this.timelineBar.addEventListener('focusout', this.onTimelineBarFocusOut); 909 | // Prevent native drag from causing horizontal wobble 910 | this.timelineBar.addEventListener('dragstart', (e) => { try { e.preventDefault(); } catch {} }); 911 | } catch {} 912 | 913 | // Long-press star 914 | this.onPointerDown = (ev) => { 915 | const dot = ev.target.closest?.('.timeline-dot'); 916 | if (!dot) return; 917 | if (typeof ev.button === 'number' && ev.button !== 0) return; 918 | this.cancelLongPress(); 919 | this.pressTargetDot = dot; 920 | this.pressStartPos = { x: ev.clientX, y: ev.clientY }; 921 | try { dot.classList.add('holding'); } catch {} 922 | this.longPressTimer = setTimeout(() => { 923 | this.longPressTimer = null; 924 | if (!this.pressTargetDot) return; 925 | const id = this.pressTargetDot.dataset.targetIdx; 926 | this.toggleStar(id); 927 | this.suppressClickUntil = Date.now() + 350; 928 | try { this.refreshTooltipForDot(this.pressTargetDot); } catch {} 929 | try { this.pressTargetDot.classList.remove('holding'); } catch {} 930 | }, this.longPressDuration); 931 | }; 932 | this.onPointerMove = (ev) => { 933 | if (!this.pressTargetDot || !this.pressStartPos) return; 934 | const dx = ev.clientX - this.pressStartPos.x; 935 | const dy = ev.clientY - this.pressStartPos.y; 936 | if ((dx * dx + dy * dy) > (this.longPressMoveTolerance * this.longPressMoveTolerance)) { 937 | this.cancelLongPress(); 938 | } 939 | }; 940 | this.onPointerUp = () => { this.cancelLongPress(); }; 941 | this.onPointerCancel = () => { this.cancelLongPress(); }; 942 | this.onPointerLeave = (ev) => { 943 | const dot = ev.target.closest?.('.timeline-dot'); 944 | if (dot && dot === this.pressTargetDot) this.cancelLongPress(); 945 | }; 946 | try { 947 | this.timelineBar.addEventListener('pointerdown', this.onPointerDown); 948 | window.addEventListener('pointermove', this.onPointerMove, { passive: true }); 949 | window.addEventListener('pointerup', this.onPointerUp, { passive: true }); 950 | window.addEventListener('pointercancel', this.onPointerCancel, { passive: true }); 951 | this.timelineBar.addEventListener('pointerleave', this.onPointerLeave); 952 | } catch {} 953 | 954 | // Slider hover show/hide 955 | this.onBarEnter = () => this.showSlider(); 956 | this.onBarLeave = () => this.hideSliderDeferred(); 957 | this.onSliderEnter = () => this.showSlider(); 958 | this.onSliderLeave = () => this.hideSliderDeferred(); 959 | try { 960 | this.timelineBar.addEventListener('pointerenter', this.onBarEnter); 961 | this.timelineBar.addEventListener('pointerleave', this.onBarLeave); 962 | if (this.ui.slider) { 963 | this.ui.slider.addEventListener('pointerenter', this.onSliderEnter); 964 | this.ui.slider.addEventListener('pointerleave', this.onSliderLeave); 965 | } 966 | } catch {} 967 | 968 | // Slider drag 969 | this.onSliderDown = (e) => { 970 | if (!this.ui.sliderHandle || typeof e.button === 'number' && e.button !== 0) return; 971 | this.sliderDragging = true; 972 | this.sliderStartClientY = e.clientY; 973 | const rect = this.ui.sliderHandle.getBoundingClientRect(); 974 | this.sliderStartTop = rect.top; 975 | try { window.addEventListener('pointermove', this.onSliderMove = (ev) => this.handleSliderDrag(ev)); } catch {} 976 | this.onSliderUp = () => this.endSliderDrag(); 977 | try { window.addEventListener('pointerup', this.onSliderUp, { passive: true }); } catch {} 978 | this.showSlider(); 979 | }; 980 | try { this.ui.sliderHandle?.addEventListener('pointerdown', this.onSliderDown); } catch {} 981 | 982 | // Window resize: reposition tooltip if visible + keep geometry fresh 983 | this.onWindowResize = () => { 984 | if (this.ui.tooltip?.classList.contains('visible')) { 985 | const activeDot = this.timelineBar.querySelector('.timeline-dot:hover, .timeline-dot:focus'); 986 | if (activeDot) { 987 | const tip = this.ui.tooltip; 988 | tip.classList.remove('visible'); 989 | let fullText = (activeDot.getAttribute('aria-label') || '').trim(); 990 | try { 991 | const id = activeDot.dataset.targetIdx; 992 | if (id && this.starred.has(id)) fullText = `★ ${fullText}`; 993 | } catch {} 994 | const p = this.computePlacementInfo(activeDot); 995 | const layout = this.truncateToThreeLines(fullText, p.width, true); 996 | tip.textContent = layout.text; 997 | this.placeTooltipAt(activeDot, p.placement, p.width, layout.height); 998 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 999 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 1000 | } 1001 | } 1002 | this.updateTimelineGeometry(); 1003 | this.syncTimelineTrackToMain(); 1004 | this.updateVirtualRangeAndRender(); 1005 | this.updateSlider(); 1006 | try { this.truncateCache?.clear(); } catch {} 1007 | // NOTE: Align with ChatGPT behavior; avoid reapplying min-gap on window resize 1008 | // this.scheduleMinGapCorrection(); 1009 | }; 1010 | try { window.addEventListener('resize', this.onWindowResize); } catch {} 1011 | }; 1012 | 1013 | DeepseekTimeline.prototype.cancelLongPress = function() { 1014 | if (this.longPressTimer) { try { clearTimeout(this.longPressTimer); } catch {} this.longPressTimer = null; } 1015 | if (this.pressTargetDot) { try { this.pressTargetDot.classList.remove('holding'); } catch {} } 1016 | this.pressTargetDot = null; 1017 | this.pressStartPos = null; 1018 | }; 1019 | 1020 | DeepseekTimeline.prototype.loadStars = function() { 1021 | this.starred.clear(); 1022 | const cid = this.conversationId; 1023 | if (!cid) return; 1024 | try { 1025 | const raw = localStorage.getItem(`deepseekTimelineStars:${cid}`); 1026 | if (!raw) return; 1027 | const arr = JSON.parse(raw); 1028 | if (Array.isArray(arr)) arr.forEach(id => this.starred.add(String(id))); 1029 | } catch {} 1030 | }; 1031 | DeepseekTimeline.prototype.saveStars = function() { 1032 | const cid = this.conversationId; 1033 | if (!cid) return; 1034 | try { localStorage.setItem(`deepseekTimelineStars:${cid}`, JSON.stringify(Array.from(this.starred))); } catch {} 1035 | }; 1036 | // Persist a lightweight handoff snapshot from share route so that stars survive redirect to /a/chat/s/ 1037 | DeepseekTimeline.prototype.updateShareHandoffSnapshot = function() { 1038 | try { 1039 | if (!isShareRouteDeepseek()) return; 1040 | const items = []; 1041 | for (let i = 0; i < this.markers.length; i++) { 1042 | const m = this.markers[i]; 1043 | if (!m) continue; 1044 | try { 1045 | const h = this.buildStableHashFromUser(m.el); 1046 | const isStar = this.starred.has(m.id); 1047 | items.push({ hash: h, starred: !!isStar }); 1048 | } catch {} 1049 | } 1050 | const payload = { 1051 | ts: Date.now(), 1052 | fromCid: this.conversationId || null, 1053 | items 1054 | }; 1055 | localStorage.setItem('deepseekShareHandoff', JSON.stringify(payload)); 1056 | } catch {} 1057 | }; 1058 | // On chat route, try adopting recent share snapshot stars by matching message text hashes 1059 | DeepseekTimeline.prototype.tryAdoptShareHandoff = function() { 1060 | try { 1061 | if (isShareRouteDeepseek()) return; // only adopt on non-share chat route 1062 | const raw = localStorage.getItem('deepseekShareHandoff'); 1063 | if (!raw) return; 1064 | let data = null; 1065 | try { data = JSON.parse(raw); } catch { data = null; } 1066 | if (!data || !Array.isArray(data.items)) return; 1067 | const age = Date.now() - (data.ts || 0); 1068 | if (!(age >= 0 && age <= 3 * 60 * 1000)) return; // adopt only if within 3 minutes 1069 | 1070 | // Build a Set of hashes that were starred on share page 1071 | const starredHashes = new Set(); 1072 | for (let i = 0; i < data.items.length; i++) { 1073 | const it = data.items[i]; 1074 | if (it && it.starred && typeof it.hash === 'string') starredHashes.add(it.hash); 1075 | } 1076 | if (starredHashes.size === 0) return; 1077 | 1078 | // Map starred hashes to current chat markers 1079 | let migratedAny = false; 1080 | for (let i = 0; i < this.markers.length; i++) { 1081 | const m = this.markers[i]; 1082 | if (!m) continue; 1083 | let h = null; 1084 | try { h = this.buildStableHashFromUser(m.el); } catch {} 1085 | if (!h) continue; 1086 | if (starredHashes.has(h) && !this.starred.has(m.id)) { 1087 | try { this.starred.add(m.id); migratedAny = true; } catch {} 1088 | // reflect to UI immediately if dot already exists 1089 | try { 1090 | if (m.dotElement) { 1091 | m.starred = true; 1092 | m.dotElement.classList.add('starred'); 1093 | m.dotElement.setAttribute('aria-pressed', 'true'); 1094 | } 1095 | } catch {} 1096 | } 1097 | } 1098 | if (migratedAny) this.saveStars(); 1099 | // Clear handoff to avoid re-applying later pages 1100 | try { localStorage.removeItem('deepseekShareHandoff'); } catch {} 1101 | } catch {} 1102 | }; 1103 | DeepseekTimeline.prototype.toggleStar = function(turnId) { 1104 | const id = String(turnId || ''); 1105 | if (!id) return; 1106 | if (this.starred.has(id)) this.starred.delete(id); else this.starred.add(id); 1107 | this.saveStars(); 1108 | // If on share route, refresh handoff snapshot so redirect can adopt 1109 | try { if (isShareRouteDeepseek()) this.updateShareHandoffSnapshot(); } catch {} 1110 | const m = this.markers.find(mm => mm.id === id); 1111 | if (m && m.dotElement) { 1112 | m.starred = this.starred.has(id); 1113 | try { 1114 | m.dotElement.classList.toggle('starred', m.starred); 1115 | m.dotElement.setAttribute('aria-pressed', m.starred ? 'true' : 'false'); 1116 | } catch {} 1117 | try { this.refreshTooltipForDot(m.dotElement); } catch {} 1118 | } 1119 | }; 1120 | 1121 | // Tooltip helpers 1122 | DeepseekTimeline.prototype.computePlacementInfo = function(dot) { 1123 | const tip = this.ui.tooltip || document.body; 1124 | const dotRect = dot.getBoundingClientRect(); 1125 | const vw = window.innerWidth; 1126 | const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6); 1127 | const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12); 1128 | const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8); 1129 | const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap); 1130 | const viewportPad = 8; 1131 | const maxW = this.getCSSVarNumber(tip, '--timeline-tooltip-max', 288); 1132 | const minW = 160; 1133 | const leftAvail = Math.max(0, dotRect.left - gap - viewportPad); 1134 | const rightAvail = Math.max(0, vw - dotRect.right - gap - viewportPad); 1135 | let placement = (rightAvail > leftAvail) ? 'right' : 'left'; 1136 | let avail = placement === 'right' ? rightAvail : leftAvail; 1137 | const tiers = [280, 240, 200, 160]; 1138 | const hardMax = Math.max(minW, Math.min(maxW, Math.floor(avail))); 1139 | let width = tiers.find(t => t <= hardMax) || Math.max(minW, Math.min(hardMax, 160)); 1140 | if (width < minW) { 1141 | // try switch side 1142 | if (placement === 'left' && rightAvail > leftAvail) { 1143 | placement = 'right'; avail = rightAvail; 1144 | } else if (placement === 'right' && leftAvail >= rightAvail) { 1145 | placement = 'left'; avail = leftAvail; 1146 | } 1147 | const hardMax2 = Math.max(minW, Math.min(maxW, Math.floor(avail))); 1148 | width = tiers.find(t => t <= hardMax2) || Math.max(120, Math.min(hardMax2, minW)); 1149 | } 1150 | width = Math.max(120, Math.min(width, maxW)); 1151 | return { placement, width }; 1152 | }; 1153 | 1154 | DeepseekTimeline.prototype.truncateToThreeLines = function(text, targetWidth, wantLayout = false) { 1155 | try { 1156 | if (!this.measureEl || !this.ui.tooltip) return wantLayout ? { text, height: 0 } : text; 1157 | const tip = this.ui.tooltip; 1158 | const lineH = this.getCSSVarNumber(tip, '--timeline-tooltip-lh', 18); 1159 | const padY = this.getCSSVarNumber(tip, '--timeline-tooltip-pad-y', 10); 1160 | const borderW = this.getCSSVarNumber(tip, '--timeline-tooltip-border-w', 1); 1161 | const maxH = Math.round(3 * lineH + 2 * padY + 2 * borderW); 1162 | const ell = '…'; 1163 | const el = this.measureEl; 1164 | const widthInt = Math.max(0, Math.floor(targetWidth)); 1165 | const rawAll = String(text || '').replace(/\s+/g, ' ').trim(); 1166 | const cacheKey = `${widthInt}|${rawAll}`; 1167 | if (this.truncateCache && this.truncateCache.has(cacheKey)) { 1168 | const cached = this.truncateCache.get(cacheKey); 1169 | return wantLayout ? { text: cached, height: maxH } : cached; 1170 | } 1171 | el.style.width = `${widthInt}px`; 1172 | el.textContent = rawAll; 1173 | let h = el.offsetHeight; 1174 | if (h <= maxH) return wantLayout ? { text: el.textContent, height: h } : el.textContent; 1175 | const raw = el.textContent; 1176 | let lo = 0, hi = raw.length, ans = 0; 1177 | while (lo <= hi) { 1178 | const mid = (lo + hi) >> 1; 1179 | el.textContent = raw.slice(0, mid).trimEnd() + ell; 1180 | h = el.offsetHeight; 1181 | if (h <= maxH) { ans = mid; lo = mid + 1; } else { hi = mid - 1; } 1182 | } 1183 | const out = (ans >= raw.length) ? raw : (raw.slice(0, ans).trimEnd() + ell); 1184 | el.textContent = out; 1185 | h = el.offsetHeight; 1186 | if (this.truncateCache) { try { this.truncateCache.set(cacheKey, out); } catch {} } 1187 | return wantLayout ? { text: out, height: Math.min(h, maxH) } : out; 1188 | } catch { 1189 | return wantLayout ? { text, height: 0 } : text; 1190 | } 1191 | }; 1192 | 1193 | DeepseekTimeline.prototype.placeTooltipAt = function(dot, placement, width, height) { 1194 | if (!this.ui.tooltip) return; 1195 | const tip = this.ui.tooltip; 1196 | const dotRect = dot.getBoundingClientRect(); 1197 | const vw = window.innerWidth; 1198 | const vh = window.innerHeight; 1199 | const arrowOut = this.getCSSVarNumber(tip, '--timeline-tooltip-arrow-outside', 6); 1200 | const baseGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-visual', 12); 1201 | const boxGap = this.getCSSVarNumber(tip, '--timeline-tooltip-gap-box', 8); 1202 | const gap = baseGap + Math.max(0, arrowOut) + Math.max(0, boxGap); 1203 | const viewportPad = 8; 1204 | let left; 1205 | if (placement === 'left') { 1206 | left = Math.round(dotRect.left - gap - width); 1207 | if (left < viewportPad) { 1208 | const altLeft = Math.round(dotRect.right + gap); 1209 | if (altLeft + width <= vw - viewportPad) { placement = 'right'; left = altLeft; } 1210 | else { const fitWidth = Math.max(120, vw - viewportPad - altLeft); left = altLeft; width = fitWidth; } 1211 | } 1212 | } else { 1213 | left = Math.round(dotRect.right + gap); 1214 | if (left + width > vw - viewportPad) { 1215 | const altLeft = Math.round(dotRect.left - gap - width); 1216 | if (altLeft >= viewportPad) { placement = 'left'; left = altLeft; } 1217 | else { const fitWidth = Math.max(120, vw - viewportPad - left); width = fitWidth; } 1218 | } 1219 | } 1220 | let top = Math.round(dotRect.top + dotRect.height / 2 - height / 2); 1221 | top = Math.max(viewportPad, Math.min(vh - height - viewportPad, top)); 1222 | tip.style.width = `${Math.floor(width)}px`; 1223 | tip.style.height = `${Math.floor(height)}px`; 1224 | tip.style.left = `${left}px`; 1225 | tip.style.top = `${top}px`; 1226 | tip.setAttribute('data-placement', placement); 1227 | }; 1228 | 1229 | DeepseekTimeline.prototype.showTooltipForDot = function(dot) { 1230 | if (!this.ui.tooltip) return; 1231 | try { if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); this.tooltipHideTimer = null; } } catch {} 1232 | const tip = this.ui.tooltip; 1233 | tip.classList.remove('visible'); 1234 | let fullText = (dot.getAttribute('aria-label') || '').trim(); 1235 | try { const id = dot.dataset.targetIdx; if (id && this.starred.has(id)) fullText = `★ ${fullText}`; } catch {} 1236 | const p = this.computePlacementInfo(dot); 1237 | const layout = this.truncateToThreeLines(fullText, p.width, true); 1238 | tip.textContent = layout.text; 1239 | this.placeTooltipAt(dot, p.placement, p.width, layout.height); 1240 | tip.setAttribute('aria-hidden', 'false'); 1241 | if (this.showRafId !== null) { try { cancelAnimationFrame(this.showRafId); } catch {} this.showRafId = null; } 1242 | this.showRafId = requestAnimationFrame(() => { this.showRafId = null; tip.classList.add('visible'); }); 1243 | }; 1244 | 1245 | DeepseekTimeline.prototype.hideTooltip = function(immediate = false) { 1246 | if (!this.ui.tooltip) return; 1247 | const doHide = () => { 1248 | this.ui.tooltip.classList.remove('visible'); 1249 | this.ui.tooltip.setAttribute('aria-hidden', 'true'); 1250 | this.tooltipHideTimer = null; 1251 | }; 1252 | if (immediate) return doHide(); 1253 | try { if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); } } catch {} 1254 | this.tooltipHideTimer = setTimeout(doHide, this.tooltipHideDelay); 1255 | }; 1256 | 1257 | DeepseekTimeline.prototype.refreshTooltipForDot = function(dot) { 1258 | if (!this.ui?.tooltip || !dot) return; 1259 | const tip = this.ui.tooltip; 1260 | if (!tip.classList.contains('visible')) return; 1261 | let fullText = (dot.getAttribute('aria-label') || '').trim(); 1262 | try { const id = dot.dataset.targetIdx; if (id && this.starred.has(id)) fullText = `★ ${fullText}`; } catch {} 1263 | const p = this.computePlacementInfo(dot); 1264 | const layout = this.truncateToThreeLines(fullText, p.width, true); 1265 | tip.textContent = layout.text; 1266 | this.placeTooltipAt(dot, p.placement, p.width, layout.height); 1267 | }; 1268 | 1269 | // Stable id (hash) for user messages based on normalized user text 1270 | DeepseekTimeline.prototype.buildStableHashFromUser = function(el) { 1271 | try { 1272 | const raw = normalizeText(el?.textContent || '').replace(/[\u200B-\u200D\uFEFF]/g, ''); 1273 | const s = raw.slice(0, 256); 1274 | let h = 0x811c9dc5 >>> 0; // FNV-1a 32-bit 1275 | for (let i = 0; i < s.length; i++) { 1276 | h ^= s.charCodeAt(i); 1277 | h = Math.imul(h, 0x01000193); 1278 | } 1279 | return (h >>> 0).toString(36); 1280 | } catch { return Math.random().toString(36).slice(2, 8); } 1281 | }; 1282 | 1283 | // --- P1: container rebind helpers --- 1284 | DeepseekTimeline.prototype.findConversationRootFromFirst = function(firstMsg) { 1285 | if (!firstMsg) return null; 1286 | try { 1287 | const allMsgs = Array.from(document.querySelectorAll(SEL_MSG)); 1288 | let node = firstMsg.parentElement; 1289 | while (node && node !== document.body) { 1290 | let allInside = true; 1291 | for (let i = 0; i < allMsgs.length; i++) { 1292 | if (!node.contains(allMsgs[i])) { allInside = false; break; } 1293 | } 1294 | if (allInside) return node; 1295 | node = node.parentElement; 1296 | } 1297 | } catch {} 1298 | return firstMsg.parentElement || null; 1299 | }; 1300 | 1301 | DeepseekTimeline.prototype.ensureContainersUpToDate = function() { 1302 | const first = document.querySelector(SEL_MSG); 1303 | if (!first) return; 1304 | const newRoot = this.findConversationRootFromFirst(first); 1305 | if (newRoot && newRoot !== this.conversationContainer) { 1306 | this.rebindConversationContainer(newRoot); 1307 | } 1308 | }; 1309 | 1310 | DeepseekTimeline.prototype.rebindConversationContainer = function(newConv) { 1311 | // Detach old listeners/observers tied to containers 1312 | if (this.scrollContainer && this.onScroll) { 1313 | try { this.scrollContainer.removeEventListener('scroll', this.onScroll); } catch {} 1314 | try { window.removeEventListener('scroll', this.onScroll); } catch {} 1315 | } 1316 | try { this.mutationObserver?.disconnect(); } catch {} 1317 | try { this.resizeObserver?.disconnect(); } catch {} 1318 | try { this.themeObserver?.disconnect(); } catch {} 1319 | if (this.onVisualViewportResize && window.visualViewport) { 1320 | try { window.visualViewport.removeEventListener('resize', this.onVisualViewportResize); } catch {} 1321 | this.onVisualViewportResize = null; 1322 | } 1323 | 1324 | this.conversationContainer = newConv; 1325 | this.scrollContainer = getScrollableAncestor(this.conversationContainer); 1326 | 1327 | // Re-attach 1328 | this.attachObservers(); 1329 | this.attachScrollSync(); 1330 | this.rebuildMarkers(); 1331 | }; 1332 | 1333 | // --- P1: min-gap correction on idle --- 1334 | DeepseekTimeline.prototype.scheduleMinGapCorrection = function() { 1335 | try { if (this.resizeIdleTimer) { clearTimeout(this.resizeIdleTimer); } } catch {} 1336 | try { 1337 | if (this.resizeIdleRICId && typeof cancelIdleCallback === 'function') { 1338 | cancelIdleCallback(this.resizeIdleRICId); 1339 | this.resizeIdleRICId = null; 1340 | } 1341 | } catch {} 1342 | this.resizeIdleTimer = setTimeout(() => { 1343 | this.resizeIdleTimer = null; 1344 | try { 1345 | if (typeof requestIdleCallback === 'function') { 1346 | this.resizeIdleRICId = requestIdleCallback(() => { 1347 | this.resizeIdleRICId = null; 1348 | this.reapplyMinGapAfterResize(); 1349 | }, { timeout: 200 }); 1350 | return; 1351 | } 1352 | } catch {} 1353 | this.reapplyMinGapAfterResize(); 1354 | }, this.resizeIdleDelay); 1355 | }; 1356 | 1357 | DeepseekTimeline.prototype.reapplyMinGapAfterResize = function() { 1358 | if (!this.timelineBar || this.markers.length === 0) return; 1359 | const barHeight = this.timelineBar.clientHeight || 0; 1360 | const trackPadding = this.getCSSVarNumber(this.timelineBar, '--timeline-track-padding', 16); 1361 | const usable = Math.max(1, barHeight - 2 * trackPadding); 1362 | const minTop = trackPadding; 1363 | const maxTop = trackPadding + usable; 1364 | const minGap = this.getCSSVarNumber(this.timelineBar, '--timeline-min-gap', 24); 1365 | const desired = this.markers.map(m => minTop + (m.n ?? 0) * usable); 1366 | const adjusted = this.applyMinGap(desired, minTop, maxTop, minGap); 1367 | for (let i = 0; i < this.markers.length; i++) { 1368 | const top = adjusted[i]; 1369 | const n = (top - minTop) / Math.max(1, (maxTop - minTop)); 1370 | this.markers[i].n = clamp01(n); 1371 | try { this.markers[i].dotElement?.style.setProperty('--n', String(this.markers[i].n)); } catch {} 1372 | } 1373 | }; 1374 | 1375 | // Slider helpers (P0) 1376 | DeepseekTimeline.prototype.updateSlider = function() { 1377 | if (!this.ui.slider || !this.ui.sliderHandle) return; 1378 | if (!this.contentHeight || !this.timelineBar || !this.track) return; 1379 | const barRect = this.timelineBar.getBoundingClientRect(); 1380 | const barH = barRect.height || 0; 1381 | const pad = this.getCSSVarNumber(this.timelineBar, '--timeline-track-padding', 16); 1382 | const innerH = Math.max(0, barH - 2 * pad); 1383 | if (this.contentHeight <= barH + 1 || innerH <= 0) { 1384 | this.sliderAlwaysVisible = false; 1385 | try { this.ui.slider.classList.remove('visible'); this.ui.slider.style.opacity = ''; } catch {} 1386 | return; 1387 | } 1388 | this.sliderAlwaysVisible = true; 1389 | const railLen = Math.max(120, Math.min(240, Math.floor(barH * 0.45))); 1390 | const railTop = Math.round(barRect.top + pad + (innerH - railLen) / 2); 1391 | const railLeftGap = 8; // gap from bar's left edge 1392 | const sliderWidth = 12; 1393 | const left = Math.round(barRect.left - railLeftGap - sliderWidth); 1394 | this.ui.slider.style.left = `${left}px`; 1395 | this.ui.slider.style.top = `${railTop}px`; 1396 | this.ui.slider.style.height = `${railLen}px`; 1397 | 1398 | const handleH = 22; 1399 | const maxTop = Math.max(0, railLen - handleH); 1400 | const range = Math.max(1, this.contentHeight - barH); 1401 | const st = this.track.scrollTop || 0; 1402 | const r = Math.max(0, Math.min(1, st / range)); 1403 | const top = Math.round(r * maxTop); 1404 | this.ui.sliderHandle.style.height = `${handleH}px`; 1405 | this.ui.sliderHandle.style.top = `${top}px`; 1406 | try { this.ui.slider.classList.add('visible'); this.ui.slider.style.opacity = ''; } catch {} 1407 | }; 1408 | 1409 | DeepseekTimeline.prototype.showSlider = function() { 1410 | if (!this.ui.slider) return; 1411 | this.ui.slider.classList.add('visible'); 1412 | if (this.sliderFadeTimer) { try { clearTimeout(this.sliderFadeTimer); } catch {} this.sliderFadeTimer = null; } 1413 | this.updateSlider(); 1414 | }; 1415 | 1416 | DeepseekTimeline.prototype.hideSliderDeferred = function() { 1417 | if (this.sliderDragging || this.sliderAlwaysVisible) return; 1418 | if (this.sliderFadeTimer) { try { clearTimeout(this.sliderFadeTimer); } catch {} } 1419 | this.sliderFadeTimer = setTimeout(() => { 1420 | this.sliderFadeTimer = null; 1421 | try { this.ui.slider?.classList.remove('visible'); } catch {} 1422 | }, this.sliderFadeDelay); 1423 | }; 1424 | 1425 | DeepseekTimeline.prototype.handleSliderDrag = function(e) { 1426 | if (!this.sliderDragging || !this.timelineBar || !this.track) return; 1427 | const barRect = this.timelineBar.getBoundingClientRect(); 1428 | const barH = barRect.height || 0; 1429 | const railLen = parseFloat(this.ui.slider.style.height || '0') || Math.max(120, Math.min(240, Math.floor(barH * 0.45))); 1430 | const handleH = this.ui.sliderHandle.getBoundingClientRect().height || 22; 1431 | const maxTop = Math.max(0, railLen - handleH); 1432 | const delta = e.clientY - this.sliderStartClientY; 1433 | let top = Math.max(0, Math.min(maxTop, (this.sliderStartTop + delta) - (parseFloat(this.ui.slider.style.top) || 0))); 1434 | const r = (maxTop > 0) ? (top / maxTop) : 0; 1435 | const range = Math.max(1, this.contentHeight - barH); 1436 | this.track.scrollTop = Math.round(r * range); 1437 | this.updateVirtualRangeAndRender(); 1438 | this.showSlider(); 1439 | this.updateSlider(); 1440 | }; 1441 | 1442 | DeepseekTimeline.prototype.endSliderDrag = function() { 1443 | this.sliderDragging = false; 1444 | try { window.removeEventListener('pointermove', this.onSliderMove); } catch {} 1445 | this.onSliderMove = null; 1446 | this.onSliderUp = null; 1447 | this.hideSliderDeferred(); 1448 | }; 1449 | 1450 | // --- Entry & SPA wiring (Stage 3) --- 1451 | let timelineActive = true; // global on/off 1452 | let providerEnabled = true; // per-provider on/off (deepseek) 1453 | let manager = null; 1454 | let currentUrl = location.href; 1455 | let routeCheckIntervalId = null; 1456 | let routeListenersAttached = false; 1457 | let initialObserver = null; 1458 | let pageObserver = null; 1459 | let initTimerId = null; // delayed init timer for SPA route 1460 | 1461 | function initializeTimeline() { 1462 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1463 | // Remove any leftover UI before creating a new instance (align with ChatGPT) 1464 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1465 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1466 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1467 | manager = new DeepseekTimeline(); 1468 | manager.init().catch(err => console.debug('[DeepseekTimeline] init failed:', err)); 1469 | } 1470 | 1471 | function cleanupGlobalObservers() { 1472 | // Align with ChatGPT: keep route listeners and HREF polling alive. 1473 | // Only detach heavy page-level MutationObserver; allow future SPA navigations to be detected. 1474 | try { pageObserver?.disconnect(); } catch {} 1475 | pageObserver = null; 1476 | // Do not clear routeCheckIntervalId (keeps href polling active) 1477 | // Do not touch initialObserver (bootstrap-only) 1478 | try { if (initTimerId) { clearTimeout(initTimerId); initTimerId = null; } } catch {} 1479 | } 1480 | 1481 | function handleUrlChange() { 1482 | if (location.href === currentUrl) return; 1483 | // Cancel any pending init from previous route 1484 | try { if (initTimerId) { clearTimeout(initTimerId); initTimerId = null; } } catch {} 1485 | currentUrl = location.href; 1486 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1487 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1488 | try { document.querySelector('.timeline-left-slider')?.remove(); } catch {} 1489 | try { document.getElementById('chatgpt-timeline-tooltip')?.remove(); } catch {} 1490 | 1491 | const enabled = (timelineActive && providerEnabled); 1492 | if (isConversationRouteDeepseek() && enabled) { 1493 | // If messages already present, init immediately; otherwise, wait for them 1494 | if (document.querySelector(SEL_MSG)) { 1495 | initializeTimeline(); 1496 | } else { 1497 | // debounce any previous attempt 1498 | try { if (initTimerId) { clearTimeout(initTimerId); initTimerId = null; } } catch {} 1499 | initTimerId = setTimeout(() => { 1500 | initTimerId = null; 1501 | // wait briefly for first message to appear 1502 | try { 1503 | waitForElement(SEL_MSG, 5000).then((el) => { 1504 | if (el && isConversationRouteDeepseek() && (timelineActive && providerEnabled)) { 1505 | initializeTimeline(); 1506 | } 1507 | }); 1508 | } catch {} 1509 | }, 300); 1510 | } 1511 | } else { 1512 | cleanupGlobalObservers(); 1513 | } 1514 | } 1515 | 1516 | // Align with ChatGPT: attach route listeners once (popstate/hashchange + href polling) 1517 | function attachRouteListenersOnce() { 1518 | if (routeListenersAttached) return; 1519 | routeListenersAttached = true; 1520 | try { window.addEventListener('popstate', handleUrlChange); } catch {} 1521 | try { window.addEventListener('hashchange', handleUrlChange); } catch {} 1522 | try { 1523 | routeCheckIntervalId = setInterval(() => { 1524 | if (location.href !== currentUrl) handleUrlChange(); 1525 | }, 800); 1526 | } catch {} 1527 | } 1528 | 1529 | function detachRouteListeners() { 1530 | if (!routeListenersAttached) return; 1531 | routeListenersAttached = false; 1532 | try { window.removeEventListener('popstate', handleUrlChange); } catch {} 1533 | try { window.removeEventListener('hashchange', handleUrlChange); } catch {} 1534 | try { if (routeCheckIntervalId) { clearInterval(routeCheckIntervalId); routeCheckIntervalId = null; } } catch {} 1535 | } 1536 | 1537 | // Boot: wait for first message to appear then init (if route matches) 1538 | try { 1539 | initialObserver = new MutationObserver(() => { 1540 | if (document.querySelector(SEL_MSG)) { 1541 | if (isConversationRouteDeepseek() && (timelineActive && providerEnabled)) initializeTimeline(); 1542 | try { initialObserver.disconnect(); } catch {} 1543 | pageObserver = new MutationObserver(handleUrlChange); 1544 | try { pageObserver.observe(document.body, { childList: true, subtree: true }); } catch {} 1545 | // Ensure route listeners are attached once 1546 | attachRouteListenersOnce(); 1547 | } 1548 | }); 1549 | initialObserver.observe(document.body, { childList: true, subtree: true }); 1550 | } catch {} 1551 | 1552 | // Proactively attach route listeners; guarded to run only once 1553 | attachRouteListenersOnce(); 1554 | 1555 | // Read initial toggles (new keys only) and react to changes 1556 | try { 1557 | if (chrome?.storage?.local) { 1558 | chrome.storage.local.get({ timelineActive: true, timelineProviders: {} }, (res) => { 1559 | try { timelineActive = !!res.timelineActive; } catch { timelineActive = true; } 1560 | try { 1561 | const map = res.timelineProviders || {}; 1562 | providerEnabled = (typeof map.deepseek === 'boolean') ? map.deepseek : true; 1563 | } catch { providerEnabled = true; } 1564 | 1565 | const enabled = timelineActive && providerEnabled; 1566 | if (!enabled) { 1567 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1568 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1569 | } else { 1570 | if (isConversationRouteDeepseek() && document.querySelector(SEL_MSG)) { 1571 | initializeTimeline(); 1572 | } 1573 | } 1574 | }); 1575 | chrome.storage.onChanged.addListener((changes, area) => { 1576 | if (area !== 'local' || !changes) return; 1577 | let changed = false; 1578 | if ('timelineActive' in changes) { 1579 | timelineActive = !!changes.timelineActive.newValue; 1580 | changed = true; 1581 | } 1582 | if ('timelineProviders' in changes) { 1583 | try { 1584 | const map = changes.timelineProviders.newValue || {}; 1585 | providerEnabled = (typeof map.deepseek === 'boolean') ? map.deepseek : true; 1586 | changed = true; 1587 | } catch {} 1588 | } 1589 | if (!changed) return; 1590 | const enabled = timelineActive && providerEnabled; 1591 | if (!enabled) { 1592 | if (manager) { try { manager.destroy(); } catch {} manager = null; } 1593 | try { document.querySelector('.chatgpt-timeline-bar')?.remove(); } catch {} 1594 | } else { 1595 | if (isConversationRouteDeepseek() && document.querySelector(SEL_MSG)) { 1596 | initializeTimeline(); 1597 | } 1598 | } 1599 | }); 1600 | } 1601 | } catch {} 1602 | 1603 | // Log presence 1604 | try { console.debug('[DeepseekTimeline] content-deepseek.js loaded (P0)'); } catch {} 1605 | })(); 1606 | --------------------------------------------------------------------------------