Skip to content

Commit fc7f6cd

Browse files
committed
feat(ui): add cloud workspace mode option
feat(twig): ff implemented chore: clean
1 parent 6cf3024 commit fc7f6cd

File tree

7 files changed

+178
-39
lines changed

7 files changed

+178
-39
lines changed

.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ VITE_POSTHOG_UI_HOST=xxx
1313

1414
# PostHog Survey IDs (optional)
1515
VITE_POSTHOG_BUG_SURVEY_ID=
16-
VITE_POSTHOG_FEEDBACK_SURVEY_ID=
16+
VITE_POSTHOG_FEEDBACK_SURVEY_ID=

apps/twig/src/api/posthogClient.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,4 +431,40 @@ export class PostHogAPIClient {
431431
slug: org.slug ?? org.id,
432432
}));
433433
}
434+
435+
/**
436+
* Check if a feature flag is enabled for the current project.
437+
* Returns true if the flag exists and is active, false otherwise.
438+
*/
439+
async isFeatureFlagEnabled(flagKey: string): Promise<boolean> {
440+
try {
441+
const teamId = await this.getTeamId();
442+
const url = new URL(
443+
`${this.api.baseUrl}/api/projects/${teamId}/feature_flags/`,
444+
);
445+
url.searchParams.set("key", flagKey);
446+
447+
const response = await this.api.fetcher.fetch({
448+
method: "get",
449+
url,
450+
path: `/api/projects/${teamId}/feature_flags/`,
451+
});
452+
453+
if (!response.ok) {
454+
log.warn(`Failed to fetch feature flags: ${response.statusText}`);
455+
return false;
456+
}
457+
458+
const data = await response.json();
459+
const flags = data.results ?? data ?? [];
460+
const flag = flags.find(
461+
(f: { key: string; active: boolean }) => f.key === flagKey,
462+
);
463+
464+
return flag?.active ?? false;
465+
} catch (error) {
466+
log.warn(`Error checking feature flag "${flagKey}":`, error);
467+
return false;
468+
}
469+
}
434470
}

apps/twig/src/renderer/features/task-detail/components/TaskInput.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,10 @@ export function TaskInput() {
155155
value={workspaceMode}
156156
onChange={(mode) => {
157157
setWorkspaceMode(mode);
158-
setLastUsedLocalWorkspaceMode(mode);
158+
// Only persist local modes, not cloud
159+
if (mode !== "cloud") {
160+
setLastUsedLocalWorkspaceMode(mode);
161+
}
159162
}}
160163
size="1"
161164
/>

apps/twig/src/renderer/features/task-detail/components/WorkspaceModeSelect.tsx

Lines changed: 48 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import { GitBranch, Laptop } from "@phosphor-icons/react";
1+
import { useFeatureFlag } from "@hooks/useFeatureFlag";
2+
import { Cloud, GitBranch, Laptop } from "@phosphor-icons/react";
23
import { ChevronDownIcon } from "@radix-ui/react-icons";
34
import { Button, DropdownMenu, Flex, Text } from "@radix-ui/themes";
45
import type { Responsive } from "@radix-ui/themes/dist/esm/props/prop-def.js";
6+
import type { WorkspaceMode } from "@shared/types";
7+
import { useMemo } from "react";
58

6-
export type WorkspaceMode = "local" | "worktree";
9+
export type { WorkspaceMode };
710

811
interface WorkspaceModeSelectProps {
912
value: WorkspaceMode;
@@ -25,13 +28,26 @@ const MODE_CONFIG: Record<
2528
description: "Edits a copy so your work stays isolated",
2629
icon: <GitBranch size={16} weight="regular" />,
2730
},
31+
cloud: {
32+
label: "Cloud",
33+
description: "Runs in isolated sandbox",
34+
icon: <Cloud size={16} weight="regular" />,
35+
},
2836
};
2937

