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
2 changes: 1 addition & 1 deletion apps/tab-manager/src/lib/ai/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const TAB_MANAGER_SYSTEM_PROMPT = `You are a browser tab management assis
## Guidelines

- Use read tools first to understand the current state before making changes
- Destructive actions (like closing tabs) have their own approval UI — do not ask for confirmation in prose
- Mutations (actions that change state) have their own approval UI — do not ask for confirmation in prose
- Group related tabs proactively when you notice patterns
- Be concise — the sidebar has limited space
- When listing tabs, include the URL and title so the user can identify them
Expand Down
6 changes: 5 additions & 1 deletion apps/tab-manager/src/lib/components/AiDrawer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as Drawer from '@epicenter/ui/drawer';
import ZapIcon from '@lucide/svelte/icons/zap';
import AiChat from '$lib/components/chat/AiChat.svelte';
import TrustSettings from '$lib/components/chat/TrustSettings.svelte';
import { authState } from '$lib/state/auth.svelte';

let { open = $bindable(false) }: { open: boolean } = $props();
Expand All @@ -11,7 +12,10 @@
<Drawer.Root bind:open direction="bottom" shouldScaleBackground={false}>
<Drawer.Content class="max-h-[80vh]">
<Drawer.Header class="text-left">
<Drawer.Title>AI Chat</Drawer.Title>
<div class="flex items-center justify-between">
<Drawer.Title>AI Chat</Drawer.Title>
<TrustSettings />
</div>
<Drawer.Description class="sr-only">
Chat with AI about your tabs
</Drawer.Description>
Expand Down
58 changes: 58 additions & 0 deletions apps/tab-manager/src/lib/components/chat/TrustSettings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<script lang="ts">
import { buttonVariants } from '@epicenter/ui/button';
import * as Popover from '@epicenter/ui/popover';
import { Switch } from '@epicenter/ui/switch';
import SettingsIcon from '@lucide/svelte/icons/settings';
import { toolTrustState } from '$lib/state/tool-trust.svelte';
import { workspaceToolTitles } from '$lib/workspace';

const trustedTools = $derived(
[...toolTrustState.entries()].filter(([, level]) => level === 'always'),
);
</script>

{#if trustedTools.length > 0}
<Popover.Root>
<Popover.Trigger
class={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
title="Tool permissions"
>
<SettingsIcon class="size-4" />
</Popover.Trigger>
<Popover.Content class="w-72" align="end">
<div class="space-y-3">
<p class="text-sm font-medium">Tool Permissions</p>
<div class="space-y-2">
{#each trustedTools as [ name ] (name)}
<div class="flex items-center justify-between gap-2">
<span class="text-sm">
{workspaceToolTitles[name] ??
name
.replace(/_/g, ' ')
.replace(/^\w/, (c) => c.toUpperCase())}
</span>
<Switch
checked={true}
onCheckedChange={() => toolTrustState.set(name, 'ask')}
/>
</div>
{/each}
</div>
{#if trustedTools.length > 1}
<div class="border-t pt-2">
<button
class="text-xs text-muted-foreground hover:text-foreground transition-colors"
onclick={() => {
for (const [toolName] of trustedTools) {
toolTrustState.set(toolName, 'ask');
}
}}
>
Revoke all
</button>
</div>
{/if}
</div>
</Popover.Content>
</Popover.Root>
{/if}
182 changes: 12 additions & 170 deletions apps/tab-manager/src/lib/quick-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
*
* Each action has a label, description, icon, and execute function.
* Dangerous actions show a confirmation dialog before executing.
* Actions read from {@link browserState} and call Chrome APIs via
* the existing `actions.ts` helpers.
* Actions read from {@link browserState} and call Chrome APIs.
*
* @example
* ```typescript
Expand All @@ -17,16 +16,12 @@
*/

