Skip to content

Commit 72c9087

Browse files
authored
feat: inbox for twig (#898)
### TL;DR Added Autonomy features to Twig, including an Inbox with Signals and Setup tabs to help users monitor and configure their repositories. ### What changed? - Added a new Inbox view with two tabs: - **Signals tab**: Displays AI-detected signals from repositories with details and evidence - **Setup tab**: Shows repository readiness status and allows users to create setup tasks - Extended the PostHog API client with new methods for: - Fetching project autonomy settings - Getting repository readiness status - Retrieving signal reports and artifacts - Updating team settings - Added UI components for displaying repository status, capability pills, and signal cards - Implemented stores for caching readiness status and managing sidebar state - Added navigation support for the new Inbox view - Enhanced the sidebar menu to include an Inbox item with signal count - Improved the Tooltip component to support complex content ### How to test? 1. Log in to Twig with a PostHog account that has Autonomy enabled 2. Look for the new "Inbox" item in the sidebar 3. Click on it to access the Signals and Setup tabs 4. In the Setup tab: - Evaluate repository readiness - Create setup tasks for repositories that need configuration 5. In the Signals tab: - View detected signals and their details - Examine evidence artifacts - Run signals with optional context ### Why make this change? This change introduces Autonomy features to Twig, allowing users to: - Discover and act on AI-detected signals from their repositories - Easily identify repositories that need proper instrumentation - Set up PostHog tracking, session replay, and error monitoring - Get a clear overview of their project's readiness status These features help users maximize the value they get from PostHog by ensuring proper setup and surfacing important insights automatically.
1 parent ac85e45 commit 72c9087

File tree

23 files changed

+2110
-28
lines changed

23 files changed

+2110
-28
lines changed

apps/twig/src/api/posthogClient.ts

Lines changed: 185 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { logger } from "@renderer/lib/logger";
2-
import type { Task, TaskRun } from "@shared/types";
2+
import type {
3+
RepoAutonomyStatus,
4+
SignalReportArtefactsResponse,
5+
SignalReportsResponse,
6+
Task,
7+
TaskRun,
8+
} from "@shared/types";
39
import type { StoredLogEntry } from "@shared/types/session-events";
410
import { buildApiFetcher } from "./fetcher";
511
import { createApiClient, type Schemas } from "./generated";
@@ -61,7 +67,41 @@ export class PostHogAPIClient {
6167
return data as Schemas.Team;
6268
}
6369

64-
async getTasks(options?: { repository?: string; createdBy?: number }) {
70+
async getProjectAutonomySettings(projectId: number): Promise<{
71+
proactive_tasks_enabled?: boolean;
72+
}> {
73+
try {
74+
const urlPath = `/api/environments/${projectId}/`;
75+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
76+
const response = await this.api.fetcher.fetch({
77+
method: "get",
78+
url,
79+
path: urlPath,
80+
});
81+
const data = (await response.json()) as {
82+
proactive_tasks_enabled?: boolean;
83+
};
84+
85+
return {
86+
proactive_tasks_enabled:
87+
typeof data.proactive_tasks_enabled === "boolean"
88+
? data.proactive_tasks_enabled
89+
: false,
90+
};
91+
} catch (error) {
92+
log.warn("Failed to resolve autonomy settings; defaulting to disabled", {
93+
projectId,
94+
error,
95+
});
96+
return { proactive_tasks_enabled: false };
97+
}
98+
}
99+
100+
async getTasks(options?: {
101+
repository?: string;
102+
createdBy?: number;
103+
originProduct?: string;
104+
}) {
65105
const teamId = await this.getTeamId();
66106
const params: Record<string, string | number> = {
67107
limit: 500,
@@ -75,6 +115,10 @@ export class PostHogAPIClient {
75115
params.created_by = options.createdBy;
76116
}
77117

118+
if (options?.originProduct) {
119+
params.origin_product = options.originProduct;
120+
}
121+
78122
const data = await this.api.get(`/api/projects/{project_id}/tasks/`, {
79123
path: { project_id: teamId.toString() },
80124
query: params,
@@ -374,6 +418,52 @@ export class PostHogAPIClient {
374418
return data.results ?? [];
375419
}
376420

421+
async updateTeam(updates: {
422+
session_recording_opt_in?: boolean;
423+
autocapture_exceptions_opt_in?: boolean;
424+
}): Promise<Schemas.Team> {
425+
const teamId = await this.getTeamId();
426+
const url = new URL(`${this.api.baseUrl}/api/projects/${teamId}/`);
427+
const response = await this.api.fetcher.fetch({
428+
method: "patch",
429+
url,
430+
path: `/api/projects/${teamId}/`,
431+
overrides: {
432+
body: JSON.stringify(updates),
433+
},
434+
});
435+
436+
if (!response.ok) {
437+
const responseText = await response.text();
438+
let detail = responseText;
439+
try {
440+
const parsed = JSON.parse(responseText) as
441+
| { detail?: string }
442+
| Record<string, unknown>;
443+
if (
444+
typeof parsed === "object" &&
445+
parsed !== null &&
446+
"detail" in parsed &&
447+
typeof parsed.detail === "string"
448+
) {
449+
detail = parsed.detail;
450+
} else if (typeof parsed === "object" && parsed !== null) {
451+
detail = Object.entries(parsed)
452+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
453+
.join(", ");
454+
}
455+
} catch {
456+
// keep plain text fallback
457+
}
458+
459+
throw new Error(
460+
`Failed to update team: ${detail || response.statusText}`,
461+
);
462+
}
463+
464+
return await response.json();
465+
}
466+
377467
/**
378468
* Get details for multiple projects by their IDs.
379469
* Returns project info including organization details.
@@ -432,6 +522,99 @@ export class PostHogAPIClient {
432522
}));
433523
}
434524

525+
async getSignalReports(): Promise<SignalReportsResponse> {
526+
const teamId = await this.getTeamId();
527+
const url = new URL(
528+
`${this.api.baseUrl}/api/projects/${teamId}/signal_reports/`,
529+
);
530+
const response = await this.api.fetcher.fetch({
531+
method: "get",
532+
url,
533+
path: `/api/projects/${teamId}/signal_reports/`,
534+
});
535+
536+
if (!response.ok) {
537+
throw new Error(`Failed to fetch signal reports: ${response.statusText}`);
538+
}
539+
540+
const data = await response.json();
541+
return {
542+
results: data.results ?? data ?? [],
543+
count: data.count ?? data.results?.length ?? data?.length ?? 0,
544+
};
545+
}
546+
547+
async getSignalReportArtefacts(
548+
reportId: string,
549+
): Promise<SignalReportArtefactsResponse> {
550+
const teamId = await this.getTeamId();
551+
const url = new URL(
552+
`${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`,
553+
);
554+
const response = await this.api.fetcher.fetch({
555+
method: "get",
556+
url,
557+
path: `/api/projects/${teamId}/signal_reports/${reportId}/artefacts/`,
558+
});
559+
560+
if (!response.ok) {
561+
throw new Error(
562+
`Failed to fetch signal report artefacts: ${response.statusText}`,
563+
);
564+
}
565+
566+
const data = await response.json();
567+
return {
568+
results: data.results ?? data ?? [],
569+
count: data.count ?? data.results?.length ?? data?.length ?? 0,
570+
};
571+
}
572+
573+
async getRepositoryReadiness(
574+
repository: string,
575+
options?: { refresh?: boolean; windowDays?: number },
576+
): Promise<RepoAutonomyStatus> {
577+
const teamId = await this.getTeamId();
578+
const url = new URL(
579+
`${this.api.baseUrl}/api/projects/${teamId}/tasks/repository_readiness/`,
580+
);
581+
url.searchParams.set("repository", repository.toLowerCase());
582+
if (options?.refresh) {
583+
url.searchParams.set("refresh", "true");
584+
}
585+
if (typeof options?.windowDays === "number") {
586+
url.searchParams.set("window_days", String(options.windowDays));
587+
}
588+
589+
const response = await this.api.fetcher.fetch({
590+
method: "get",
591+
url,
592+
path: `/api/projects/${teamId}/tasks/repository_readiness/`,
593+
});
594+
595+
if (!response.ok) {
596+
throw new Error(
597+
`Failed to fetch repository readiness: ${response.statusText}`,
598+
);
599+
}
600+
601+
const data = await response.json();
602+
return {
603+
repository: data.repository,
604+
classification: data.classification,
605+
excluded: data.excluded,
606+
coreSuggestions: data.coreSuggestions,
607+
replayInsights: data.replayInsights,
608+
errorInsights: data.errorInsights,
609+
overall: data.overall,
610+
evidenceTaskCount: data.evidenceTaskCount ?? 0,
611+
windowDays: data.windowDays,
612+
generatedAt: data.generatedAt,
613+
cacheAgeSeconds: data.cacheAgeSeconds,
614+
scan: data.scan,
615+
} as RepoAutonomyStatus;
616+
}
617+
435618
/**
436619
* Check if a feature flag is enabled for the current project.
437620
* Returns true if the flag exists and is active, false otherwise.

apps/twig/src/renderer/components/MainLayout.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { ConnectivityPrompt } from "@components/ConnectivityPrompt";
22
import { HeaderRow } from "@components/HeaderRow";
33
import { KeyboardShortcutsSheet } from "@components/KeyboardShortcutsSheet";
44
import { UpdatePrompt } from "@components/UpdatePrompt";
5+
import { useAutonomy } from "@features/autonomy/hooks/useAutonomy";
56
import { CommandMenu } from "@features/command/components/CommandMenu";
7+
import { InboxView } from "@features/inbox/components/InboxView";
68
import { RightSidebar, RightSidebarContent } from "@features/right-sidebar";
79
import { FolderSettingsView } from "@features/settings/components/FolderSettingsView";
810
import { SettingsDialog } from "@features/settings/components/SettingsDialog";
@@ -21,7 +23,7 @@ import { useTaskDeepLink } from "../hooks/useTaskDeepLink";
2123
import { GlobalEventHandlers } from "./GlobalEventHandlers";
2224

2325
export function MainLayout() {
24-
const { view, hydrateTask } = useNavigationStore();
26+
const { view, hydrateTask, navigateToTaskInput } = useNavigationStore();
2527
const [commandMenuOpen, setCommandMenuOpen] = useState(false);
2628
const {
2729
isOpen: shortcutsSheetOpen,
@@ -30,6 +32,7 @@ export function MainLayout() {
3032
} = useShortcutsSheetStore();
3133
const { data: tasks } = useTasks();
3234
const { showPrompt, isChecking, check, dismiss } = useConnectivity();
35+
const inboxEnabled = useAutonomy();
3336

3437
useIntegrations();
3538
useTaskDeepLink();
@@ -40,6 +43,12 @@ export function MainLayout() {
4043
}
4144
}, [tasks, hydrateTask]);
4245

46+
useEffect(() => {
47+
if (view.type === "inbox" && !inboxEnabled) {
48+
navigateToTaskInput();
49+
}
50+
}, [view.type, inboxEnabled, navigateToTaskInput]);
51+
4352
const handleToggleCommandMenu = useCallback(() => {
4453
setCommandMenuOpen((prev) => !prev);
4554
}, []);
@@ -58,6 +67,10 @@ export function MainLayout() {
5867
)}
5968

6069
{view.type === "folder-settings" && <FolderSettingsView />}
70+
71+
{view.type === "inbox" && inboxEnabled && <InboxView />}
72+
73+
{view.type === "inbox" && !inboxEnabled && <TaskInput />}
6174
</Box>
6275

6376
{view.type === "task-detail" && view.data && (

apps/twig/src/renderer/components/ui/Tooltip.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ export function Tooltip({
2727
defaultOpen,
2828
onOpenChange,
2929
}: TooltipProps) {
30+
const isSimpleContent =
31+
typeof content === "string" || typeof content === "number";
32+
3033
return (
3134
<TooltipPrimitive.Provider delayDuration={delayDuration}>
3235
<TooltipPrimitive.Root
@@ -51,7 +54,7 @@ export function Tooltip({
5154
borderRadius: "6px",
5255
fontSize: "12px",
5356
lineHeight: "1.4",
54-
whiteSpace: "nowrap",
57+
whiteSpace: isSimpleContent ? "nowrap" : "normal",
5558
border: "1px solid var(--gray-4)",
5659
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
5760
zIndex: 9999,
@@ -60,7 +63,7 @@ export function Tooltip({
6063
willChange: "transform, opacity",
6164
}}
6265
>
63-
<span>{content}</span>
66+
{isSimpleContent ? <span>{content}</span> : content}
6467
{shortcut && (
6568
<KeyHint style={{ fontSize: "12px" }}>{shortcut}</KeyHint>
6669
)}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useAuthStore } from "@features/auth/stores/authStore";
2+
import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery";
3+
4+
interface AutonomySettings {
5+
proactive_tasks_enabled?: boolean;
6+
}
7+
8+
export function useAutonomy(): boolean {
9+
const projectId = useAuthStore((state) => state.projectId);
10+
const { data: autonomySettings } = useAuthenticatedQuery<AutonomySettings>(
11+
["inbox", "autonomy-settings", projectId],
12+
(client) =>
13+
projectId
14+
? client.getProjectAutonomySettings(projectId)
15+
: Promise.resolve({}),
16+
{
17+
enabled: !!projectId,
18+
staleTime: 30 * 1000,
19+
},
20+
);
21+
22+
return autonomySettings?.proactive_tasks_enabled === true;
23+
}

0 commit comments

Comments
 (0)