diff --git a/src/app/index.tsx b/src/app/index.tsx index 94a01c6..0ce24f9 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -29,6 +29,7 @@ function App() { const [focusLabel, setFocusLabel] = useState(); const [focusChildLabel, setFocusChildLabel] = useState(); const timersRef = useRef[]>([]); + const didInitRef = useRef(false); const isLoading = loadingPhase !== "idle"; @@ -54,11 +55,14 @@ function App() { }; }, [clearTimers]); - const countTabs = (result: GroupingResponse) => - result.groups.reduce( - (sum, g) => sum + g.tabs.length + (g.children?.reduce((s, c) => s + c.tabs.length, 0) ?? 0), - 0, - ) + result.ungrouped.length; + const countTabs = useCallback( + (result: GroupingResponse) => + result.groups.reduce( + (sum, g) => sum + g.tabs.length + (g.children?.reduce((s, c) => s + c.tabs.length, 0) ?? 0), + 0, + ) + result.ungrouped.length, + [], + ); const doGrouping = useCallback( async (spec: number, opts?: { silent?: boolean }) => { @@ -116,15 +120,6 @@ function App() { }); }); setGroupAssignments(assignments); - } - - if (silent) { - // Silent regroup: storage listener picks up the update automatically. - // Only set state here as fallback (storage listener may have already fired). - setRawGrouping(result); - setTabCount(countTabs(result)); - } else { - // Full regroup: update all state setRawGrouping(result); setTabCount(countTabs(result)); setHistory([]); @@ -132,6 +127,11 @@ function App() { setFocusChildLabel(undefined); setLoadingPhase("idle"); } + + if (silent) { + setRawGrouping(result); + setTabCount(countTabs(result)); + } } catch (err) { if (!silent) { setError(err instanceof Error ? err.message : "Failed to group tabs"); @@ -142,9 +142,10 @@ function App() { [clearTimers, countTabs], ); - // Initial grouping — show cache instantly, then refresh in background + // Initial grouping — runs once when API key becomes available useEffect(() => { - if (!config?.anthropicApiKey) return; + if (!config?.anthropicApiKey || didInitRef.current) return; + didInitRef.current = true; (async () => { try { @@ -152,14 +153,10 @@ function App() { type: "get-cached-grouping", })) as GroupingResponse | null; if (cached && cached.groups.length > 0) { - // Show cached grouping instantly as placeholder setRawGrouping(cached); setTabCount(countTabs(cached)); setSpecificity(cached.specificity ?? specificity); setLoadingPhase("idle"); - // Kick off a silent background regroup so tabs stay fresh - // (storage listener will pick up the result) - doGrouping(cached.specificity ?? specificity, { silent: true }); return; } } catch { @@ -167,23 +164,8 @@ function App() { } doGrouping(specificity); })(); - }, [config?.anthropicApiKey, countTabs, doGrouping, specificity]); - - // Listen for background regroups (tab created/removed/updated) - useEffect(() => { - const listener = (changes: { [key: string]: chrome.storage.StorageChange }, area: string) => { - if (area !== "local") return; - if (!changes.groupthink_grouping?.newValue) return; - // Only pick up background updates when not actively loading - if (loadingPhase !== "idle" || chatLoading) return; - - const updated = changes.groupthink_grouping.newValue as GroupingResponse; - setRawGrouping(updated); - setTabCount(countTabs(updated)); - }; - chrome.storage.onChanged.addListener(listener); - return () => chrome.storage.onChanged.removeListener(listener); - }, [loadingPhase, chatLoading, countTabs]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally runs once + }, [config?.anthropicApiKey]); const handleSpecificityChange = useCallback( (newSpec: number) => { diff --git a/src/background/index.ts b/src/background/index.ts index 45a5644..c4577f2 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -113,33 +113,6 @@ chrome.commands.onCommand.addListener((command) => { } }); -// ── Preemptive background regrouping on tab changes ── - -let regroupTimer: ReturnType | undefined; - -function scheduleRegroup() { - clearTimeout(regroupTimer); - regroupTimer = setTimeout(async () => { - try { - const config = await Storage.getConfig(); - if (!config.anthropicApiKey) return; - // Use specificity from last grouping (preserves user's slider position) - const lastGrouping = await Storage.getGrouping(); - const specificity = lastGrouping?.specificity ?? config.specificity; - console.log("[GroupThink] preemptive regroup triggered by tab change"); - await handleMessage({ type: "group-tabs", specificity }); - } catch (err) { - console.warn("[GroupThink] preemptive regroup failed:", err); - } - }, 10_000); // 10s debounce -} - -chrome.tabs.onCreated.addListener(scheduleRegroup); -chrome.tabs.onRemoved.addListener(scheduleRegroup); -chrome.tabs.onUpdated.addListener((_tabId, changeInfo) => { - if (changeInfo.url) scheduleRegroup(); -}); - // ── Auto-configure on install ── chrome.runtime.onInstalled.addListener(async (details) => { @@ -179,8 +152,17 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): case "ping": return { ok: true }; - case "get-config": - return Storage.getConfig(); + case "get-config": { + const cfg = await Storage.getConfig(); + if (!cfg.anthropicApiKey && ENV_CONFIG?.ANTHROPIC_API_KEY) { + await Storage.setConfig({ + anthropicApiKey: ENV_CONFIG.ANTHROPIC_API_KEY, + model: ENV_CONFIG.ANTHROPIC_MODEL || cfg.model, + }); + return Storage.getConfig(); + } + return cfg; + } case "set-config": { await Storage.setConfig(message.config as Record); @@ -201,39 +183,10 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): case "get-tabs": { const tabs = await getAllTabs(); - - // Capture thumbnails for each window's active (visible) tab - const windowIds = [...new Set(tabs.map((t) => t.windowId))]; - const thumbnails = new Map(); - - await Promise.allSettled( - windowIds.map(async (wid) => { - try { - const dataUrl = await chrome.tabs.captureVisibleTab(wid, { - format: "jpeg", - quality: 40, - }); - // Find the active tab in this window - const activeTabs = await chrome.tabs.query({ windowId: wid, active: true }); - if (activeTabs[0]?.id) { - thumbnails.set(activeTabs[0].id, dataUrl); - } - } catch { - // captureVisibleTab can fail (e.g., devtools focused) — ignore - } - }), - ); - - // Attach thumbnails to matching tabs - const enrichedTabs = tabs.map((t) => { - const thumb = thumbnails.get(t.id); - return thumb ? { ...t, thumbnail: thumb } : t; - }); - console.log( - `[GroupThink] get-tabs: ${tabs.length} tabs, ${thumbnails.size} thumbnails in ${Math.round(performance.now() - t0)}ms`, + `[GroupThink] get-tabs: ${tabs.length} tabs in ${Math.round(performance.now() - t0)}ms`, ); - return enrichedTabs; + return tabs; } case "group-tabs": { diff --git a/src/components/TreemapView.tsx b/src/components/TreemapView.tsx index f634fc4..03f8853 100644 --- a/src/components/TreemapView.tsx +++ b/src/components/TreemapView.tsx @@ -125,7 +125,6 @@ export function TreemapView({ const handleClickGroup = useCallback( (groupId: string) => { - // Check if this is a child-group click (when parent is focused) if (focusedGroupId) { const focusedGroup = grouping.groups.find((g) => g.id === focusedGroupId); const isChild = @@ -133,6 +132,7 @@ export function TreemapView({ groupId === `${focusedGroupId}__direct`; if (isChild) { + // Toggle child focus within the focused group if (focusedChildId === groupId) { setFocusedChildId(null); } else { @@ -141,6 +141,16 @@ export function TreemapView({ return; } + // Check if this is a child of a sibling group — navigate to the parent + const siblingParent = grouping.groups.find( + (g) => g.id !== focusedGroupId && g.children?.some((c) => c.id === groupId), + ); + if (siblingParent) { + setFocusedGroupId(siblingParent.id); + setFocusedChildId(null); + return; + } + if (groupId !== focusedGroupId) { // Sibling group navigation — direct jump setFocusedGroupId(groupId); diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 48c1787..05ba4a8 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -167,7 +167,7 @@ export class GroupThinkAI { const t0 = performance.now(); const response = await this.client.messages.create({ model: this.model, - max_tokens: 4096, + max_tokens: 1024, system: SYSTEM_PROMPT, messages: [{ role: "user", content: buildSweepPrompt(existingGroups, ungroupedTabs) }], }); @@ -200,7 +200,7 @@ export class GroupThinkAI { const t0 = performance.now(); const response = await this.client.messages.create({ model: this.model, - max_tokens: 4096, + max_tokens: 2048, system: SYSTEM_PROMPT, messages, }); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts index ba7272e..36ebdcf 100644 --- a/src/lib/prompts.ts +++ b/src/lib/prompts.ts @@ -1,44 +1,16 @@ -export const SYSTEM_PROMPT = `You are a tab organizer. Given a list of browser tabs, group them by TOPIC and INTENT, not by website or domain. - -Core principle: -- Group by what the page is ABOUT, not where it's hosted. A YouTube video about "Claude Code best practices" belongs with other Claude/Anthropic tabs, NOT in a "Videos" or "YouTube" group. A Medium article about React belongs with React/frontend tabs, not a "Medium" group. A GitHub repo for a Python library belongs with Python tabs, not a generic "GitHub" group. -- Think about what the user was DOING when they opened each tab — group tabs that serve the same task or topic together. -- When browser context signals are provided (visit frequency, bookmarks, recently closed tabs), use them to make better grouping decisions: high visit counts indicate active projects, bookmark folders reveal the user's mental model, recently closed tabs hint at completed or paused tasks. +export const SYSTEM_PROMPT = `Tab organizer. Group by TOPIC/INTENT, never by website. Return valid JSON only. Rules: -- Return ONLY valid JSON, no markdown fences, no commentary -- Each group has a "label" of 1–3 words (concise, like a magazine section header) -- CRITICAL: When the specificity level calls for subcategories (see user prompt), you MUST use "children" to decompose qualifying groups. A group that qualifies for decomposition MUST NOT have an empty or missing "children" array. When you create children, move all tab IDs into the children — the parent's "tabIds" should be empty. -- Children have their own "label" (1–3 words) and optionally a "sublabel" (1–3 words) for extra context -- Groups with 3 or fewer tabs should stay broad regardless of specificity -- You MUST categorize every tab. The "ungrouped" array should be empty. Create a catchall group (e.g. "Miscellany") rather than leaving any tab ungrouped. -- Never repeat a tab ID across multiple groups -- Be witty and precise with labels — prefer evocative over generic -- NEVER create groups based on website/domain (no "YouTube", "GitHub", "Medium", "Reddit" groups). Always group by the content's topic. -- CRITICAL: The field for tab IDs must be exactly "tabIds" (camelCase). Never use "tabs", "tab_ids", or other variants. -- For each tab, write a 5–10 word description summarizing the page content. Include these in a top-level "tabDescriptions" map keyed by tab ID (as string). -- For each tab, provide 1–3 short tags (1–2 words each) describing the page's topic or purpose. Include these in a top-level "tabTags" map keyed by tab ID (as string). Tags should be lowercase, concise topic labels (e.g., "ai", "docs", "pricing", "tutorial", "api reference"). - -JSON schema: -{ - "groups": [ - { - "label": "string (1-3 words)", - "sublabel": "string (1-3 words, optional)", - "tabIds": [number], - "children": [ - { - "label": "string", - "sublabel": "string (optional)", - "tabIds": [number] - } - ] - } - ], - "ungrouped": [number], - "tabDescriptions": { "": "string (5-10 word description)" }, - "tabTags": { "": ["string (1-2 word tag)", ...] } -}`; +1. Group by page content, not host. YouTube video about React → React group, not "YouTube". +2. Labels: 1–3 words, evocative. No domain-based groups. +3. Field name for tab IDs is "tabIds" (camelCase). No variants. +4. Categorize every tab. "ungrouped" must be empty — use a catchall if needed. +5. No duplicate tab IDs across groups. +6. When specificity requires children: move ALL tabIds into children, parent tabIds=[]. +7. Groups with ≤3 tabs: no children regardless of specificity. +8. Use browser context signals when provided (visit frequency, bookmarks, etc). + +Schema: {"groups":[{"label":"str","sublabel?":"str","tabIds":[int],"children?":[{"label":"str","sublabel?":"str","tabIds":[int]}]}],"ungrouped":[int]}`; export function buildGroupingPrompt( tabs: { id: number; title: string; url: string }[], @@ -46,30 +18,35 @@ export function buildGroupingPrompt( contextHints?: string, ): string { const tabList = tabs - .map( - (t) => - `[${t.id}] "${t.title}" — ${new URL(t.url).hostname}${new URL(t.url).pathname.slice(0, 60)}`, - ) + .map((t) => { + try { + const u = new URL(t.url); + return `${t.id}|${t.title}|${u.hostname}${u.pathname.slice(0, 50)}`; + } catch { + return `${t.id}|${t.title}|${t.url.slice(0, 60)}`; + } + }) .join("\n"); - let prompt = `Specificity level: ${specificity}/10 + const childRule = + specificity <= 3 + ? "No children. 3–5 broad groups." + : specificity <= 6 + ? "Groups with 6+ tabs MUST have 2–4 children (min 2 tabs each)." + : "Groups with 4+ tabs MUST have 2–5 children (min 2 tabs each)."; -Guidelines for this level: -- 1–3: Use 3–5 very broad categories. No subcategories. -- 4–6: Moderate detail. Any group with 6+ tabs MUST be decomposed into 2–4 children subcategories. Each child must have at least 2 tabs. -- 7–10: Fine-grained. Any group with 4+ tabs MUST be decomposed into 2–5 children subcategories. Each child must have at least 2 tabs. This is a hard requirement. + let prompt = `Specificity: ${specificity}/10. ${childRule} -Tabs: -${tabList}`; +Tabs (id|title|url): +${tabList} + +Also return: "tabDescriptions":{"":"5-10 word summary"}, "tabTags":{"":["tag",...]}. +Tags: 1–3 lowercase topic words per tab.`; if (contextHints) { prompt += `\n\n${contextHints}`; } - prompt += `\n\nBefore returning, verify: at this specificity level, every group above the tab threshold has "children". If not, fix it. - -Return JSON only.`; - return prompt; } @@ -77,36 +54,24 @@ export function buildSweepPrompt( existingGroups: { label: string; tabIds: number[] }[], ungroupedTabs: { id: number; title: string; url: string }[], ): string { - const groupList = existingGroups - .map((g) => `• "${g.label}" (${g.tabIds.length} tabs)`) - .join("\n"); + const groupList = existingGroups.map((g) => `${g.label} (${g.tabIds.length})`).join(", "); const tabList = ungroupedTabs .map((t) => { try { - return `[${t.id}] "${t.title}" — ${new URL(t.url).hostname}${new URL(t.url).pathname.slice(0, 60)}`; + return `${t.id}|${t.title}|${new URL(t.url).hostname}`; } catch { - return `[${t.id}] "${t.title}" — ${t.url}`; + return `${t.id}|${t.title}`; } }) .join("\n"); - return `These tabs were left uncategorized in a previous pass. Assign EVERY one to an existing group or create 1–2 new groups. - -Existing groups: -${groupList} - -Uncategorized tabs: -${tabList} + return `Assign each uncategorized tab to an existing group or create 1–2 new groups. Use EXACT label strings. ungrouped must be empty. No tabDescriptions/tabTags needed. -Rules: -- To assign to an existing group, use the EXACT label string from above. -- Create a new group only if no existing group fits. -- Every tab must appear in exactly one group. -- The "ungrouped" array MUST be empty. -- Use "tabIds" (camelCase) for the tab ID field. +Groups: ${groupList} -Return JSON only (same schema).`; +Tabs (id|title|host): +${tabList}`; } export function buildContextHints( @@ -163,17 +128,13 @@ export function buildContextHints( } export function buildRefinePrompt(currentGroupingJson: string, userInstruction: string): string { - return `Current tab grouping: -${currentGroupingJson} - -User instruction: "${userInstruction}" - -Apply the user's instruction to the current grouping. Return the complete revised grouping as JSON (same schema). + return `Grouping: ${currentGroupingJson} -FOCUS: If the user's message implies they want to look at, focus on, explore, or zoom into a specific group or topic, include a "focusGroupLabel" field set to the EXACT label of the group to focus. If they also mention a specific subgroup or child, include "focusChildLabel" with the EXACT child label. If the user asks to focus a topic that doesn't have its own group yet, restructure the grouping so that topic becomes a distinct group, then set "focusGroupLabel" to it. If the user's instruction is purely structural (merge, rename, split) with no focus intent, omit these fields. +Instruction: "${userInstruction}" -Examples of focus intent: "show me my AI tabs", "focus on shopping", "what do I have open about React?", "zoom into the research group", "let me see the Claude stuff" -Examples of no focus intent: "merge News and Media", "rename Shopping to Commerce", "split Development into Frontend and Backend" +Apply the instruction. Return complete revised grouping JSON. No tabDescriptions/tabTags needed. -Return JSON only.`; +FOCUS: If user wants to view/explore a topic, add "focusGroupLabel" (exact label) and optionally "focusChildLabel". If the topic isn't a group yet, create it. Omit for structural changes (merge/rename/split). +Focus examples: "show AI tabs", "focus on shopping", "zoom into research" +No-focus examples: "merge News and Media", "rename Shopping to Commerce"`; } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 90dfb73..350a414 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,4 +1,5 @@ import type { ConversationMessage, GroupingResponse, GroupThinkConfig } from "../types"; +import { DEFAULT_CONFIG } from "../types"; const KEYS = { CONFIG: "groupthink_config", @@ -11,13 +12,7 @@ export class Storage { static async getConfig(): Promise { const result = await chrome.storage.local.get(KEYS.CONFIG); - return ( - result[KEYS.CONFIG] ?? { - model: "claude-sonnet-4-20250514", - specificity: 5, - theme: "auto", - } - ); + return result[KEYS.CONFIG] ?? { ...DEFAULT_CONFIG }; } static async setConfig(config: Partial): Promise { diff --git a/src/options/index.tsx b/src/options/index.tsx index 443803a..c068580 100644 --- a/src/options/index.tsx +++ b/src/options/index.tsx @@ -5,8 +5,8 @@ import type { GroupThinkConfig } from "../types"; const MODELS = [ { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4 (recommended)" }, + { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5 (cheapest)" }, { value: "claude-opus-4-20250514", label: "Claude Opus 4" }, - { value: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; type EnrichmentLevel = "off" | "basic" | "full";