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
56 changes: 19 additions & 37 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ function App() {
const [focusLabel, setFocusLabel] = useState<string | undefined>();
const [focusChildLabel, setFocusChildLabel] = useState<string | undefined>();
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const didInitRef = useRef(false);

const isLoading = loadingPhase !== "idle";

Expand All @@ -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 }) => {
Expand Down Expand Up @@ -116,22 +120,18 @@ 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([]);
setFocusLabel(undefined);
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");
Expand All @@ -142,48 +142,30 @@ 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 {
const cached = (await chrome.runtime.sendMessage({
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 {
// Cache read failed — fall through to full grouping
}
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) => {
Expand Down
73 changes: 13 additions & 60 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,33 +113,6 @@ chrome.commands.onCommand.addListener((command) => {
}
});

// ── Preemptive background regrouping on tab changes ──

let regroupTimer: ReturnType<typeof setTimeout> | 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) => {
Expand Down Expand Up @@ -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<string, unknown>);
Expand All @@ -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<number, string>();

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": {
Expand Down
12 changes: 11 additions & 1 deletion src/components/TreemapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ 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 =
focusedGroup?.children?.some((c) => c.id === groupId) ||
groupId === `${focusedGroupId}__direct`;

if (isChild) {
// Toggle child focus within the focused group
if (focusedChildId === groupId) {
setFocusedChildId(null);
} else {
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/lib/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }],
});
Expand Down Expand Up @@ -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,
});
Expand Down
Loading
Loading