Skip to content
Draft
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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@ All notable changes to inflow are documented here. This project follows
[semantic versioning](https://semver.org/) and the format of
[Keep a Changelog](https://keepachangelog.com/).

## [0.5.0] - 2026-08-09

### Added
- **Connections section** — browse your full first-degree connection list
(no longer capped at 40), with search by name/headline and sorting by date
added, first name, or last name.
- **AI categorization** — connections are sorted into role categories
(Investor, Founder, Engineering, …) and matched against your own interest
tags (e.g. "Investors"), so you can filter your network. Choose **Auto**
(categorize after each sync) or **Manual** in Settings → AI.
- **AI summaries** — a one-line summary of each connection in the detail pane,
plus a per-connection **Refresh** to re-analyze someone.
- **Insights** — a new section showing your network's composition by role,
interest breakdown, and the firms your connections cluster around.
- **Ask your network** — a chat in Insights (the "Ask" tab) to ask
natural-language questions about your connections, grounded in your data.
- **Settings panel** — a proper Settings window (gear in the nav rail or ⌘,)
with AI, Appearance, Backup, Advanced, and About sections.
- **Backup & restore** — save your connections and everything the AI derived
to a versioned JSON file in a folder you choose, with optional auto-backup
after categorizing. Restores correctly across future updates.
- **What's new** — this window: release notes shown once after each update
(also available anytime from Settings → About or the command palette).

### Fixed
- **Categorization failures are now visible** — a failed batch (e.g. a rate
limit) surfaces a notice with a Retry action instead of silently stopping.
- **Toggle switches** — the switch knob no longer overflows the pill.
- **Nameless connections hidden** — connections that come back without a
resolvable name (private/restricted profiles) no longer show as "Unknown"
rows; they stay stored and reappear if a later sync resolves them.

### Changed
- **AI setup moved into Settings** — the API key, get-a-key instructions, and
reply-suggestion toggle now live in Settings → AI (previously a
command-palette-only modal).
- **Build-numbered versions** — production builds now carry a 4th version
segment (e.g. `0.5.0.461`) so every published build is uniquely identifiable;
the human version stays 3-part (`0.5.0`).

## [0.4.0] - 2026-07-13

### Added
Expand Down
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ inflow is a Chrome extension (MV3) that provides a keyboard-driven messaging cli

## Release process

1. Update `CHANGELOG.md` with the new version section
2. `npm version <patch|minor|major>` — bumps `package.json` + creates `vX.Y.Z` tag
Ship small and often — normally bump the **patch** each release (`0.5.1`, `0.5.2`, …); reserve minor/major for larger milestones.

1. Update `CHANGELOG.md` with the new `## [x.y.z] - DATE` section (the What's-new modal reads these — see `src/lib/changelog.ts`)
2. `npm version <patch|minor|major>` — bumps `package.json` + creates `vX.Y.Z` tag (keep this 3-part semver)
3. `git push --follow-tags` — triggers the GitHub Actions release workflow
4. CI runs tests, builds the zip, and creates a GitHub Release with release notes from the changelog

### Versioning note

`package.json`, git tags, and the changelog stay clean 3-part semver (`0.5.1`). The **manifest** `version` gets a 4th auto build segment on **every** build (dev and prod) so the chrome://extensions card always shows a changing number — `0.5.1.<build>` (e.g. `0.5.1.461`). The build number is `GITHUB_RUN_NUMBER` in CI, otherwise the local **git commit count** (`APP_VERSION`/`commitCount()` in `wxt.config.ts`). Dev also appends `-dev+<sha>` in `version_name`. App code that reasons about versions (What's-new, update check) normalizes with `coreVersion()` from `src/lib/update.ts`. Chrome segment rules: 1–4 integers, each `0–65535`, no leading zeros.
102 changes: 71 additions & 31 deletions entrypoints/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,21 @@ import { ShortcutOverlay, SHORTCUT_PANEL_PADDING } from '@/components/common/Sho
import { ImageLightbox } from '@/components/common/ImageLightbox';
import { ConfirmDeleteModal } from '@/components/common/ConfirmDeleteModal';
import { ConfirmSpamModal } from '@/components/common/ConfirmSpamModal';
import { AISetupModal } from '@/components/common/AISetupModal';
import { SettingsModal } from '@/components/settings/SettingsModal';
import { WhatsNewModal } from '@/components/common/WhatsNewModal';
import { Toast } from '@/components/common/Toast';
import { UpdateBanner } from '@/components/common/UpdateBanner';
import { IncomingMessageToast } from '@/components/common/IncomingMessageToast';
import { PendingNavigation } from '@/components/common/PendingNavigation';
import { DebugPanel } from '@/components/common/DebugPanel';
import { NewMessageComposer } from '@/components/composer/NewMessageComposer';
import { NavRail } from '@/components/nav/NavRail';
import { ConnectionsList } from '@/components/connections/ConnectionsList';
import { ConnectionDetail } from '@/components/connections/ConnectionDetail';
import { InsightsView } from '@/components/insights/InsightsView';
import { ChatView } from '@/components/chat/ChatView';
import { useConversations } from '@/hooks/useConversations';
import { useConnections } from '@/hooks/useConnections';
import { useRemoteSearch } from '@/hooks/useRemoteSearch';
import { useKeyboard } from '@/hooks/useKeyboard';
import { useOptimisticAction } from '@/hooks/useOptimisticAction';
Expand All @@ -30,6 +37,8 @@ export function App() {
const { remoteResults, isSearching, hasMore, loadMore } = useRemoteSearch();
const selectedConversationId = useUIStore((s) => s.selectedConversationId);
const composeNewActive = useUIStore((s) => s.composeNewActive);
const activeSection = useUIStore((s) => s.activeSection);
const { connections } = useConnections();
const shortcutPanelOpen = useUIStore((s) => s.shortcutOverlayOpen);
const deleteConfirmId = useUIStore((s) => s.deleteConfirmId);
const setDeleteConfirmId = useUIStore((s) => s.setDeleteConfirmId);
Expand Down Expand Up @@ -110,6 +119,7 @@ export function App() {
// - Selection not in current list → pick first conversation
// - Selection exists in list → sync selectedIndex to its position
useEffect(() => {
if (activeSection !== 'inbox') return; // connections section manages its own selection
if (conversations.length === 0) return;
const store = useUIStore.getState();

Expand Down Expand Up @@ -169,7 +179,7 @@ export function App() {
}
}
}
}, [conversations, selectedConversationId]);
}, [conversations, selectedConversationId, activeSection]);

// Debug panel toggle
const toggleDebug = useCallback(() => setDebugOpen(prev => !prev), []);
Expand Down Expand Up @@ -212,41 +222,70 @@ export function App() {
setSpamConfirmId(null);
}, [spamConversation, actions, setSpamConfirmId]);

