From 766585d1cb44acd7c5cbffc3d1c046a9f75ceadb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 27 Jan 2026 13:06:01 +0700 Subject: [PATCH] Update files --- packages/app/src/components/agent-list.tsx | 2 +- packages/app/src/components/git-diff-pane.tsx | 266 ++++++++---------- .../app/src/components/grouped-agent-list.tsx | 2 +- packages/app/src/contexts/session-context.tsx | 113 -------- .../src/hooks/use-checkout-status-query.ts | 11 +- .../server/src/client/daemon-client-v2.ts | 6 +- packages/server/src/server/session.ts | 16 +- packages/server/src/shared/messages.ts | 2 + .../server/src/utils/project-icon.test.ts | 54 +++- packages/server/src/utils/project-icon.ts | 56 ++++ 10 files changed, 249 insertions(+), 279 deletions(-) diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 4dcf50373..af892c50f 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -171,7 +171,7 @@ export function AgentList({ void queryClient.prefetchQuery({ queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.id), + queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }), staleTime: CHECKOUT_STATUS_STALE_TIME, }); } diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index b07fe3348..17ae553f3 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -362,7 +362,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const client = useSessionStore( (state) => state.sessions[serverId]?.client ?? null ); - const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted"); + const [diffModeOverride, setDiffModeOverride] = useState<"uncommitted" | "base" | null>(null); const [actionError, setActionError] = useState(null); const [actionStatus, setActionStatus] = useState(null); const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge"); @@ -375,6 +375,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { status?.error?.message ?? (isStatusError && statusError instanceof Error ? statusError.message : null); const baseRef = gitStatus?.baseRef ?? undefined; + + // Auto-select diff mode based on state: uncommitted when dirty, base when clean + const hasUncommittedChanges = Boolean(gitStatus?.isDirty); + const autoDiffMode = hasUncommittedChanges ? "uncommitted" : "base"; + const diffMode = diffModeOverride ?? autoDiffMode; + const { files, payloadError: diffPayloadError, @@ -485,6 +491,11 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } }, [isDiffFetching, isStatusFetching, isManualRefresh]); + // Clear diff mode override when auto mode changes (e.g., after commit) + useEffect(() => { + setDiffModeOverride(null); + }, [autoDiffMode]); + useEffect(() => { if (!isPerfLoggingEnabled()) { return; @@ -697,7 +708,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { const branchLabel = gitStatus?.currentBranch ?? (notGit ? "Not a git repository" : "Unknown"); const actionsDisabled = !isGit || Boolean(status?.error) || isStatusLoading; - const hasUncommittedChanges = Boolean(gitStatus?.isDirty); const aheadCount = gitStatus?.aheadBehind?.ahead ?? 0; const canShowCommit = isGit && hasUncommittedChanges; const canShowShip = isGit && aheadCount > 0; @@ -780,7 +790,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { } const hasPullRequest = Boolean(prStatus?.url); - const prActionLabel = hasPullRequest ? "Open PR" : "Create PR"; + const prActionLabel = hasPullRequest ? "View PR" : "Create PR"; type ShipActionKey = "merge" | "pr"; const shipActions: { key: ShipActionKey; label: string; disabled: boolean; isPending: boolean }[] = @@ -817,6 +827,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { return hasPullRequest ? false : prMutation.isPending; }, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]); + // When there are uncommitted changes, Commit becomes the primary CTA + const showCommitAsPrimary = canShowCommit; + const showShipSplitButton = canShowShip && !showCommitAsPrimary; + return ( @@ -831,7 +845,86 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { {isGit ? ( - {canShowShip ? ( + {showCommitAsPrimary ? ( + + commitMutation.mutate()} + disabled={commitDisabled} + accessibilityRole="button" + accessibilityLabel="Commit changes" + > + {commitMutation.isPending ? ( + + ) : ( + Commit + )} + + + + + + + {canShowShip ? ( + <> + { + void persistShipDefault("merge"); + mergeMutation.mutate(); + }} + > + Merge branch + + { + void persistShipDefault("pr"); + if (hasPullRequest && prStatus?.url) { + void Linking.openURL(prStatus.url); + return; + } + prMutation.mutate(); + }} + > + {prActionLabel} + + + + ) : null} + mergeFromBaseMutation.mutate()} + > + Merge from {baseRefLabel} + + + pushMutation.mutate()} + > + Push to remote + + + + + ) : showShipSplitButton ? ( { void persistShipDefault("merge"); mergeMutation.mutate(); @@ -891,7 +983,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { { void persistShipDefault("pr"); @@ -902,7 +994,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { prMutation.mutate(); }} > - {hasPullRequest ? "Open PR" : "Create PR"} + {prActionLabel} @@ -923,7 +1015,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { <> {resolvedShipPrimary === "merge" ? ( { void persistShipDefault("pr"); @@ -934,7 +1026,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) { prMutation.mutate(); }} > - {hasPullRequest ? "Open PR" : "Create PR"} + {prActionLabel} ) : ( + setDiffModeOverride(diffMode === "uncommitted" ? "base" : "uncommitted")} + > + {diffMode === "uncommitted" ? `Show changes vs ${baseRefLabel}` : "Show uncommitted changes"} + + {isGit ? ( - - - {canShowCommit ? ( - commitMutation.mutate()} - disabled={commitDisabled} - > - {commitMutation.isPending ? ( - - ) : ( - Commit - )} - - ) : ( - - )} - - - - - - {diffMode === "uncommitted" ? "Working" : "Base"} - - - - - setDiffMode("uncommitted")} - > - Working - - setDiffMode("base")} - > - Base - - - - + + + {diffMode === "uncommitted" ? "Uncommitted changes" : `Changes vs ${baseRefLabel}`} + ) : null} {actionStatus ? {actionStatus} : null} {actionError ? {actionError} : null} - {prStatus ? ( - { - if (!prStatus.url) return; - void Linking.openURL(prStatus.url); - }} - > - PR - - {prStatus.state} {prStatus.url ? `ยท ${prStatus.url}` : ""} - - - ) : null} {prErrorMessage ? ( {prErrorMessage} ) : null} @@ -1103,64 +1136,15 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.medium, flexShrink: 1, }, - viewSelector: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - paddingHorizontal: theme.spacing[2], - paddingVertical: theme.spacing[1], - borderRadius: theme.borderRadius.lg, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - }, - viewSelectorText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, - }, - toolbarRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], + diffStatusRow: { paddingHorizontal: theme.spacing[3], - paddingTop: theme.spacing[2], - paddingBottom: theme.spacing[3], + paddingVertical: theme.spacing[2], borderBottomWidth: 1, borderBottomColor: theme.colors.border, }, - toolbarLeft: { - flex: 1, - minWidth: 0, - }, - toolbarLeftSpacer: { - height: 36, - }, - toolbarRight: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 0, - }, - secondaryActionButton: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - borderRadius: theme.borderRadius.md, - backgroundColor: theme.colors.surface2, - borderWidth: theme.borderWidth[1], - borderColor: theme.colors.borderAccent, - alignSelf: "flex-start", - }, - secondaryActionButtonDisabled: { - opacity: 0.5, - }, - secondaryActionText: { + diffStatusText: { fontSize: theme.fontSize.xs, - color: theme.colors.foreground, - fontWeight: theme.fontWeight.medium, - }, - buttonSpinner: { - height: theme.fontSize.xs * 1.4, + color: theme.colors.foregroundMuted, }, shipSplitButton: { flexDirection: "row", @@ -1266,24 +1250,6 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.destructive, }, - prStatusRow: { - paddingHorizontal: theme.spacing[3], - paddingBottom: theme.spacing[2], - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, - prStatusLabel: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - textTransform: "uppercase", - letterSpacing: 0.6, - }, - prStatusValue: { - fontSize: theme.fontSize.xs, - color: theme.colors.foreground, - flexShrink: 1, - }, diffContainer: { flex: 1, minHeight: 0, diff --git a/packages/app/src/components/grouped-agent-list.tsx b/packages/app/src/components/grouped-agent-list.tsx index 274f02b23..b1becfd74 100644 --- a/packages/app/src/components/grouped-agent-list.tsx +++ b/packages/app/src/components/grouped-agent-list.tsx @@ -299,7 +299,7 @@ export function GroupedAgentList({ void queryClient.prefetchQuery({ queryKey, - queryFn: async () => await client.getCheckoutStatus(agent.id), + queryFn: async () => await client.getCheckoutStatus(agent.id, { cwd: agent.cwd }), staleTime: CHECKOUT_STATUS_STALE_TIME, }); } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index dcc977d26..e821af8ac 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -194,8 +194,6 @@ type FileDownloadTokenPayload = Extract< { type: "file_download_token_response" } >["payload"]; -const SESSION_SNAPSHOT_STORAGE_PREFIX = "@paseo:session-snapshot:"; - // Module-level map for agent initialization promises // Key: `${serverId}:${agentId}`, Value: { promise, resolve, reject } // This survives Fast Refresh because it's outside React component tree @@ -210,60 +208,6 @@ function getInitKey(serverId: string, agentId: string): string { return `${serverId}:${agentId}`; } -type PersistedSessionSnapshot = { - agents: AgentSnapshotPayload[]; - savedAt: string; -}; - -const getSessionSnapshotStorageKey = (serverId: string): string => { - return `${SESSION_SNAPSHOT_STORAGE_PREFIX}${serverId}`; -}; - -async function loadPersistedSessionSnapshot( - serverId: string -): Promise { - try { - const raw = await AsyncStorage.getItem( - getSessionSnapshotStorageKey(serverId) - ); - if (!raw) { - return null; - } - const parsed = JSON.parse(raw) as PersistedSessionSnapshot; - if (!Array.isArray(parsed?.agents)) { - return null; - } - return parsed; - } catch (error) { - console.error( - `[Session] Failed to load persisted snapshot for ${serverId}`, - error - ); - return null; - } -} - -async function persistSessionSnapshot( - serverId: string, - snapshot: { agents: AgentSnapshotPayload[] } -) { - try { - const payload: PersistedSessionSnapshot = { - agents: snapshot.agents, - savedAt: new Date().toISOString(), - }; - await AsyncStorage.setItem( - getSessionSnapshotStorageKey(serverId), - JSON.stringify(payload) - ); - } catch (error) { - console.error( - `[Session] Failed to persist snapshot for ${serverId}`, - error - ); - } -} - function normalizeAgentSnapshot( snapshot: AgentSnapshotPayload, serverId: string @@ -462,7 +406,6 @@ export function SessionProvider({ ) => Promise) | null >(null); - const hasHydratedSnapshotRef = useRef(false); const hasRequestedInitialSnapshotRef = useRef(false); const sessionStateTimeoutRef = useRef | null>( null @@ -621,61 +564,6 @@ export function SessionProvider({ }; }, [serverId, updateConnectionStatus]); - useEffect(() => { - hasHydratedSnapshotRef.current = false; - setHasHydratedAgents(serverId, false); - }, [serverId, setHasHydratedAgents]); - - useEffect(() => { - let isMounted = true; - - const hydrateFromSnapshot = async () => { - if (hasHydratedSnapshotRef.current) { - return; - } - hasHydratedSnapshotRef.current = true; - const snapshot = await loadPersistedSessionSnapshot(serverId); - if (!snapshot || !isMounted) { - return; - } - - const agents = new Map(); - const pendingPermissions = new Map(); - const agentLastActivity = new Map(); - - for (const agentSnapshot of snapshot.agents) { - const agent = normalizeAgentSnapshot(agentSnapshot, serverId); - agents.set(agent.id, agent); - agentLastActivity.set(agent.id, agent.lastActivityAt); - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - } - - setAgents(serverId, (prev) => { - if (prev.size > 0) { - return prev; - } - return agents; - }); - - // Initialize agentLastActivity slice (top-level) - for (const [agentId, timestamp] of agentLastActivity.entries()) { - setAgentLastActivity(agentId, timestamp); - } - - setPendingPermissions(serverId, pendingPermissions); - setHasHydratedAgents(serverId, true); - }; - - void hydrateFromSnapshot(); - - return () => { - isMounted = false; - }; - }, [serverId, setAgents, setPendingPermissions, setHasHydratedAgents]); - const updateExplorerState = useCallback( (agentId: string, updater: (state: any) => any) => { setFileExplorer(serverId, (prev) => { @@ -953,7 +841,6 @@ export function SessionProvider({ return changed ? next : prev; }); - void persistSessionSnapshot(serverId, { agents: agentsList }); setHasHydratedAgents(serverId, true); updateConnectionStatus(serverId, { status: "online", diff --git a/packages/app/src/hooks/use-checkout-status-query.ts b/packages/app/src/hooks/use-checkout-status-query.ts index e5be10e40..0cfc82185 100644 --- a/packages/app/src/hooks/use-checkout-status-query.ts +++ b/packages/app/src/hooks/use-checkout-status-query.ts @@ -24,10 +24,11 @@ interface UseCheckoutStatusQueryOptions { export type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; function fetchCheckoutStatus( - client: { getCheckoutStatus: (agentId: string) => Promise }, - agentId: string + client: { getCheckoutStatus: (agentId: string, options?: { cwd?: string }) => Promise }, + agentId: string, + cwd: string ): Promise { - return client.getCheckoutStatus(agentId); + return client.getCheckoutStatus(agentId, { cwd }); } export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutStatusQueryOptions) { @@ -50,7 +51,7 @@ export function useCheckoutStatusQuery({ serverId, agentId, cwd }: UseCheckoutSt if (!client) { throw new Error("Daemon client not available"); } - return await fetchCheckoutStatus(client, agentId); + return await fetchCheckoutStatus(client, agentId, cwd); }, enabled: !!client && isConnected && !!agentId && !!cwd, staleTime: CHECKOUT_STATUS_STALE_TIME, @@ -98,7 +99,7 @@ export function useCheckoutStatusCacheOnly({ serverId, agentId, cwd }: UseChecko if (!client) { throw new Error("Daemon client not available"); } - return await fetchCheckoutStatus(client, agentId); + return await fetchCheckoutStatus(client, agentId, cwd); }, enabled: false, staleTime: CHECKOUT_STATUS_STALE_TIME, diff --git a/packages/server/src/client/daemon-client-v2.ts b/packages/server/src/client/daemon-client-v2.ts index 3c2572e88..8420c51fe 100644 --- a/packages/server/src/client/daemon-client-v2.ts +++ b/packages/server/src/client/daemon-client-v2.ts @@ -1131,8 +1131,11 @@ export class DaemonClientV2 { async getCheckoutStatus( agentId: string, - requestId?: string + options?: { cwd?: string; requestId?: string } ): Promise { + const requestId = options?.requestId; + const cwd = options?.cwd; + if (!requestId) { const existing = this.checkoutStatusInFlight.get(agentId); if (existing) { @@ -1144,6 +1147,7 @@ export class DaemonClientV2 { const message = SessionInboundMessageSchema.parse({ type: "checkout_status_request", agentId, + cwd, requestId: resolvedRequestId, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9dd59656e..998abbe5b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2855,7 +2855,9 @@ export class Session { ): Promise { const { agentId, requestId } = msg; const agent = this.agentManager.getAgent(agentId); - if (!agent) { + // Use cwd from agent if found, otherwise fall back to cwd from message + const cwd = agent?.cwd ?? msg.cwd; + if (!cwd) { this.emit({ type: "checkout_status_response", payload: { @@ -2870,7 +2872,7 @@ export class Session { hasRemote: false, remoteUrl: null, isPaseoOwnedWorktree: false, - error: { code: "UNKNOWN", message: `Agent not found: ${agentId}` }, + error: { code: "UNKNOWN", message: `Agent not found and no cwd provided: ${agentId}` }, requestId, }, }); @@ -2878,13 +2880,13 @@ export class Session { } try { - const status = await getCheckoutStatus(agent.cwd, { paseoHome: this.paseoHome }); + const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome }); if (!status.isGit) { this.emit({ type: "checkout_status_response", payload: { agentId, - cwd: agent.cwd, + cwd, isGit: false, repoRoot: null, currentBranch: null, @@ -2906,7 +2908,7 @@ export class Session { type: "checkout_status_response", payload: { agentId, - cwd: agent.cwd, + cwd, isGit: true, repoRoot: status.repoRoot ?? null, currentBranch: status.currentBranch ?? null, @@ -2927,7 +2929,7 @@ export class Session { type: "checkout_status_response", payload: { agentId, - cwd: agent.cwd, + cwd, isGit: true, repoRoot: status.repoRoot ?? null, currentBranch: status.currentBranch ?? null, @@ -2946,7 +2948,7 @@ export class Session { type: "checkout_status_response", payload: { agentId, - cwd: agent.cwd, + cwd, isGit: false, repoRoot: null, currentBranch: null, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index b10f4ab4b..77835fcac 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -477,6 +477,8 @@ const CheckoutDiffCompareSchema = z.object({ export const CheckoutStatusRequestSchema = z.object({ type: z.literal("checkout_status_request"), agentId: z.string(), + /** Optional cwd to use if the agent is not live in memory (e.g. from persisted agents.json) */ + cwd: z.string().optional(), requestId: z.string(), }); diff --git a/packages/server/src/utils/project-icon.test.ts b/packages/server/src/utils/project-icon.test.ts index 9a6c5d5e0..ca158eac1 100644 --- a/packages/server/src/utils/project-icon.test.ts +++ b/packages/server/src/utils/project-icon.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS } from "./project-icon.js"; +import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS, MONOREPO_PACKAGE_DIRS } from "./project-icon.js"; function createTempDir(): string { return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-"))); @@ -55,6 +55,13 @@ describe("findProjectIcon", () => { }); }); + describe("MONOREPO_PACKAGE_DIRS", () => { + it("includes common monorepo package directories", () => { + expect(MONOREPO_PACKAGE_DIRS).toContain("packages"); + expect(MONOREPO_PACKAGE_DIRS).toContain("apps"); + }); + }); + it("returns null when no icon is found", async () => { const result = await findProjectIcon(tempDir); expect(result).toBeNull(); @@ -175,6 +182,51 @@ describe("findProjectIcon", () => { // Should return the first one based on pattern order (favicon.ico comes first) expect(result).toBe(join(tempDir, "favicon.ico")); }); + + describe("monorepo package directories", () => { + it("finds icon in packages/*/public directory", async () => { + mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true }); + writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico")); + }); + + it("finds icon in apps/*/public directory", async () => { + mkdirSync(join(tempDir, "apps", "web", "public"), { recursive: true }); + writeFileSync(join(tempDir, "apps", "web", "public", "favicon.png"), "icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "apps", "web", "public", "favicon.png")); + }); + + it("finds icon in packages/* root", async () => { + mkdirSync(join(tempDir, "packages", "ui"), { recursive: true }); + writeFileSync(join(tempDir, "packages", "ui", "logo.svg"), "icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "packages", "ui", "logo.svg")); + }); + + it("prioritizes root priority dirs over monorepo dirs", async () => { + mkdirSync(join(tempDir, "public"), { recursive: true }); + mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true }); + writeFileSync(join(tempDir, "public", "favicon.ico"), "root icon"); + writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "public", "favicon.ico")); + }); + + it("prioritizes monorepo dirs over root dir (non-priority)", async () => { + mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true }); + writeFileSync(join(tempDir, "logo.png"), "root icon"); + writeFileSync(join(tempDir, "packages", "app", "public", "favicon.ico"), "package icon"); + + const result = await findProjectIcon(tempDir); + expect(result).toBe(join(tempDir, "packages", "app", "public", "favicon.ico")); + }); + }); }); describe("getProjectIcon", () => { diff --git a/packages/server/src/utils/project-icon.ts b/packages/server/src/utils/project-icon.ts index 24d416239..754a166f5 100644 --- a/packages/server/src/utils/project-icon.ts +++ b/packages/server/src/utils/project-icon.ts @@ -9,6 +9,9 @@ export const ICON_PATTERNS = [ "favicon.ico", "favicon.png", "favicon.svg", + "favico.ico", + "favico.png", + "favico.svg", "icon.png", "icon.svg", "app-icon.png", @@ -24,6 +27,11 @@ export const ICON_PATTERNS = [ */ export const PRIORITY_DIRS = ["public", "static", "assets", "images", "img"]; +/** + * Monorepo package directory patterns to scan (e.g., packages/app, apps/web). + */ +export const MONOREPO_PACKAGE_DIRS = ["packages", "apps"]; + /** * Directories to ignore during search. */ @@ -306,6 +314,54 @@ export async function findProjectIcon( } } + // Then search monorepo package directories (packages/*, apps/*) + for (const monoDir of MONOREPO_PACKAGE_DIRS) { + const monoPath = join(projectDir, monoDir); + let packageEntries: string[]; + try { + packageEntries = await readdir(monoPath); + } catch { + continue; + } + + for (const packageName of packageEntries) { + const packagePath = join(monoPath, packageName); + try { + const packageStats = await stat(packagePath); + if (!packageStats.isDirectory()) continue; + } catch { + continue; + } + + // Search priority dirs within the package + for (const priorityDir of PRIORITY_DIRS) { + const priorityPath = join(packagePath, priorityDir); + try { + const priorityStats = await stat(priorityPath); + if (priorityStats.isDirectory()) { + const result = await searchDirRecursively( + priorityPath, + ICON_PATTERNS, + ignoredDirsSet, + maxDepth - 1 + ); + if (result) { + return result; + } + } + } catch { + // Directory doesn't exist, continue + } + } + + // Search package root + const found = await findIconInDir(packagePath, ICON_PATTERNS); + if (found) { + return found; + } + } + } + // Then search root and any other non-priority directories const found = await findDirRecursively(projectDir); if (found) {