Skip to content

Commit 9ccafca

Browse files
committed
feat: workspace ui improvements and bug fixes
1 parent 722880c commit 9ccafca

31 files changed

Lines changed: 672 additions & 391 deletions

apps/twig/src/main/services/git/service.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,12 @@ export interface GitServiceEvents {
3434
[GitServiceEvent.CloneProgress]: CloneProgressPayload;
3535
}
3636

37+
const FETCH_THROTTLE_MS = 5 * 60 * 1000; // 5 minutes
38+
3739
@injectable()
3840
export class GitService extends TypedEventEmitter<GitServiceEvents> {
41+
private lastFetchTime = new Map<string, number>();
42+
3943
public async detectRepo(
4044
directoryPath: string,
4145
): Promise<DetectRepoResult | null> {
@@ -475,13 +479,18 @@ export class GitService extends TypedEventEmitter<GitServiceEvents> {
475479
};
476480
}
477481

478-
try {
479-
await execAsync("git fetch --quiet", {
480-
cwd: directoryPath,
481-
timeout: 10000,
482-
});
483-
} catch {
484-
// Fetch failed (likely offline), continue with stale data
482+
const now = Date.now();
483+
const lastFetch = this.lastFetchTime.get(directoryPath) ?? 0;
484+
if (now - lastFetch > FETCH_THROTTLE_MS) {
485+
try {
486+
await execAsync("git fetch --quiet", {
487+
cwd: directoryPath,
488+
timeout: 10000,
489+
});
490+
this.lastFetchTime.set(directoryPath, now);
491+
} catch {
492+
// Fetch failed (likely offline), continue with stale data
493+
}
485494
}
486495

487496
const { stdout: revList } = await execAsync(

apps/twig/src/main/trpc/routers/os.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import os from "node:os";
33
import path from "node:path";
44
import { app, dialog, shell } from "electron";
55
import { z } from "zod";
6+
import { getWorktreeLocation } from "../../services/settingsStore.js";
67
import { getMainWindow } from "../context.js";
78
import { publicProcedure, router } from "../trpc.js";
89

@@ -143,4 +144,9 @@ export const osRouter = router({
143144
* Get the application version
144145
*/
145146
getAppVersion: publicProcedure.query(() => app.getVersion()),
147+
148+
/**
149+
* Get the worktree base location (e.g., ~/.twig)
150+
*/
151+
getWorktreeLocation: publicProcedure.query(() => getWorktreeLocation()),
146152
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Gear } from "@phosphor-icons/react";
2+
import { IconButton, Tooltip } from "@radix-ui/themes";
3+
import { useNavigationStore } from "@stores/navigationStore";
4+
5+
export function SettingsToggle() {
6+
const view = useNavigationStore((s) => s.view);
7+
const toggleSettings = useNavigationStore((s) => s.toggleSettings);
8+
const isSettingsOpen = view.type === "settings";
9+
10+
return (
11+
<Tooltip content="Settings">
12+
<IconButton
13+
size="1"
14+
variant="ghost"
15+
onClick={toggleSettings}
16+
style={{
17+
color: isSettingsOpen ? "var(--blue-9)" : "var(--gray-9)",
18+
}}
19+
>
20+
<Gear size={16} weight={isSettingsOpen ? "fill" : "regular"} />
21+
</IconButton>
22+
</Tooltip>
23+
);
24+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CampfireToggle } from "@components/CampfireToggle";
2+
import { SettingsToggle } from "@components/SettingsToggle";
23
import { StatusBarMenu } from "@components/StatusBarMenu";
34
import { Badge, Box, Code, Flex, Kbd } from "@radix-ui/themes";
45
import { useStatusBarStore } from "@stores/statusBarStore";
@@ -48,6 +49,7 @@ export function StatusBar({ showKeyHints = true }: StatusBarProps) {
4849

4950
<Flex align="center" gap="2">
5051
<CampfireToggle />
52+
<SettingsToggle />
5153
{IS_DEV && (
5254
<Badge size="1">
5355
<Code size="1" variant="ghost">

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function ThemeWrapper({ children }: { children: React.ReactNode }) {
2727
grayColor="slate"
2828
panelBackground="solid"
2929
radius="none"
30-
scaling="100%"
30+
scaling="105%"
3131
>
3232
{children}
3333
<div ref={portalRef} id="portal-container" />

apps/twig/src/renderer/features/code-editor/hooks/useCodeMirror.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@codemirror/merge";
77
import { EditorState, type Extension } from "@codemirror/state";
88
import { EditorView } from "@codemirror/view";
9+
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
910
import { trpcVanilla } from "@renderer/trpc/client";
1011
import { handleExternalAppAction } from "@utils/handleExternalAppAction";
1112
import { useEffect, useRef } from "react";
@@ -170,7 +171,25 @@ export function useCodeMirror(options: SingleDocOptions | DiffOptions) {
170171

171172
if (result.action.type === "external-app") {
172173
const fileName = filePath.split("/").pop() || "file";
173-
await handleExternalAppAction(result.action.action, filePath, fileName);
174+
175+
// Find workspace by matching filePath
176+
const workspaces = useWorkspaceStore.getState().workspaces;
177+
const workspace =
178+
Object.values(workspaces).find(
179+
(ws) =>
180+
(ws?.worktreePath && filePath.startsWith(ws.worktreePath)) ||
181+
(ws?.folderPath && filePath.startsWith(ws.folderPath)),
182+
) ?? null;
183+
184+
await handleExternalAppAction(
185+
result.action.action,
186+
filePath,
187+
fileName,
188+
{
189+
workspace,
190+
mainRepoPath: workspace?.folderPath,
191+
},
192+
);
174193
}
175194
};
176195

apps/twig/src/renderer/features/panels/components/DraggableTab.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useSortable } from "@dnd-kit/react/sortable";
22
import type { TabData } from "@features/panels/store/panelTypes";
3+
import { useWorkspaceStore } from "@features/workspace/stores/workspaceStore";
34
import { Cross2Icon } from "@radix-ui/react-icons";
45
import { Box, Flex, IconButton, Text } from "@radix-ui/themes";
56
import { trpcVanilla } from "@renderer/trpc/client";
@@ -89,10 +90,28 @@ export const DraggableTab: React.FC<DraggableTabProps> = ({
8990
break;
9091
case "external-app":
9192
if (filePath) {
93+
// Find workspace by matching repoPath
94+
const repoPath =
95+
tabData.type === "file" || tabData.type === "diff"
96+
? tabData.repoPath
97+
: undefined;
98+
const workspaces = useWorkspaceStore.getState().workspaces;
99+
const workspace = repoPath
100+
? (Object.values(workspaces).find(
101+
(ws) =>
102+
ws?.worktreePath === repoPath ||
103+
ws?.folderPath === repoPath,
104+
) ?? null)
105+
: null;
106+
92107
await handleExternalAppAction(
93108
result.action.action,
94109
filePath,
95110
label,
111+
{
112+
workspace,
113+
mainRepoPath: workspace?.folderPath,
114+
},
96115
);
97116
}
98117
break;

apps/twig/src/renderer/features/panels/components/TabbedPanel.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { useDroppable } from "@dnd-kit/react";
2-
import { SquareSplitHorizontalIcon } from "@phosphor-icons/react";
3-
import { PlusIcon } from "@radix-ui/react-icons";
2+
import { SquareSplitHorizontalIcon, Terminal } from "@phosphor-icons/react";
43
import { Box, Flex } from "@radix-ui/themes";
54
import { trpcVanilla } from "@renderer/trpc/client";
65
import type React from "react";
@@ -34,6 +33,7 @@ function TabBarButton({ ariaLabel, onClick, children }: TabBarButtonProps) {
3433
justifyContent: "center",
3534
background: isHovered ? "var(--gray-4)" : "var(--color-background)",
3635
border: "none",
36+
borderBottom: "1px solid var(--gray-6)",
3737
cursor: "pointer",
3838
color: "var(--gray-11)",
3939
}}
@@ -212,7 +212,7 @@ export const TabbedPanel: React.FC<TabbedPanelProps> = ({
212212
)}
213213
{onAddTerminal && (
214214
<TabBarButton ariaLabel="Add terminal" onClick={onAddTerminal}>
215-
<PlusIcon width={12} height={12} />
215+
<Terminal size={14} />
216216
</TabBarButton>
217217
)}
218218
</Flex>

apps/twig/src/renderer/features/right-sidebar/components/RightSidebarContent.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { ChangesPanel } from "@features/task-detail/components/ChangesPanel";
22
import { FileTreePanel } from "@features/task-detail/components/FileTreePanel";
3+
import { FolderSimple, GitDiff } from "@phosphor-icons/react";
34
import { Box, Flex, Text } from "@radix-ui/themes";
45
import type { Task } from "@shared/types";
6+
import type React from "react";
57
import { useState } from "react";
68

79
interface RightSidebarContentProps {
@@ -14,30 +16,40 @@ type TabId = "changes" | "files";
1416
interface TabProps {
1517
id: TabId;
1618
label: string;
19+
icon: React.ReactNode;
1720
isActive: boolean;
1821
onClick: () => void;
1922
}
2023

21-
function Tab({ label, isActive, onClick }: TabProps) {
24+
function Tab({ label, icon, isActive, onClick }: TabProps) {
2225
return (
23-
<Box
26+
<Flex
2427
onClick={onClick}
25-
px="3"
26-
py="2"
28+
align="center"
29+
gap="1"
30+
pl="3"
31+
pr="3"
32+
className="flex-shrink-0 cursor-pointer select-none border-r border-b-2"
2733
style={{
28-
cursor: "pointer",
29-
borderBottom: isActive
30-
? "2px solid var(--accent-9)"
31-
: "2px solid transparent",
32-
color: isActive ? "var(--gray-12)" : "var(--gray-11)",
33-
userSelect: "none",
34+
borderRightColor: "var(--gray-6)",
35+
borderBottomColor: isActive ? "var(--accent-10)" : "transparent",
36+
color: isActive ? "var(--accent-12)" : "var(--gray-11)",
37+
height: "31px",
38+
}}
39+
onMouseEnter={(e) => {
40+
if (!isActive) {
41+
e.currentTarget.style.color = "var(--gray-12)";
42+
}
43+
}}
44+
onMouseLeave={(e) => {
45+
if (!isActive) {
46+
e.currentTarget.style.color = "var(--gray-11)";
47+
}
3448
}}
35-
className={isActive ? "" : "hover:bg-gray-2"}
3649
>
37-
<Text size="2" weight={isActive ? "medium" : "regular"}>
38-
{label}
39-
</Text>
40-
</Box>
50+
<Box style={{ display: "flex", alignItems: "center" }}>{icon}</Box>
51+
<Text size="1">{label}</Text>
52+
</Flex>
4153
);
4254
}
4355

@@ -62,12 +74,14 @@ export function RightSidebarContent({
6274
<Tab
6375
id="changes"
6476
label="Changes"
77+
icon={<GitDiff size={14} />}
6578
isActive={activeTab === "changes"}
6679
onClick={() => setActiveTab("changes")}
6780
/>
6881
<Tab
6982
id="files"
7083
label="Files"
84+
icon={<FolderSimple size={14} />}
7185
isActive={activeTab === "files"}
7286
onClick={() => setActiveTab("files")}
7387
/>

apps/twig/src/renderer/features/sessions/stores/sessionStore.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -633,11 +633,15 @@ const useStore = create<SessionStore>()(
633633
addSession(session);
634634
subscribeToChannel(taskRunId);
635635

636-
// Proactively get the local worktree path so the agent has access to both
637-
// the main repo and the local worktree when/if we focus a worktree later
638-
const localWorktreePath = await trpcVanilla.focus.getLocalWorktreePath
639-
.query({ mainRepoPath: repoPath })
640-
.catch(() => null);
636+
// For local-mode sessions (not worktree), proactively get the local worktree path
637+
// so the agent has access to both the main repo and the local worktree when/if
638+
// we focus a worktree later. Skip this for worktree sessions (paths inside ~/.twig).
639+
const isWorktreeSession = repoPath.includes("/.twig/");
640+
const localWorktreePath = isWorktreeSession
641+
? null
642+
: await trpcVanilla.focus.getLocalWorktreePath
643+
.query({ mainRepoPath: repoPath })
644+
.catch(() => null);
641645

642646
const result = await trpcVanilla.agent.reconnect.mutate({
643647
taskId,
@@ -649,7 +653,7 @@ const useStore = create<SessionStore>()(
649653
logUrl,
650654
sdkSessionId,
651655
// Add the local worktree as an additional directory so the agent
652-
// can access it if the user focuses a worktree later
656+
// can access it if the user focuses a worktree later (local-mode only)
653657
...(localWorktreePath && {
654658
additionalDirectories: [localWorktreePath],
655659
}),
@@ -706,11 +710,15 @@ const useStore = create<SessionStore>()(
706710
const persistedMode = getPersistedTaskMode(taskId);
707711
const effectiveMode = executionMode ?? persistedMode;
708712

709-
// Proactively get the local worktree path so the agent has access to both
710-
// the main repo and the local worktree when/if we focus a worktree later
711-
const localWorktreePath = await trpcVanilla.focus.getLocalWorktreePath
712-
.query({ mainRepoPath: repoPath })
713-
.catch(() => null);
713+
// For local-mode sessions (not worktree), proactively get the local worktree path
714+
// so the agent has access to both the main repo and the local worktree when/if
715+
// we focus a worktree later. Skip this for worktree sessions (paths inside ~/.twig).
716+
const isWorktreeSession = repoPath.includes("/.twig/");
717+
const localWorktreePath = isWorktreeSession
718+
? null
719+
: await trpcVanilla.focus.getLocalWorktreePath
720+
.query({ mainRepoPath: repoPath })
721+
.catch(() => null);
714722

715723
const { defaultModel } = useSettingsStore.getState();
716724
const result = await trpcVanilla.agent.start.mutate({
@@ -723,7 +731,7 @@ const useStore = create<SessionStore>()(
723731
model: defaultModel,
724732
executionMode: effectiveMode,
725733
// Add the local worktree as an additional directory so the agent
726-
// can access it if the user focuses a worktree later
734+
// can access it if the user focuses a worktree later (local-mode only)
727735
...(localWorktreePath && {
728736
additionalDirectories: [localWorktreePath],
729737
}),

0 commit comments

Comments
 (0)