import { confirmationDialog } from '@epicenter/ui/confirmation-dialog';
import ArchiveIcon from '@lucide/svelte/icons/archive';
import ArrowDownAZIcon from '@lucide/svelte/icons/arrow-down-a-z';
import CopyMinusIcon from '@lucide/svelte/icons/copy-minus';
import GlobeIcon from '@lucide/svelte/icons/globe';
import GroupIcon from '@lucide/svelte/icons/group';
import type { Component } from 'svelte';
import { Ok, tryAsync } from 'wellcrafted/result';
import { browserState } from '$lib/state/browser-state.svelte';
import { savedTabState } from '$lib/state/saved-tab-state.svelte';
import { getDomain } from '$lib/utils/format';
import { findDuplicateGroups, groupTabsByDomain } from '$lib/utils/tab-helpers';
import type { TabCompositeId } from '$lib/workspace';
import { parseTabId } from '$lib/workspace';

Expand Down Expand Up @@ -58,72 +53,13 @@ function compositeToNativeIds(compositeIds: TabCompositeId[]): number[] {
.filter((id) => id !== undefined);
}

/**
* Normalize a URL for duplicate comparison.
*
* Strips trailing slash, query params, and hash to treat
* `https://github.com/foo` and `https://github.com/foo?ref=bar#readme`
* as the same page.
*/
function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
return parsed.origin + parsed.pathname.replace(/\/$/, '');
} catch {
return url;
}
}

/**
* Find groups of tabs with the same normalized URL.
*
* Returns only groups with 2+ tabs (actual duplicates).
* Within each group, tabs are ordered by their original array position.
*/
function findDuplicates(): Map<
string,
{ tabId: TabCompositeId; title: string }[]
> {
const byUrl = new Map<string, { tabId: TabCompositeId; title: string }[]>();

for (const window of browserState.windows) {
for (const tab of browserState.tabsByWindow(window.id)) {
if (!tab.url) continue;
const normalized = normalizeUrl(tab.url);
const group = byUrl.get(normalized) ?? [];
group.push({ tabId: tab.id, title: tab.title ?? 'Untitled' });
byUrl.set(normalized, group);
}
}

return new Map([...byUrl].filter(([, group]) => group.length > 1));
}

/**
* Get all tabs across all windows as a flat array.
*/
function getAllTabs() {
return browserState.windows.flatMap((w) => browserState.tabsByWindow(w.id));
}

/**
* Get unique domains from all open tabs.
*/
function getUniqueDomains(): Map<string, TabCompositeId[]> {
const byDomain = new Map<string, TabCompositeId[]>();

for (const tab of getAllTabs()) {
if (!tab.url) continue;
const domain = getDomain(tab.url);
if (!domain) continue;
const ids = byDomain.get(domain) ?? [];
ids.push(tab.id);
byDomain.set(domain, ids);
}

return byDomain;
}

