-
{channel.name}
+
+ {channel.name} | {channel.channel}
+
{formatDuration(channel.duration_seconds)}
diff --git a/internal/native/darwin.go b/internal/native/darwin.go
index 9a8b2d7..659ec99 100644
--- a/internal/native/darwin.go
+++ b/internal/native/darwin.go
@@ -30,7 +30,7 @@ static int checkAutomationPermission(const char* bundleID) {
}
// Forward declaration of Go callback
-extern void goOnTitleChange(int pid, char* bundleID, char* title, char* appName, char* executablePath, char* appIcon);
+extern void goOnTitleChange(int pid, char* bundleID, char* title, char* appName, char* executablePath, char* appIcon, char* appCategory);
// Global state
static AXObserverRef gObserver = NULL;
@@ -77,7 +77,18 @@ static void emitTitleChange(pid_t pid, NSString* title) {
}
const char* appIconStr = [appIconBase64 UTF8String] ?: "";
- goOnTitleChange((int)pid, (char*)bundleIDStr, (char*)titleStr, (char*)appNameStr, (char*)execPathStr, (char*)appIconStr);
+ // Get app category from Info.plist (LSApplicationCategoryType)
+ NSString* appCategoryStr = @"";
+ if (app.bundleURL) {
+ NSBundle* appBundle = [NSBundle bundleWithURL:app.bundleURL];
+ if (appBundle) {
+ NSString* cat = [appBundle objectForInfoDictionaryKey:@"LSApplicationCategoryType"];
+ if (cat) appCategoryStr = cat;
+ }
+ }
+ const char* appCategoryC = [appCategoryStr UTF8String] ?: "";
+
+ goOnTitleChange((int)pid, (char*)bundleIDStr, (char*)titleStr, (char*)appNameStr, (char*)execPathStr, (char*)appIconStr, (char*)appCategoryC);
}
// AXObserver callback
@@ -246,7 +257,7 @@ func startObserver() {
}
//export goOnTitleChange
-func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.char, cExecutablePath *C.char, cAppIcon *C.char) {
+func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.char, cExecutablePath *C.char, cAppIcon *C.char, cAppCategory *C.char) {
// Copy C strings to Go strings synchronously (C memory may be freed after return)
pid := int(cPID)
bundleID := C.GoString(cBundleID)
@@ -254,6 +265,7 @@ func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.
appName := C.GoString(cAppName)
executablePath := C.GoString(cExecutablePath)
appIcon := C.GoString(cAppIcon)
+ appCategory := C.GoString(cAppCategory)
go func() {
var browserURL string
@@ -282,6 +294,7 @@ func goOnTitleChange(cPID C.int, cBundleID *C.char, cTitle *C.char, cAppName *C.
Title: title,
AppIcon: appIcon,
URL: browserURL,
+ AppCategory: appCategory,
})
}()
}
diff --git a/internal/native/types.go b/internal/native/types.go
index d7c6ac8..547f93a 100644
--- a/internal/native/types.go
+++ b/internal/native/types.go
@@ -29,6 +29,7 @@ type NativeEvent struct {
Title string
AppIcon string // base64 encoded PNG
URL string
+ AppCategory string // LSApplicationCategoryType from Info.plist, e.g. "public.app-category.developer-tools"
}
func (e *NativeEvent) BrowserHostname() string {
diff --git a/internal/usage/classifier_llm.go b/internal/usage/classifier_llm.go
index a84ca48..e433854 100644
--- a/internal/usage/classifier_llm.go
+++ b/internal/usage/classifier_llm.go
@@ -2,10 +2,10 @@ package usage
import "context"
-func (s *Service) ClassifyWithLLM(ctx context.Context, appName, title string, url *string) (*ClassificationResponse, error) {
+func (s *Service) ClassifyWithLLM(ctx context.Context, appName, title string, url, bundleID, appCategory *string) (*ClassificationResponse, error) {
if url != nil {
return s.classifyWebsite(ctx, *url, title)
}
- return s.classifyApplication(ctx, appName, title, url)
+ return s.classifyApplication(ctx, appName, title, bundleID, appCategory)
}
diff --git a/internal/usage/classifier_llm_apps.go b/internal/usage/classifier_llm_apps.go
index 534d228..6e53649 100644
--- a/internal/usage/classifier_llm_apps.go
+++ b/internal/usage/classifier_llm_apps.go
@@ -6,12 +6,15 @@ import (
"strings"
)
-func (s *Service) classifyApplication(ctx context.Context, appName, title string, url *string) (*ClassificationResponse, error) {
+func (s *Service) classifyApplication(ctx context.Context, appName, title string, bundleID, appCategory *string) (*ClassificationResponse, error) {
switch strings.ToLower(appName) {
case "slack":
return s.classifySlackApp(ctx, appName, title)
}
+ bundleIDValue := fromPtr(bundleID)
+ appCategoryValue := fromPtr(appCategory)
+
var (
instructions = `
You are a Software Engineering Application Intent Classifier. Your job is to determine if the user is actively doing work related to their software engineering job or seeking entertainment/distraction.
@@ -40,7 +43,20 @@ You are a Software Engineering Application Intent Classifier. Your job is to det
**Includes:**
- Email, Calendar, Notes, general file managers.
- General communication apps without context (Slack, Teams).
-- System settings or OS utilities.
+- System settings or OS utilities.
+
+# Metadata Signals
+
+**Bundle ID** (when provided) is a precise, machine-readable identifier for the app (e.g. "com.apple.dt.Xcode", "com.spotify.client"). Use it as a strong classification signal.
+
+**App Store Category** (when provided) is Apple's own pre-assigned category for the app. Treat it as the strongest available signal:
+- "public.app-category.developer-tools" -> productive
+- "public.app-category.games" -> distracting
+- "public.app-category.entertainment" -> distracting
+- "public.app-category.social-networking" -> distracting
+- "public.app-category.music" -> distracting (unless context suggests otherwise)
+- "public.app-category.productivity" / "public.app-category.business" -> likely productive or neutral
+- "public.app-category.utilities" -> neutral
Return a JSON object with the following keys:
1. "classification": "productive", "neutral", or "distracting".
@@ -48,22 +64,20 @@ Return a JSON object with the following keys:
3. "tags": Array of strings from ONLY these options: ["coding", "docs", "debug", "communication", "terminal", "planning", "learning", "entertainment", "news", "social", "shopping", "terminal", "other"].
- IMPORTANT: Be extremely conservative with the "communication" tag. Only use it when there is actual messaging, emailing, or chatting happening. Do NOT use it for reading code reviews, terminal multiplexers, or project management.
4. "confidence_score": Float (0.0 - 1.0)
+5. "detected_project": If the window title clearly implies a project name. Return null if no project can be reliably inferred.
+6. "detected_communication_channel": If the window title clearly implies a communication channel and the app has communication tag in the tags array, extract just the channel name (e.g. "engineering", "random"). Return null if no channel can be reliably inferred.
`
inputTmpl = `
The user is currently using an application. Classify the activity based on the following information:
Application Name: %s
Window Title: %s
-Executable Path: %s
+Bundle ID: %s
+App Store Category: %s
`
)
- urlValue := ""
- if url != nil {
- urlValue = *url
- }
-
- input := fmt.Sprintf(inputTmpl, appName, title, urlValue)
+ input := fmt.Sprintf(inputTmpl, appName, title, bundleIDValue, appCategoryValue)
response, err := s.classifyWithGemini(ctx, instructions, input)
if err != nil {
diff --git a/internal/usage/classifier_llm_test.go b/internal/usage/classifier_llm_test.go
index da63557..e5ae0c8 100644
--- a/internal/usage/classifier_llm_test.go
+++ b/internal/usage/classifier_llm_test.go
@@ -291,7 +291,7 @@ func TestClassify_Application_Slack(t *testing.T) {
s, _ := setUpService(t, WithGenaiClient(genaiClient))
t.Run("extract channel name", func(t *testing.T) {
- response, err := s.classifyApplication(context.Background(), "slack", "private-coin-team-chat (Channel) - Snyk - Slack", nil)
+ response, err := s.classifyApplication(context.Background(), "slack", "private-coin-team-chat (Channel) - Snyk - Slack", nil, nil)
require.NoError(t, err, "failed to classify application")
assert.Equal(t, ClassificationProductive, response.Classification)
diff --git a/internal/usage/service_usage.go b/internal/usage/service_usage.go
index 558c7b0..5395358 100644
--- a/internal/usage/service_usage.go
+++ b/internal/usage/service_usage.go
@@ -93,7 +93,7 @@ func (s *Service) IdleChanged(ctx context.Context, isIdle bool) error {
// TitleChanged is called when the title of the current application changes,
// whether it's a new application or the same application title has changed
-func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, url *string) error {
+func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle, appName, icon string, bundleID, url, appCategory *string) error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -113,7 +113,7 @@ func (s *Service) TitleChanged(ctx context.Context, executablePath, windowTitle,
return fmt.Errorf("failed to close current application usage: %w", err)
}
- application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, url)
+ application, err := s.getOrCreateApplication(ctx, appName, icon, bundleID, url, appCategory)
if err != nil {
return fmt.Errorf("failed to get or create application: %w", err)
}
@@ -240,7 +240,7 @@ func (s *Service) classifyApplicationUsage(ctx context.Context, applicationUsage
}
slog.Info("classifying application usage with LLM")
- resp, err := s.ClassifyWithLLM(ctx, applicationUsage.Application.Name, applicationUsage.WindowTitle, applicationUsage.BrowserURL)
+ resp, err := s.ClassifyWithLLM(ctx, applicationUsage.Application.Name, applicationUsage.WindowTitle, applicationUsage.BrowserURL, applicationUsage.Application.BundleID, applicationUsage.Application.AppCategory)
if err != nil {
return nil, fmt.Errorf("failed to classify application usage with LLM: %w", err)
}
@@ -285,7 +285,7 @@ func (s *Service) classifyApplicationUsage(ctx context.Context, applicationUsage
// Returns:
// - Application: The found or newly created application record
// - error: Any error encountered during database operations or favicon fetching
-func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, bundleID, rawURL *string) (Application, error) {
+func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string, bundleID, rawURL, appCategory *string) (Application, error) {
// Handle web applications (browser tabs with URLs)
rawURLValue := fromPtr(rawURL)
@@ -340,6 +340,8 @@ func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string,
}
}
+ application.AppCategory = appCategory
+
if err := s.db.Save(&application).Error; err != nil {
return Application{}, fmt.Errorf("failed to create application: %w", err)
}
@@ -370,6 +372,8 @@ func (s *Service) getOrCreateApplication(ctx context.Context, name, icon string,
application.Icon = &icon
}
+ application.AppCategory = appCategory
+
// Persist the application (creates new or updates existing)
if err := s.db.Save(&application).Error; err != nil {
return Application{}, fmt.Errorf("failed to create application: %w", err)
diff --git a/internal/usage/service_usage_test.go b/internal/usage/service_usage_test.go
index 8131286..a6a84bd 100644
--- a/internal/usage/service_usage_test.go
+++ b/internal/usage/service_usage_test.go
@@ -186,7 +186,7 @@ func TestService_TitleChanged_WhenSameApplication_ContinueCurrentApplicationUsag
}
// change the title of the current application
- err := service.TitleChanged(ctx, "/Applications/Slack.app/Contents/MacOS/Slack", "Slack", "Slack", "", nil, nil)
+ err := service.TitleChanged(ctx, "/Applications/Slack.app/Contents/MacOS/Slack", "Slack", "Slack", "", nil, nil, nil)
require.NoError(t, err, "failed to change title")
// read the application usage
@@ -216,7 +216,7 @@ func TestService_TitleChanged_WhenDifferentApplication_CloseCurrentApplicationUs
}
// change the title of the current application
- err := service.TitleChanged(ctx, "com.apple.Safari", "Safari", "New Tab", "", nil, nil)
+ err := service.TitleChanged(ctx, "com.apple.Safari", "Safari", "New Tab", "", nil, nil, nil)
require.NoError(t, err, "failed to change title")
// read the application usage
@@ -232,7 +232,7 @@ func TestService_TitleChanged_ClassificationErrorStored(t *testing.T) {
// setup a service with a mock settings service that returns invalid custom rules to trigger a classification error
usageService, db := setUpServiceWithSettings(t, "invalid custom rules")
- err := usageService.TitleChanged(context.Background(), "com.apple.Safari", "Safari", "New Tab", "", nil, nil)
+ err := usageService.TitleChanged(context.Background(), "com.apple.Safari", "Safari", "New Tab", "", nil, nil, nil)
require.Nil(t, err)
var readApplicationUsage usage.ApplicationUsage
@@ -286,6 +286,7 @@ func TestService_TitleChanged_PropogateClassificationFromLLM(t *testing.T) {
"",
nil,
&url,
+ nil,
)
require.NoError(t, err, "failed to change title")
diff --git a/internal/usage/types_db.go b/internal/usage/types_db.go
index 3d0795a..d69f5f9 100644
--- a/internal/usage/types_db.go
+++ b/internal/usage/types_db.go
@@ -32,7 +32,8 @@ type Application struct {
Domain *string `json:"domain"`
// darwin only
- BundleID *string `json:"bundle_id"`
+ BundleID *string `json:"bundle_id"`
+ AppCategory *string `json:"app_category"` // LSApplicationCategoryType, e.g. "public.app-category.developer-tools"
}
func (a Application) TableName() string {
diff --git a/internal/usage/types_usage.go b/internal/usage/types_usage.go
index 219e02b..ccc23eb 100644
--- a/internal/usage/types_usage.go
+++ b/internal/usage/types_usage.go
@@ -47,9 +47,17 @@ type ClassificationResponse struct {
ClassificationSource ClassificationSource `json:"classification_source"`
Reasoning string `json:"reasoning"`
ConfidenceScore float32 `json:"confidence_score"`
- DetectedProject string `json:"detected_project"`
- DetectedCommunicationChannel string `json:"detected_communication_channel"`
- Tags []string `json:"tags"`
+ // DetectedProject is inferred by the LLM from the window title or channel name.
+ // For coding apps (VS Code, Xcode, etc.), it extracts the workspace/project name from the title format.
+ // For communication apps (Slack), it extracts the project/team context if strongly implied by the channel name.
+ DetectedProject string `json:"detected_project"`
+
+ // DetectedCommunicationChannel is inferred by the LLM from the window title for communication apps.
+ // E.g., for Slack it extracts "engineering" from "Slack | #engineering | Acme Corp".
+ // This is only populated when the "communication" tag is assigned.
+ DetectedCommunicationChannel string `json:"detected_communication_channel"`
+
+ Tags []string `json:"tags"`
SandboxContext string `json:"sandbox_context"`
SandboxResponse *string `json:"sandbox_response"`
diff --git a/main.go b/main.go
index b6293ae..81cedf6 100644
--- a/main.go
+++ b/main.go
@@ -193,10 +193,20 @@ func main() {
return
}
- var url *string
+ var (
+ url *string
+ bundleID *string
+ category *string
+ )
if event.URL != "" {
url = &event.URL
}
+ if event.BundleID != "" {
+ bundleID = &event.BundleID
+ }
+ if event.AppCategory != "" {
+ category = &event.AppCategory
+ }
err := usageService.TitleChanged(
ctx,
@@ -204,8 +214,9 @@ func main() {
event.Title,
event.AppName,
event.Icon,
- &event.BundleID,
+ bundleID,
url,
+ category,
)
if err != nil {
slog.Error("failed to handle title change", "error", err)
From ecfde803c786d79237863a5969245d4b03d86f76 Mon Sep 17 00:00:00 2001
From: Aram Petrosyan
Date: Wed, 11 Mar 2026 18:49:37 +0400
Subject: [PATCH 4/4] feat(ui): add sleek search input for blocked distractions
---
frontend/src/routes/activity.tsx | 60 ++++++++++++++++++++++++--------
1 file changed, 46 insertions(+), 14 deletions(-)
diff --git a/frontend/src/routes/activity.tsx b/frontend/src/routes/activity.tsx
index fc65ef1..b6e46bb 100644
--- a/frontend/src/routes/activity.tsx
+++ b/frontend/src/routes/activity.tsx
@@ -7,6 +7,7 @@ import {
IconShield,
IconChevronDown,
IconClock,
+ IconSearch,
} from "@tabler/icons-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -123,6 +124,8 @@ function ActivityPage() {
const allowedItems = useUsageStore((state) => state.allowedItems);
const blockedItems = useUsageStore((state) => state.blockedItems); // Subscribe to blocked items map
+ const [searchQuery, setSearchQuery] = useState("");
+
// Defer rendering of the full list to make navigation instant
const [renderCount, setRenderCount] = useState(15);
@@ -214,6 +217,19 @@ function ActivityPage() {
return result.sort((a, b) => (b.usage.started_at ?? 0) - (a.usage.started_at ?? 0));
}, [getBlockedItemsList, blockedItems, allowedItems, recentUsages]);
+ const filteredBlockedUsages = useMemo(() => {
+ if (!searchQuery) return blockedUsagesDisplay;
+ const q = searchQuery.toLowerCase();
+ return blockedUsagesDisplay.filter((item) => {
+ const { usage } = item;
+ const name = usage.application?.name?.toLowerCase() || "";
+ const host = usage.application?.hostname?.toLowerCase() || "";
+ const title = usage.window_title?.toLowerCase() || "";
+ const tags = usage.tags?.map((t) => t.tag.toLowerCase()).join(" ") || "";
+ return name.includes(q) || host.includes(q) || title.includes(q) || tags.includes(q);
+ });
+ }, [blockedUsagesDisplay, searchQuery]);
+
return (
@@ -222,26 +238,42 @@ function ActivityPage() {
{blockedUsagesDisplay.length > 0 && (
-
-
+
+
Blocked Distractions Today
-
- {blockedUsagesDisplay.filter((b) => !b.isAllowed).length} PREVENTED
-
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search..."
+ className="h-6 w-20 focus:w-40 bg-transparent text-xs text-white/70 pl-5 pr-0 outline-none placeholder:text-white/20 transition-all focus:placeholder:opacity-0 cursor-pointer focus:cursor-text"
+ />
+
+
+ {filteredBlockedUsages.filter((b) => !b.isAllowed).length} PREVENTED
+
+
- {blockedUsagesDisplay.map((item) => (
-
- ))}
+ {filteredBlockedUsages.length === 0 ? (
+
No matches found
+ ) : (
+ filteredBlockedUsages.map((item) => (
+
+ ))
+ )}