Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 57 additions & 10 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ function App() {
Map<number, GroupAssignment> | undefined
>();
const [treemapReady, setTreemapReady] = useState(false);
const [focusLabel, setFocusLabel] = useState<string | undefined>();
const [focusChildLabel, setFocusChildLabel] = useState<string | undefined>();
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);

const isLoading = loadingPhase !== "idle";
Expand Down Expand Up @@ -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");
Expand All @@ -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;

Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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]);
Expand All @@ -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(
Expand Down Expand Up @@ -337,6 +378,12 @@ function App() {
onFocusTab={handleFocusTab}
onCloseTab={handleCloseTab}
onReady={() => setTreemapReady(true)}
requestedFocusLabel={focusLabel}
requestedChildLabel={focusChildLabel}
onFocusDismissed={() => {
setFocusLabel(undefined);
setFocusChildLabel(undefined);
}}
/>
)}

Expand Down
43 changes: 43 additions & 0 deletions src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
47 changes: 43 additions & 4 deletions src/background/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -49,13 +51,19 @@ async function captureTabThumbnail(tabId: number): Promise<string | null> {
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 {
Expand Down Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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 };
Expand Down
60 changes: 57 additions & 3 deletions src/components/TreemapNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,14 @@ function ChildGroupContent({
onClick={() => onClickGroup(group.id)}
data-node-id={node.id}
>
<span className="gt-node__child-label">
<span
className="gt-node__child-label"
style={w < 80 || h < 30 ? { fontSize: "8px" } : undefined}
>
{group.label}
{group.sublabel && <span className="gt-node__child-sublabel"> / {group.sublabel}</span>}
{w > 60 && group.sublabel && (
<span className="gt-node__child-sublabel"> / {group.sublabel}</span>
)}
</span>
</div>
);
Expand Down Expand Up @@ -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<HTMLDivElement>(null);

Expand All @@ -280,6 +286,7 @@ function TabContent({
const hoverTimer = useRef<ReturnType<typeof setTimeout>>();

const onEnter = useCallback(() => {
if (isMicro) return;
hoverTimer.current = setTimeout(() => {
if (nodeRef.current) {
const r = nodeRef.current.getBoundingClientRect();
Expand All @@ -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 (
<div
className={`${classList} gt-node--micro`}
style={{
left: x,
top: y,
width: w,
height: h,
background: gradient,
borderColor,
opacity: node.opacity,
}}
onClick={() => onFocusTab(tab.id)}
data-node-id={node.id}
>
{w > 10 && h > 10 && (
<img
className="gt-node__tab-favicon gt-node__tab-favicon--micro"
src={tab.favIconUrl || `https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt=""
width={12}
height={12}
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
</div>
);
}

return (
<div
ref={nodeRef}
Expand Down Expand Up @@ -338,6 +378,20 @@ function TabContent({

{h > 56 && <span className="gt-node__tab-domain">{domain}</span>}

{h > 64 && w > 120 && tab.tags && tab.tags.length > 0 && (
<div className="gt-node__tab-tags">
{tab.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="gt-tag"
style={{ backgroundColor: `hsl(${node.colorIndex * 47 + 200}, 25%, ${40}%)` }}
>
{tag}
</span>
))}
</div>
)}

<button
className="gt-node__tab-close"
onClick={(e) => {
Expand Down
Loading
Loading