3038
export function WorkspaceModeSelect({
3139
value,
3240
onChange,
3341
size = "1",
3442
}: WorkspaceModeSelectProps) {
43+
const cloudModeEnabled = useFeatureFlag("twig-cloud-mode-toggle");
44+
45+
const availableModes = useMemo<WorkspaceMode[]>(
46+
() =>
47+
cloudModeEnabled ? ["worktree", "local", "cloud"] : ["worktree", "local"],
48+
[cloudModeEnabled],
49+
);
50+
3551
const currentMode = MODE_CONFIG[value] ?? MODE_CONFIG.worktree;
3652

3753
return (
@@ -49,40 +65,36 @@ export function WorkspaceModeSelect({
4965
</DropdownMenu.Trigger>
5066

5167
<DropdownMenu.Content align="start" size="1">
52-
<DropdownMenu.Item
53-
onSelect={() => onChange("worktree")}
54-
style={{ padding: "6px 8px", height: "auto" }}
55-
>
56-
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
57-
<GitBranch
58-
size={12}
59-
style={{ marginTop: 2, flexShrink: 0, color: "var(--gray-11)" }}
60-
/>
61-
<div>
62-
<Text size="1">{MODE_CONFIG.worktree.label}</Text>
63-
<Text size="1" color="gray" style={{ display: "block" }}>
64-
{MODE_CONFIG.worktree.description}
65-
</Text>
66-
</div>
67-
</div>
68-
</DropdownMenu.Item>
69-
<DropdownMenu.Item
70-
onSelect={() => onChange("local")}
71-
style={{ padding: "6px 8px", height: "auto" }}
72-
>
73-
<div style={{ display: "flex", gap: 6, alignItems: "flex-start" }}>
74-
<Laptop
75-
size={12}
76-
style={{ marginTop: 2, flexShrink: 0, color: "var(--gray-11)" }}
77-
/>
78-
<div>
79-
<Text size="1">{MODE_CONFIG.local.label}</Text>
80-
<Text size="1" color="gray" style={{ display: "block" }}>
81-
{MODE_CONFIG.local.description}
82-
</Text>
83-
</div>
84-
</div>
85-
</DropdownMenu.Item>
68+
{availableModes.map((mode) => {
69+
const config = MODE_CONFIG[mode];
70+
return (
71+
<DropdownMenu.Item
72+
key={mode}
73+
onSelect={() => onChange(mode)}
74+
style={{ padding: "6px 8px", height: "auto" }}
75+
>
76+
<div
77+
style={{ display: "flex", gap: 6, alignItems: "flex-start" }}
78+
>
79+
<span
80+
style={{
81+
marginTop: 2,
82+
flexShrink: 0,
83+
color: "var(--gray-11)",
84+
}}
85+
>
86+
{config.icon}
87+
</span>
88+
<div>
89+
<Text size="1">{config.label}</Text>
90+
<Text size="1" color="gray" style={{ display: "block" }}>
91+
{config.description}
92+
</Text>
93+
</div>
94+
</div>
95+
</DropdownMenu.Item>
96+
);
97+
})}
8698
</DropdownMenu.Content>
8799
</DropdownMenu.Root>
88100
);

apps/twig/src/renderer/features/task-detail/hooks/useTaskCreation.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,15 @@ export function useTaskCreation({
123123
const { isOnline } = useConnectivity();
124124

125125
const isCloudMode = workspaceMode === "cloud";
126+
// Cloud mode can work with either selectedRepository (production) or selectedDirectory (dev testing)
127+
const hasRequiredPath = isCloudMode
128+
? !!selectedRepository || !!selectedDirectory
129+
: !!selectedDirectory;
126130
const canSubmit =
127131
!!editorRef.current &&
128132
isAuthenticated &&
129133
isOnline &&
130-
(isCloudMode ? !!selectedRepository : !!selectedDirectory) &&
134+
hasRequiredPath &&
131135
!isCreatingTask &&
132136
!editorIsEmpty;
133137

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useAuthStore } from "@features/auth/stores/authStore";
2+
import { logger } from "@renderer/lib/logger";
3+
import { useEffect, useState } from "react";
4+
5+
const log = logger.scope("useFeatureFlag");
6+
7+
// Cache for to avoid having too many repeated API calls
8+
const flagCache = new Map<string, { value: boolean; timestamp: number }>();
9+
const CACHE_TTL_MS = 60 * 1000; // 1 minute
10+
11+
export function useFeatureFlag(
12+
flagKey: string,
13+
defaultValue: boolean = false,
14+
): boolean {
15+
const client = useAuthStore((state) => state.client);
16+
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
17+
const [enabled, setEnabled] = useState(defaultValue);
18+
19+
useEffect(() => {
20+
if (!isAuthenticated || !client) {
21+
log.debug(`Cannot check flag "${flagKey}": not authenticated`);
22+
setEnabled(defaultValue);
23+
return;
24+
}
25+
26+
// Check cache first
27+
const cached = flagCache.get(flagKey);
28+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
29+
log.debug(`Flag "${flagKey}" from cache:`, cached.value);
30+
setEnabled(cached.value);
31+
return;
32+
}
33+
34+
// Fetch from API
35+
client
36+
.isFeatureFlagEnabled(flagKey)
37+
.then((value) => {
38+
log.debug(`Flag "${flagKey}" from API:`, value);
39+
flagCache.set(flagKey, { value, timestamp: Date.now() });
40+
setEnabled(value);
41+
})
42+
.catch((error) => {
43+
log.warn(`Error checking flag "${flagKey}":`, error);
44+
setEnabled(defaultValue);
45+
});
46+
}, [flagKey, client, isAuthenticated, defaultValue]);
47+
48+
return enabled;
49+
}
50+
51+
export function clearFeatureFlagCache(): void {
52+
flagCache.clear();
53+
}

apps/twig/src/renderer/lib/analytics.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,34 @@ export function displaySurvey(surveyId: string) {
156156

157157
posthog.displaySurvey(surveyId);
158158
}
159+
160+
// ============================================================================
161+
// Feature Flags
162+
// ============================================================================
163+
164+
/**
165+
* Check if a feature flag is enabled for the current user.
166+
* Returns false if PostHog is not initialized or flag is not found.
167+
*/
168+
export function isFeatureFlagEnabled(flagKey: string): boolean {
169+
if (!isInitialized) {
170+
log.warn("PostHog not initialized, cannot check feature flag");
171+
return false;
172+
}
173+
174+
return posthog.isFeatureEnabled(flagKey) ?? false;
175+
}
176+
177+
/**
178+
* Subscribe to feature flag changes.
179+
* Callback is called when flags are loaded or updated.
180+
* Returns unsubscribe function.
181+
*/
182+
export function onFeatureFlagsLoaded(callback: () => void): () => void {
183+
if (!isInitialized) {
184+
log.warn("PostHog not initialized, cannot subscribe to feature flags");
185+
return () => {};
186+
}
187+
188+
return posthog.onFeatureFlags(callback);
189+
}

0 commit comments

Comments
 (0)