// ─────────────────────────────────────────────────────────────────────────────
// Actions
// ─────────────────────────────────────────────────────────────────────────────
Expand All @@ -136,17 +72,16 @@ const dedupAction: QuickAction = {
keywords: ['dedup', 'duplicate', 'remove', 'close', 'clean'],
dangerous: true,
execute() {
const dupes = findDuplicates();
const dupes = findDuplicateGroups(getAllTabs());
if (dupes.size === 0) return;

const totalDuplicates = [...dupes.values()].reduce(
(sum, group) => sum + group.length - 1,
0,
);

// Collect the tab IDs to close (all but the first in each group)
const toClose = [...dupes.values()].flatMap((group) =>
group.slice(1).map((t) => t.tabId),
group.slice(1).map((t) => t.id as TabCompositeId),
);

confirmationDialog.open({
Expand All @@ -164,46 +99,22 @@ const dedupAction: QuickAction = {
},
};

const sortAction: QuickAction = {
id: 'sort',
label: 'Sort Tabs by Title',
description: 'Sort tabs alphabetically within each window',
icon: ArrowDownAZIcon,
keywords: ['sort', 'alphabetical', 'order', 'organize'],
async execute() {
for (const window of browserState.windows) {
const tabs = browserState.tabsByWindow(window.id);
const sorted = [...tabs].sort((a, b) =>
(a.title ?? '').localeCompare(b.title ?? ''),
);

for (let i = 0; i < sorted.length; i++) {
const tab = sorted[i];
if (!tab) continue;
const parsed = parseTabId(tab.id);
if (!parsed) continue;
await tryAsync({
try: () => browser.tabs.move(parsed.tabId, { index: i }),
catch: () => Ok(undefined),
});
}
}
},
};

const groupByDomainAction: QuickAction = {
id: 'group-by-domain',
label: 'Group Tabs by Domain',
description: 'Create tab groups based on website domain',
icon: GroupIcon,
keywords: ['group', 'domain', 'organize', 'categorize'],
async execute() {
const domains = getUniqueDomains();
const allTabs = getAllTabs();
const domains = groupTabsByDomain(allTabs);

const groupOps = [...domains.entries()]
.filter(([, tabIds]) => tabIds.length >= 2)
.map(([domain, tabIds]) => {
const nativeIds = compositeToNativeIds(tabIds);
.filter(([, tabs]) => tabs.length >= 2)
.map(([domain, tabs]) => {
const nativeIds = compositeToNativeIds(
tabs.map((t) => t.id as TabCompositeId),
);
return nativeIds.length >= 2 ? { domain, nativeIds } : null;
})
.filter((op) => op !== null);
Expand All @@ -219,78 +130,9 @@ const groupByDomainAction: QuickAction = {
},
};

const saveAllAction: QuickAction = {
id: 'save-all',
label: 'Save All Tabs',
description: 'Save all open tabs for later and close them',
icon: ArchiveIcon,
keywords: ['save', 'all', 'close', 'stash', 'park'],
dangerous: true,
execute() {
const allTabs = getAllTabs();
if (allTabs.length === 0) return;

confirmationDialog.open({
title: 'Save All Tabs',
description: `Save and close ${allTabs.length} tab${allTabs.length === 1 ? '' : 's'}?`,
confirm: { text: 'Save & Close All', variant: 'destructive' },
async onConfirm() {
const tabsWithUrls = allTabs.filter((tab) => tab.url);
await Promise.allSettled(
tabsWithUrls.map((tab) => savedTabState.actions.save(tab)),
);
},
});
},
};

const closeByDomainAction: QuickAction = {
id: 'close-by-domain',
label: 'Close Tabs by Domain',
description: 'Close all tabs from a specific domain',
icon: GlobeIcon,
keywords: ['close', 'domain', 'website', 'remove'],
execute() {
// This action needs a domain picker—for now it closes tabs from the most common domain
const domains = getUniqueDomains();
if (domains.size === 0) return;

// Find the domain with the most tabs
let topDomain = '';
let topCount = 0;
for (const [domain, ids] of domains) {
if (ids.length > topCount) {
topDomain = domain;
topCount = ids.length;
}
}

const tabIds = domains.get(topDomain) ?? [];

confirmationDialog.open({
title: `Close ${topDomain} Tabs`,
description: `Close ${topCount} tab${topCount === 1 ? '' : 's'} from ${topDomain}?`,
confirm: { text: 'Close Tabs', variant: 'destructive' },
async onConfirm() {
const nativeIds = compositeToNativeIds(tabIds);
await tryAsync({
try: () => browser.tabs.remove(nativeIds),
catch: () => Ok(undefined),
});
},
});
},
};

/**
* All registered quick actions for the command palette.
*
* Actions are ordered by expected frequency of use.
*/
export const quickActions: QuickAction[] = [
dedupAction,
groupByDomainAction,
sortAction,
closeByDomainAction,
saveAllAction,
];
export const quickActions: QuickAction[] = [dedupAction, groupByDomainAction];
2 changes: 1 addition & 1 deletion apps/tab-manager/src/lib/state/chat-state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ function createAiChatState() {
* Respond to a tool approval request.
*
* Called when the user clicks [Allow], [Always Allow], or [Deny]
* on a destructive tool call in the chat. Delegates to ChatClient's
* on a mutation tool call in the chat. Delegates to ChatClient's
* `addToolApprovalResponse`, which sends the response back to the
* server to resume or cancel tool execution.
*
Expand Down
Loading
Loading