diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2317ee..9ba8364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,13 +12,12 @@ jobs: steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - with: - version: '9.15.4' - uses: actions/setup-node@v4 with: node-version: '24' cache: 'pnpm' - run: pnpm install --frozen-lockfile + - run: node scripts/inject-env.js - run: pnpm lint - run: pnpm type-check - run: pnpm build:prod diff --git a/src/app/index.tsx b/src/app/index.tsx index eeb507c..94a01c6 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -26,6 +26,8 @@ function App() { Map | undefined >(); const [treemapReady, setTreemapReady] = useState(false); + const [focusLabel, setFocusLabel] = useState(); + const [focusChildLabel, setFocusChildLabel] = useState(); const timersRef = useRef[]>([]); const isLoading = loadingPhase !== "idle"; @@ -116,11 +118,20 @@ function App() { setGroupAssignments(assignments); } - // Update treemap - setRawGrouping(result); - setTabCount(countTabs(result)); - setHistory([]); - if (!silent) setLoadingPhase("idle"); + 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([]); + setFocusLabel(undefined); + setFocusChildLabel(undefined); + setLoadingPhase("idle"); + } } catch (err) { if (!silent) { setError(err instanceof Error ? err.message : "Failed to group tabs"); @@ -131,7 +142,7 @@ function App() { [clearTimers, countTabs], ); - // Initial grouping — try cache first for instant load + // Initial grouping — show cache instantly, then refresh in background useEffect(() => { if (!config?.anthropicApiKey) return; @@ -141,12 +152,14 @@ function App() { type: "get-cached-grouping", })) as GroupingResponse | null; if (cached && cached.groups.length > 0) { - // Show cached grouping instantly — no animation - // Background tab listeners keep the cache fresh; user can hit Refresh for immediate update + // 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 { @@ -156,6 +169,22 @@ function App() { })(); }, [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]); + const handleSpecificityChange = useCallback( (newSpec: number) => { setSpecificity(newSpec); @@ -195,9 +224,21 @@ function App() { if (result.error) throw new Error(result.error); setRawGrouping(result); + // Apply focus hints from LLM + if (result.focusGroupLabel) { + setFocusLabel(result.focusGroupLabel); + setFocusChildLabel(result.focusChildLabel ?? undefined); + } else { + setFocusLabel(undefined); + setFocusChildLabel(undefined); + } + + const focusNote = result.focusGroupLabel + ? ` → Focused: ${result.focusGroupLabel}${result.focusChildLabel ? ` / ${result.focusChildLabel}` : ""}` + : ""; const assistantMsg: ConversationMessage = { role: "assistant", - content: JSON.stringify(result.groups.map((g) => g.label)), + content: JSON.stringify(result.groups.map((g) => g.label)) + focusNote, timestamp: Date.now(), }; setHistory([...updatedHistory, assistantMsg]); @@ -211,7 +252,7 @@ function App() { ); const handleFocusTab = useCallback((tabId: number) => { - chrome.runtime.sendMessage({ type: "focus-tab", tabId }); + chrome.runtime.sendMessage({ type: "split-view-tab", tabId }); }, []); const handleCloseTab = useCallback( @@ -337,6 +378,12 @@ function App() { onFocusTab={handleFocusTab} onCloseTab={handleCloseTab} onReady={() => setTreemapReady(true)} + requestedFocusLabel={focusLabel} + requestedChildLabel={focusChildLabel} + onFocusDismissed={() => { + setFocusLabel(undefined); + setFocusChildLabel(undefined); + }} /> )} diff --git a/src/app/styles.css b/src/app/styles.css index 77c82a8..82f7200 100644 --- a/src/app/styles.css +++ b/src/app/styles.css @@ -931,6 +931,29 @@ body { filter: brightness(1.3); } +/* Micro tab tiles inside gutter groups — microchip look */ +.gt-node--micro { + cursor: default; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border-width: 1px; + border-radius: 2px; + overflow: hidden; + pointer-events: none; +} + +.gt-node--micro .gt-node__tab-close { + display: none !important; +} + +.gt-node__tab-favicon--micro { + width: 12px; + height: 12px; + opacity: 0.7; +} + /* Focused group = background/header behind tab tiles */ .gt-node--focused { cursor: pointer; @@ -1091,6 +1114,26 @@ body { max-width: 100%; } +.gt-node__tab-tags { + display: flex; + flex-wrap: wrap; + gap: 3px; + max-width: 100%; + overflow: hidden; + margin-top: 1px; +} + +.gt-tag { + display: inline-flex; + padding: 1px 5px; + font-size: 8px; + font-weight: 500; + border-radius: 3px; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + line-height: 1.4; +} + .gt-node__tab-close { position: absolute; top: 4px; diff --git a/src/background/index.ts b/src/background/index.ts index 4a112bb..45a5644 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -1,8 +1,10 @@ import { GroupThinkAI } from "../lib/ai"; +import { gatherBrowserContext } from "../lib/browser-context"; import { ENV_CONFIG } from "../lib/env"; -import { mergeSmallGroups, mergeStepResults } from "../lib/grouping"; +import { enforceSubgroups, mergeSmallGroups, mergeStepResults } from "../lib/grouping"; +import { buildContextHints } from "../lib/prompts"; import { Storage } from "../lib/storage"; -import { closeTab, focusTab, getAllTabs } from "../lib/tabs"; +import { closeTab, focusTab, getAllTabs, splitViewTab } from "../lib/tabs"; import type { ConversationMessage, GroupingResponse, @@ -49,13 +51,19 @@ async function captureTabThumbnail(tabId: number): Promise { function hydrate(llmResult: LLMGroupingResult, tabs: TabInfo[]): GroupingResponse { const tabMap = new Map(tabs.map((t) => [t.id, t])); const descriptions = llmResult.tabDescriptions ?? {}; + const tags = llmResult.tabTags ?? {}; let idCounter = 0; function hydrateTab(tid: number): TabInfo | undefined { const tab = tabMap.get(tid); if (!tab) return undefined; const desc = descriptions[String(tid)]; - return desc ? { ...tab, description: desc } : tab; + const tabTagList = tags[String(tid)]; + return { + ...tab, + ...(desc ? { description: desc } : {}), + ...(tabTagList ? { tags: tabTagList } : {}), + }; } function hydrateGroup(item: LLMGroupItem): TabGroup { @@ -249,9 +257,17 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): const ai = new GroupThinkAI(config.anthropicApiKey, config.model); const specificity = (message.specificity as number) ?? config.specificity; + // ── Gather browser context (if enabled) ── + let contextHints: string | undefined; + if (config.contextEnrichment && config.contextEnrichment !== "off") { + console.log(`[GroupThink] gathering browser context (${config.contextEnrichment})...`); + const context = await gatherBrowserContext(tabs, config.contextEnrichment); + contextHints = buildContextHints(context, tabs); + } + // ── Step 1: Initial grouping ── console.log("[GroupThink] step 1: initial grouping..."); - let llmResult = await ai.groupTabs(tabs, specificity); + let llmResult = await ai.groupTabs(tabs, specificity, contextHints); console.log( `[GroupThink] step 1 complete: ${llmResult.groups.length} groups, ${llmResult.ungrouped.length} ungrouped`, ); @@ -287,6 +303,16 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): llmResult = mergeSmallGroups(llmResult, tabSummaries); console.log(`[GroupThink] step 3 complete: ${llmResult.groups.length} groups`); + // ── Step 4: Enforce subgroups for high specificity ── + if (specificity >= 5) { + console.log("[GroupThink] step 4: enforcing subgroups..."); + const tabDetails = tabs.map((t) => ({ id: t.id, url: t.url, title: t.title })); + llmResult = enforceSubgroups(llmResult, specificity, tabDetails); + console.log(`[GroupThink] step 4 complete: ${llmResult.groups.length} groups`); + } else { + console.log("[GroupThink] step 4: skipped (specificity < 5)"); + } + // ── Hydrate + store ── console.log(`[GroupThink] hydrating ${llmResult.groups.length} groups...`); const grouping = hydrate(llmResult, tabs); @@ -343,6 +369,14 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): const grouping = hydrate(llmResult, allTabs); grouping.specificity = currentGrouping.specificity; + // Pass through focus hints from LLM + if (llmResult.focusGroupLabel) { + grouping.focusGroupLabel = llmResult.focusGroupLabel; + } + if (llmResult.focusChildLabel) { + grouping.focusChildLabel = llmResult.focusChildLabel; + } + await Storage.setGrouping(grouping); return grouping; } @@ -352,6 +386,11 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): return { ok: true }; } + case "split-view-tab": { + await splitViewTab(message.tabId as number); + return { ok: true }; + } + case "close-tab": { await closeTab(message.tabId as number); return { ok: true }; diff --git a/src/components/TreemapNode.tsx b/src/components/TreemapNode.tsx index 8b2a8e4..4037bb5 100644 --- a/src/components/TreemapNode.tsx +++ b/src/components/TreemapNode.tsx @@ -237,9 +237,14 @@ function ChildGroupContent({ onClick={() => onClickGroup(group.id)} data-node-id={node.id} > - + {group.label} - {group.sublabel && / {group.sublabel}} + {w > 60 && group.sublabel && ( + / {group.sublabel} + )} ); @@ -271,6 +276,7 @@ function TabContent({ onCloseTab: (id: number) => void; }) { const tab = node.tab!; + const isMicro = node.opacity < 1; const domain = useMemo(() => getDomain(tab.url), [tab.url]); const nodeRef = useRef(null); @@ -280,6 +286,7 @@ function TabContent({ const hoverTimer = useRef>(); const onEnter = useCallback(() => { + if (isMicro) return; hoverTimer.current = setTimeout(() => { if (nodeRef.current) { const r = nodeRef.current.getBoundingClientRect(); @@ -297,13 +304,46 @@ function TabContent({ .catch(() => {}); } }, 200); - }, [tab.thumbnail, tab.id, capturedThumb]); + }, [tab.thumbnail, tab.id, capturedThumb, isMicro]); const onLeave = useCallback(() => { clearTimeout(hoverTimer.current); setHovered(false); }, []); + // Micro gutter tabs — minimal tile with favicon only + if (isMicro) { + return ( +
onFocusTab(tab.id)} + data-node-id={node.id} + > + {w > 10 && h > 10 && ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
+ ); + } + return (
56 && {domain}} + {h > 64 && w > 120 && tab.tags && tab.tags.length > 0 && ( +
+ {tab.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+
+ + +

+ When enabled, browser signals are sent to the LLM for smarter grouping. Data is gathered + fresh per request and never stored. + {enrichment !== "off" && " Additional browser permissions will be requested."} +

+
+