// Shared resize handle between the list column and the detail/thread pane —
// used by both the Inbox and Connections sections.
const resizeDivider = (
<div
onMouseDown={onDividerMouseDown}
onDoubleClick={onDividerDoubleClick}
title="Drag to resize · double-click to reset"
className={`group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize ${isDraggingSidebar ? 'bg-blue-500/40' : ''}`}
>
<div className={`absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors ${isDraggingSidebar ? 'bg-blue-500' : 'bg-transparent group-hover:bg-blue-500/60'}`} />
</div>
);

return (
<AuthGate>
<UpdateBanner />
<div className={`flex min-h-0 flex-1 overflow-hidden bg-surface text-fg transition-[padding-bottom] duration-200 ease-out ${shortcutPanelOpen ? SHORTCUT_PANEL_PADDING : 'pb-0'}`}>
{/* Conversation List — collapses to a fixed avatar rail on narrow windows */}
<div style={{ width: railMode ? RAIL_WIDTH : sidebarWidth }} className="flex h-full shrink-0 flex-col border-r border-edge">
<ConversationList conversations={conversations} isLoading={isLoading} isDiscovering={isDiscovering} category={category} isSearching={isSearching} hasMoreSearchResults={hasMore} onLoadMoreSearch={loadMore} onOpenDebug={() => setDebugOpen(true)} compact={railMode} />
</div>
{/* Section nav rail — Inbox / Connections, collapsible */}
<NavRail connectionsCount={connections.length} />

{/* Resize handle: thin visual divider with a wider invisible hit zone.
Drag to resize the sidebar, double-click to reset. Hidden in rail
mode — the rail width is fixed. */}
{!railMode && (
<div
onMouseDown={onDividerMouseDown}
onDoubleClick={onDividerDoubleClick}
title="Drag to resize · double-click to reset"
className={`group relative z-10 -mx-1 w-2 shrink-0 cursor-col-resize ${isDraggingSidebar ? 'bg-blue-500/40' : ''}`}
>
<div className={`absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors ${isDraggingSidebar ? 'bg-blue-500' : 'bg-transparent group-hover:bg-blue-500/60'}`} />
{activeSection === 'chat' ? (
<ChatView />
) : activeSection === 'insights' ? (
<div className="flex h-full min-w-0 flex-1 flex-col">
<InsightsView />
</div>
)}
) : activeSection === 'connections' ? (
<>
{/* Connections list */}
<div style={{ width: sidebarWidth }} className="flex h-full shrink-0 flex-col border-r border-edge">
<ConnectionsList />
</div>
{!railMode && resizeDivider}
{/* Selected connection detail */}
<div className="flex h-full min-w-0 flex-1 flex-col">
<ConnectionDetail />
</div>
</>
) : (
<>
{/* Conversation List — collapses to a fixed avatar rail on narrow windows */}
<div style={{ width: railMode ? RAIL_WIDTH : sidebarWidth }} className="flex h-full shrink-0 flex-col border-r border-edge">
<ConversationList conversations={conversations} isLoading={isLoading} isDiscovering={isDiscovering} category={category} isSearching={isSearching} hasMoreSearchResults={hasMore} onLoadMoreSearch={loadMore} onOpenDebug={() => setDebugOpen(true)} compact={railMode} />
</div>

{/* Thread View or New Message Composer */}
<div className="flex h-full min-w-0 flex-1 flex-col">
{composeNewActive ? (
<NewMessageComposer
key={selectedConversation?.draft === 1 ? selectedConversation.id : 'new'}
draftConversation={selectedConversation?.draft === 1 ? selectedConversation : undefined}
composeRef={composeRef}
/>
) : selectedConversation ? (
<ThreadView conversation={selectedConversation} composeRef={composeRef} />
) : null}
</div>
{/* Resize handle: thin visual divider with a wider invisible hit zone.
Drag to resize the sidebar, double-click to reset. Hidden in rail
mode — the rail width is fixed. */}
{!railMode && resizeDivider}

{/* Thread View or New Message Composer */}
<div className="flex h-full min-w-0 flex-1 flex-col">
{composeNewActive ? (
<NewMessageComposer
key={selectedConversation?.draft === 1 ? selectedConversation.id : 'new'}
draftConversation={selectedConversation?.draft === 1 ? selectedConversation : undefined}
composeRef={composeRef}
/>
) : selectedConversation ? (
<ThreadView conversation={selectedConversation} composeRef={composeRef} />
) : null}
</div>
</>
)}
</div>

{/* Drag-and-drop overlay */}
Expand Down Expand Up @@ -279,7 +318,8 @@ export function App() {
onCancel={() => setSpamConfirmId(null)}
/>
)}
<AISetupModal />
<SettingsModal />
<WhatsNewModal />
<Toast />
<IncomingMessageToast />
<PendingNavigation />
Expand Down
52 changes: 52 additions & 0 deletions entrypoints/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,58 @@
--scrollbar-hover: #52525b;
}

