├── 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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------