/*
* Purple theme — a dark base tinted purple, with translucent surfaces layered
* over a fixed gradient backdrop for a glassy "transparent purple" look.
* Applied together with .dark (see applyTheme), so it only overrides the tokens
* that differ and inherits dark behavior for everything else.
*/
.theme-purple {
--surface: rgba(23, 15, 43, 0.72);
--surface-raised: rgba(38, 25, 66, 0.7);
--surface-hover: rgba(124, 92, 214, 0.18);
--surface-active: rgba(139, 92, 246, 0.32);
--surface-input: rgba(45, 30, 78, 0.6);
--surface-muted: rgba(76, 52, 128, 0.55);
--edge: rgba(167, 139, 250, 0.20);
--ring: rgba(167, 139, 250, 0.35);
--ring-muted: rgba(139, 92, 246, 0.22);
--fg: #ece7fb;
--fg-strong: #ffffff;
--fg-secondary: #c7b8f5;
--fg-muted: #9d89cf;
--fg-faint: #6f5f9c;
--scrollbar: rgba(167, 139, 250, 0.35);
--scrollbar-hover: rgba(167, 139, 250, 0.6);
}

/* Fixed gradient backdrop; the translucent surfaces above let it show through. */
html.theme-purple {
background:
radial-gradient(1100px 760px at 12% -12%, rgba(139, 92, 246, 0.38), transparent 60%),
radial-gradient(980px 700px at 105% 4%, rgba(217, 70, 239, 0.22), transparent 55%),
linear-gradient(158deg, #0e0720 0%, #180d30 46%, #0a0417 100%);
background-attachment: fixed;
}

/*
* Primary buttons — a soft, glassy translucent accent instead of a loud solid
* fill. One class so the whole app's CTAs stay consistent; keep sizing/layout
* utilities inline on each button.
*/
.btn-primary {
background-color: rgb(59 130 246 / 0.15);
color: rgb(147 197 253);
box-shadow: inset 0 0 0 1px rgb(59 130 246 / 0.30);
transition: background-color 150ms ease;
}
.btn-primary:hover:not(:disabled) {
background-color: rgb(59 130 246 / 0.28);
}
.btn-primary:disabled {
opacity: 0.4;
}

/* Register theme colors for Tailwind v4 */
@theme inline {
--color-surface: var(--surface);
Expand Down
71 changes: 71 additions & 0 deletions entrypoints/background/api/connections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { voyagerFetch } from './client';
import { debugLog } from '@/lib/debug-log';

// Decoration that inlines the connected member's Profile (name, headline,
// picture) alongside each Connection so a single request has everything the
// list needs.
const DECORATION =
'com.linkedin.voyager.dash.deco.web.mynetwork.ConnectionListWithProfile-16';

// LinkedIn caps this endpoint's page size; 40 is the largest it reliably honors.
export const CONNECTIONS_PAGE_SIZE = 40;

// Safety ceiling so a viewer with thousands of connections can't spin the
// pager indefinitely. Covers the vast majority of accounts; the loop also
// stops early on the first short page.
export const MAX_CONNECTIONS = 2000;

/**
* Fetch one page of the viewer's connections, most-recently-added first.
* Returns the raw normalized+json response for {@link normalizeConnections}.
*/
export async function fetchConnectionsPage(
count: number = CONNECTIONS_PAGE_SIZE,
start: number = 0,
): Promise<any> {
const path =
`/relationships/dash/connections?decorationId=${DECORATION}` +
`&count=${count}&q=search&sortType=RECENTLY_ADDED&start=${start}`;

// First page is user-initiated (panel open) — skip the human-like jitter.
const res = await voyagerFetch(path, { skipJitter: start === 0 });
if (!res.ok) {
debugLog('error', `fetchConnectionsPage failed: ${res.status}`);
throw new Error(`Fetch connections failed: ${res.status}`);
}
return res.json();
}

/**
* Page through the viewer's entire connection list (RECENTLY_ADDED first),
* invoking `onPage` with each raw response as it arrives so the caller can
* normalize + persist incrementally (the UI fills in progressively).
*
* Stops when a page returns fewer than a full page of elements (the last page)
* or when `maxConnections` is reached. Returns the number of elements seen.
*/
export async function fetchAllConnections(
onPage: (raw: any, pageIndex: number) => Promise<void> | void,
maxConnections: number = MAX_CONNECTIONS,
): Promise<number> {
let start = 0;
let pageIndex = 0;
let total = 0;

while (start < maxConnections) {
// Don't overshoot the cap on the final page.
const count = Math.min(CONNECTIONS_PAGE_SIZE, maxConnections - start);
const raw = await fetchConnectionsPage(count, start);
const elements: any[] = raw?.data?.['*elements'] || [];

await onPage(raw, pageIndex);
total += elements.length;

// A short page means we've reached the end of the list.
if (elements.length < count) break;
start += CONNECTIONS_PAGE_SIZE;
pageIndex++;
}

return total;
}
Loading