From 35c936af4d09e947d483e10270a069419d4406fc Mon Sep 17 00:00:00 2001 From: Jacob Kim Date: Wed, 8 Jul 2026 10:57:11 +0900 Subject: [PATCH] feat(ui): flatten sidebar, fullscreen settings, and revamp information panel - remove glassmorphism (backdrop-blur/translucent surfaces) from the sidebar, popovers, dialogs, and chat surfaces; keep modal dim scrims - flatten the project/workspace sidebar rows and add a Fleet View entry plus an "Active workspaces" list above the project list - convert Settings from a modal dialog to a full-screen surface with a back-to-app button instead of a close button - add an Amplify Link Information panel section (types, schema, MCP tool, host runtime, agent auto-registration instructions) - make Information panel sections drag-to-reorder (persisted globally), render Notes as markdown with click-to-edit, and move the Plans count and refresh action into the section header like Pull Requests - add a "cover chat" display mode to Lens alongside the existing fullscreen mode, migrating the persisted layout key automatically Co-Authored-By: Claude Sonnet 5 --- electron/host-service.ts | 4 + electron/host-service/local-mcp-runtime.ts | 53 ++- electron/host-service/protocol.ts | 3 +- electron/main/stave-mcp-server.ts | 37 ++ electron/main/stave-mcp-service.ts | 12 + electron/providers/claude-sdk-runtime.ts | 1 + src/components/ai-elements/prompt-input.tsx | 8 +- src/components/layout/AppShell.tsx | 5 +- src/components/layout/CliSessionPanel.tsx | 2 +- src/components/layout/ConfirmDialog.tsx | 2 +- .../layout/CreateWorkspaceBranchPicker.tsx | 2 +- .../layout/CreateWorkspaceDialog.tsx | 2 +- .../layout/KeyboardShortcutsDrawer.tsx | 2 +- src/components/layout/OpenPathDialog.tsx | 2 +- .../layout/ProjectWorkspaceSidebar.tsx | 197 ++++++++- .../layout/ProjectWorkspaceSidebar.utils.ts | 89 ++++ .../layout/QuitConfirmationDialog.tsx | 2 +- src/components/layout/ResourcesPopover.tsx | 2 +- src/components/layout/SettingsDialog.tsx | 121 +++--- src/components/layout/TerminalDock.tsx | 2 +- src/components/layout/TopBarFileSearch.tsx | 2 +- .../layout/WorkspaceInformationPanel.tsx | 383 +++++++++++++++++- src/components/layout/WorkspaceLensPanel.tsx | 123 +++++- .../layout/WorkspacePlansSection.tsx | 29 +- src/components/layout/WorkspaceTaskTabs.tsx | 6 +- .../layout/settings-dialog.utils.ts | 12 - src/components/scripts/ScriptLogView.tsx | 6 +- src/components/session/ChatPanel.tsx | 2 +- .../session/SessionLoadingState.tsx | 2 +- .../session/chat-input-approval-queue.tsx | 2 +- src/components/ui/command.tsx | 2 +- src/components/ui/context-menu.tsx | 4 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/drawer.tsx | 2 +- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/image-lightbox.tsx | 2 +- src/components/ui/select.tsx | 2 +- src/components/ui/sheet.tsx | 2 +- src/globals.css | 96 ----- .../task-context/current-task-awareness.ts | 14 + src/lib/task-context/schemas.ts | 10 + src/lib/workspace-information-references.ts | 20 + src/lib/workspace-information.ts | 52 +++ src/store/app.store.ts | 2 +- src/store/layout.utils.ts | 48 ++- tests/bridge-persistence-regression.test.ts | 2 + tests/current-task-awareness.test.ts | 16 + tests/layout-utils.test.ts | 77 ++++ tests/project-workspace-sidebar.test.ts | 65 +++ tests/settings-dialog-utils.test.ts | 26 +- tests/workspace-information.test.ts | 27 ++ 51 files changed, 1288 insertions(+), 302 deletions(-) create mode 100644 tests/layout-utils.test.ts diff --git a/electron/host-service.ts b/electron/host-service.ts index c6a169ea..75d99ecf 100644 --- a/electron/host-service.ts +++ b/electron/host-service.ts @@ -560,6 +560,10 @@ async function invokeLocalMcpAction(action: HostLocalMcpAction, args: unknown) { return localMcpRuntime.addWorkspaceSlackThread( args as Parameters[0], ); + case "add-workspace-amplify-link": + return localMcpRuntime.addWorkspaceAmplifyLink( + args as Parameters[0], + ); default: action satisfies never; throw new Error(`Unsupported local MCP action: ${action}`); diff --git a/electron/host-service/local-mcp-runtime.ts b/electron/host-service/local-mcp-runtime.ts index 52718f9a..d1d9a20e 100644 --- a/electron/host-service/local-mcp-runtime.ts +++ b/electron/host-service/local-mcp-runtime.ts @@ -12,6 +12,7 @@ import { buildCurrentTaskAwarenessRetrievedContext } from "../../src/lib/task-co import type { AppNotificationCreateInput } from "../../src/lib/notifications/notification.types"; import { workspaceHasActiveTurns } from "../../src/lib/notifications/notification.types"; import { + createWorkspaceAmplifyLink, createWorkspaceConfluencePage, createWorkspaceFigmaResource, createWorkspaceInfoCustomField, @@ -22,6 +23,7 @@ import { applyWorkspaceTodoStatus, createWorkspaceTodoItem, type WorkspaceTodoStatus, + extractAmplifyLinkReference, extractConfluencePageReference, extractFigmaResourceReference, extractJiraIssueReference, @@ -169,7 +171,8 @@ type WorkspaceInformationResourceKind = | "confluence" | "figma" | "storybook" - | "slack"; + | "slack" + | "amplify"; type WorkspaceCustomFieldValueInput = string | number | boolean | null; @@ -512,6 +515,7 @@ function normalizeWorkspaceResourceKind( case "figma": case "storybook": case "slack": + case "amplify": return value.trim(); default: throw new Error(`Unsupported workspace resource kind: ${value}`); @@ -895,6 +899,17 @@ export async function addWorkspaceResource(args: { slackThreads: [...current.slackThreads, nextLink], }; } + case "amplify": { + const nextLink = createWorkspaceAmplifyLink(); + nextLink.url = url; + nextLink.label = + title || extractAmplifyLinkReference(url)?.branch || ""; + nextLink.note = note; + return { + ...current, + amplifyLinks: [...(current.amplifyLinks ?? []), nextLink], + }; + } } }, }); @@ -985,6 +1000,18 @@ export async function removeWorkspaceResource(args: { slackThreads, }; } + case "amplify": { + const amplifyLinks = (current.amplifyLinks ?? []).filter( + (item) => item.id !== args.itemId, + ); + if (amplifyLinks.length === (current.amplifyLinks ?? []).length) { + throw new Error(`Workspace resource not found: ${args.itemId}`); + } + return { + ...current, + amplifyLinks, + }; + } } }, }); @@ -1308,6 +1335,30 @@ export async function addWorkspaceSlackThread(args: { workspaceInformation: workspaceInformation.workspaceInformation, }; } + +export async function addWorkspaceAmplifyLink(args: { + workspaceId: string; + url: string; + label?: string; + note?: string; +}) { + const parsed = extractAmplifyLinkReference(args.url); + const workspaceInformation = await addWorkspaceResource({ + workspaceId: args.workspaceId, + kind: "amplify", + url: normalizeWorkspaceInfoString(args.url), + title: normalizeWorkspaceInfoString(args.label) || parsed?.branch || "", + note: normalizeWorkspaceInfoString(args.note), + }); + return { + workspaceId: workspaceInformation.workspaceId, + added: + (workspaceInformation.workspaceInformation.amplifyLinks ?? []).at(-1) ?? + null, + workspaceInformation: workspaceInformation.workspaceInformation, + }; +} + function buildTaskTitleFromPrompt(prompt: string) { return ( prompt diff --git a/electron/host-service/protocol.ts b/electron/host-service/protocol.ts index b2d07e69..d7dcc882 100644 --- a/electron/host-service/protocol.ts +++ b/electron/host-service/protocol.ts @@ -343,7 +343,8 @@ export type HostLocalMcpAction = | "add-workspace-figma-resource" | "add-workspace-storybook-resource" | "update-workspace-storybook-resource-access" - | "add-workspace-slack-thread"; + | "add-workspace-slack-thread" + | "add-workspace-amplify-link"; export interface HostServiceRequestMap { "service.shutdown": undefined; diff --git a/electron/main/stave-mcp-server.ts b/electron/main/stave-mcp-server.ts index 63609320..fa59ef54 100644 --- a/electron/main/stave-mcp-server.ts +++ b/electron/main/stave-mcp-server.ts @@ -27,6 +27,7 @@ import { } from "./stave-mcp-config"; import { ensurePersistenceReady } from "./state"; import { + addWorkspaceAmplifyLink, addWorkspaceCustomField, addWorkspaceConfluencePage, addWorkspaceFigmaResource, @@ -598,6 +599,7 @@ function createToolServer() { "storybook", "slack", "figma", + "amplify", ]) .describe("Resource kind."), url: z.string().url().describe("Resource URL."), @@ -659,6 +661,7 @@ function createToolServer() { "storybook", "slack", "figma", + "amplify", ]) .describe("Resource kind."), itemId: z.string().min(1).describe("Stored resource id."), @@ -995,6 +998,40 @@ function createToolServer() { ), ); + server.registerTool( + "stave_add_workspace_amplify_link", + { + description: + "Register an AWS Amplify deploy URL in the Stave Workspace Information panel.", + inputSchema: { + workspaceId: z.string().min(1).describe("Workspace id."), + url: z + .string() + .min(1) + .describe( + "Amplify deploy URL, e.g. https://..amplifyapp.com.", + ), + label: z + .string() + .optional() + .describe("Optional branch/environment label."), + note: z + .string() + .optional() + .describe("Optional note stored with the link."), + }, + }, + async ({ workspaceId, url, label, note }) => + toStructuredResult( + await addWorkspaceAmplifyLink({ + workspaceId, + url, + label, + note, + }), + ), + ); + server.registerTool( "stave_respond_approval", { diff --git a/electron/main/stave-mcp-service.ts b/electron/main/stave-mcp-service.ts index 790e3d67..f3ebcea1 100644 --- a/electron/main/stave-mcp-service.ts +++ b/electron/main/stave-mcp-service.ts @@ -263,6 +263,18 @@ export async function addWorkspaceSlackThread(args: { }>("add-workspace-slack-thread", args); } +export async function addWorkspaceAmplifyLink(args: { + workspaceId: string; + url: string; + label?: string; + note?: string; +}) { + return invokeLocalMcp<{ + workspaceId: string; + workspaceInformation: import("../../src/lib/workspace-information").WorkspaceInformationState; + }>("add-workspace-amplify-link", args); +} + export async function registerProject(args: { projectPath: string; projectName?: string; diff --git a/electron/providers/claude-sdk-runtime.ts b/electron/providers/claude-sdk-runtime.ts index 03dc7698..f42bcf41 100644 --- a/electron/providers/claude-sdk-runtime.ts +++ b/electron/providers/claude-sdk-runtime.ts @@ -157,6 +157,7 @@ const CLAUDE_AUTO_ALLOWED_MCP_TOOL_NAMES = new Set([ "stave_update_workspace_storybook_resource_access", "stave_add_workspace_figma_resource", "stave_add_workspace_slack_thread", + "stave_add_workspace_amplify_link", "stave_add_workspace_custom_field", "stave_set_workspace_custom_field", "stave_remove_workspace_custom_field", diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx index 19b91d8b..508d75d6 100644 --- a/src/components/ai-elements/prompt-input.tsx +++ b/src/components/ai-elements/prompt-input.tsx @@ -1902,7 +1902,7 @@ export function PromptInput(args: PromptInputProps) {
@@ -1937,7 +1937,7 @@ export function PromptInput(args: PromptInputProps) { className: cn( PROMPT_TOOLBAR_BUTTON, PROMPT_FLOATING_SURFACE, - "h-7 gap-1.5 px-2.5 text-xs shadow-sm supports-backdrop-filter:backdrop-blur-md", + "h-7 gap-1.5 px-2.5 text-xs shadow-sm", steerQueueSecondaryAction === "steer" && "text-primary hover:text-primary", primaryActionDisabled && @@ -1980,7 +1980,7 @@ export function PromptInput(args: PromptInputProps) { className={cn( PROMPT_TOOLBAR_BUTTON, PROMPT_FLOATING_SURFACE, - "pointer-events-auto h-8 gap-2 shadow-sm supports-backdrop-filter:backdrop-blur-md", + "pointer-events-auto h-8 gap-2 shadow-sm", )} > Focus @@ -3235,7 +3235,7 @@ export function PromptInput(args: PromptInputProps) { - + Current Runtime diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index b4f56c1d..0b3dcb7c 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -387,7 +387,7 @@ export function AppShell() { function OverlayLoadingFallback(args: { title: string }) { return (
@@ -1263,7 +1263,7 @@ export function AppShell() {
-
+
Zoom {zoomHudPercent}%
@@ -1397,6 +1397,7 @@ export function AppShell() { >
diff --git a/src/components/layout/CliSessionPanel.tsx b/src/components/layout/CliSessionPanel.tsx index e8812f73..a68b9564 100644 --- a/src/components/layout/CliSessionPanel.tsx +++ b/src/components/layout/CliSessionPanel.tsx @@ -297,7 +297,7 @@ function CliSessionPanelImpl() {
{/* Header container always at child position 0 so {terminalViewport} stays at position 1 and React never unmounts the terminal surface. */} -
+
{isVisible ? (
diff --git a/src/components/layout/ConfirmDialog.tsx b/src/components/layout/ConfirmDialog.tsx index 02e0078d..36bb7c9c 100644 --- a/src/components/layout/ConfirmDialog.tsx +++ b/src/components/layout/ConfirmDialog.tsx @@ -55,7 +55,7 @@ export function ConfirmDialog(args: ConfirmDialogProps) { } return ( -
+
event.stopPropagation()}>

{title}

diff --git a/src/components/layout/CreateWorkspaceBranchPicker.tsx b/src/components/layout/CreateWorkspaceBranchPicker.tsx index e648d0df..dc19852b 100644 --- a/src/components/layout/CreateWorkspaceBranchPicker.tsx +++ b/src/components/layout/CreateWorkspaceBranchPicker.tsx @@ -262,7 +262,7 @@ export function CreateWorkspaceBranchPicker({ event.preventDefault()} > diff --git a/src/components/layout/CreateWorkspaceDialog.tsx b/src/components/layout/CreateWorkspaceDialog.tsx index 7e2f9f7a..2f775188 100644 --- a/src/components/layout/CreateWorkspaceDialog.tsx +++ b/src/components/layout/CreateWorkspaceDialog.tsx @@ -272,7 +272,7 @@ export function CreateWorkspaceDialog({
- +
diff --git a/src/components/layout/OpenPathDialog.tsx b/src/components/layout/OpenPathDialog.tsx index 21f8b4b3..df13f2e8 100644 --- a/src/components/layout/OpenPathDialog.tsx +++ b/src/components/layout/OpenPathDialog.tsx @@ -91,7 +91,7 @@ export function OpenPathDialog(args: OpenPathDialogProps) { return (
{ return [ @@ -979,6 +995,13 @@ export function ProjectWorkspaceSidebar(args: { state.setLayout, state.fetchAllWorkspacePrStatuses, state.hydrateWorkspaces, + state.activeAppSurface, + state.openFleetView, + state.tasks, + state.messagesByTask, + state.activeTurnIdsByTask, + state.providerTurnActivityByTask, + state.workspaceRuntimeCacheById, ] as const; }), ); @@ -1057,6 +1080,69 @@ export function ProjectWorkspaceSidebar(args: { }), [activeWorkspaceId, projects], ); + const recentProjectLastOpenedAtByPath = useMemo(() => { + const map: Record = {}; + for (const project of recentProjects) { + map[project.projectPath] = project.lastOpenedAt; + } + return map; + }, [recentProjects]); + const workspaceFleetStatusById = useMemo(() => { + const statusById: Record = {}; + for (const project of projects) { + for (const workspace of project.workspaces) { + const isActiveWorkspace = + project.isCurrent && workspace.id === activeWorkspaceId; + const runtimeState = isActiveWorkspace + ? { tasks: activeTasks, messagesByTask, activeTurnIdsByTask } + : workspaceRuntimeCacheById[workspace.id]; + if (!runtimeState) { + continue; + } + + let bestStatus: FleetTaskStatus = "idle"; + for (const task of runtimeState.tasks) { + if (isTaskArchived(task) || isLegacyBranchTask(task)) { + continue; + } + const status = classifyTaskStatus({ + task, + messages: runtimeState.messagesByTask[task.id], + activeTurnId: runtimeState.activeTurnIdsByTask[task.id] ?? null, + activity: providerTurnActivityByTask[task.id] ?? null, + }); + if (compareFleetTaskStatus(status, bestStatus) < 0) { + bestStatus = status; + } + } + statusById[workspace.id] = bestStatus; + } + } + return statusById; + }, [ + activeTasks, + activeTurnIdsByTask, + activeWorkspaceId, + messagesByTask, + projects, + providerTurnActivityByTask, + workspaceRuntimeCacheById, + ]); + const activeWorkspaceEntries = useMemo( + () => + buildSidebarActiveWorkspaceEntries({ + projects, + recentProjectLastOpenedAtByPath, + statusByWorkspaceId: workspaceFleetStatusById, + activeWorkspaceId, + }), + [ + activeWorkspaceId, + projects, + recentProjectLastOpenedAtByPath, + workspaceFleetStatusById, + ], + ); const workspaceShortcutTargets = useMemo( () => buildVisibleWorkspaceShortcutTargets({ @@ -1311,7 +1397,7 @@ export function ProjectWorkspaceSidebar(args: { + + Fleet View +
) : ( @@ -1383,7 +1488,7 @@ export function ProjectWorkspaceSidebar(args: { "flex h-10 w-10 items-center justify-center rounded-md border transition-colors", entry.isActive ? "border-primary/40 bg-primary/10 text-primary shadow-sm" - : "border-transparent bg-background/60 text-muted-foreground hover:border-border/70 hover:bg-secondary/70 hover:text-foreground", + : "border-transparent bg-transparent text-muted-foreground hover:border-border/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", )} onClick={() => void handleProjectWorkspaceOpen({ @@ -1411,6 +1516,69 @@ export function ProjectWorkspaceSidebar(args: { {!args.collapsed ? (
+
+ + {activeWorkspaceEntries.length > 0 ? ( + <> +
+ Active workspaces +
+ {activeWorkspaceEntries.map((entry) => ( + + + + ))} + + ) : null} +
setOpenPathDialogOpen(true)} aria-label="open-project" > @@ -1445,7 +1613,7 @@ export function ProjectWorkspaceSidebar(args: { type="button" variant="ghost" size="sm" - className="h-8 w-8 rounded-md p-0 text-muted-foreground hover:bg-background/20 hover:text-foreground" + className="h-8 w-8 rounded-md p-0 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" aria-label="workspace-item-display-mode" > {workspaceSidebarItemDisplayMode === @@ -1487,7 +1655,7 @@ export function ProjectWorkspaceSidebar(args: { variant="ghost" size="sm" className={cn( - "h-8 w-8 rounded-md p-0 text-muted-foreground hover:bg-background/20 hover:text-foreground", + "h-8 w-8 rounded-md p-0 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", reorderMode && "bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary", )} @@ -1514,7 +1682,7 @@ export function ProjectWorkspaceSidebar(args: { setWorkspaceSearchQuery(event.target.value) } placeholder="Search labels or branches" - className="h-8 rounded-md border-sidebar-border/60 bg-background/45 pl-7 pr-7 text-xs" + className="h-8 rounded-md border-sidebar-border/60 bg-transparent pl-7 pr-7 text-xs" aria-label="search-workspaces" /> {workspaceSearchQuery.trim() ? ( @@ -1551,7 +1719,7 @@ export function ProjectWorkspaceSidebar(args: { )} strategy={verticalListSortingStrategy} > -
+
{visibleProjects.map((project) => { const collapsed = collapsedByProjectPath[project.projectPath] ?? @@ -1572,15 +1740,14 @@ export function ProjectWorkspaceSidebar(args: { {({ dragHandle, isDragging }) => (
-
+
{dragHandle}
@@ -1729,7 +1896,7 @@ export function ProjectWorkspaceSidebar(args: {
{!collapsed ? ( -
+
{dragHandle} diff --git a/src/components/layout/ProjectWorkspaceSidebar.utils.ts b/src/components/layout/ProjectWorkspaceSidebar.utils.ts index 912b51f7..4364bbe3 100644 --- a/src/components/layout/ProjectWorkspaceSidebar.utils.ts +++ b/src/components/layout/ProjectWorkspaceSidebar.utils.ts @@ -1,3 +1,5 @@ +import type { FleetTaskStatus } from "@/lib/fleet/task-status"; +import { hasFleetTaskAttentionStatus } from "@/lib/fleet/task-status"; import { isLegacyBranchTask, isTaskArchived } from "@/lib/tasks"; import type { Task } from "@/types/chat"; @@ -198,6 +200,93 @@ export function buildCollapsedWorkspaceEntries(args: { }, []); } +export interface SidebarActiveWorkspaceEntry { + projectPath: string; + projectName: string; + workspaceId: string; + workspaceName: string; + branch?: string; + isDefault: boolean; + isActive: boolean; + status: FleetTaskStatus; +} + +const SIDEBAR_ACTIVE_WORKSPACE_STATUS_RANK: Record = { + "waiting-input": 0, + "waiting-approval": 0, + error: 1, + running: 2, + idle: 3, +}; + +/** + * Ranks and caps a sidebar "Active workspaces" list: attention (waiting on + * the user) and error/running workspaces surface first, the remainder is + * filled out with the most recently opened workspace per project. + */ +export function buildSidebarActiveWorkspaceEntries(args: { + projects: ProjectSidebarCollapsedProjectView[]; + recentProjectLastOpenedAtByPath: Record; + statusByWorkspaceId: Record; + activeWorkspaceId: string; + limit?: number; +}): SidebarActiveWorkspaceEntry[] { + const limit = args.limit ?? 5; + const seen = new Set(); + const entries: (SidebarActiveWorkspaceEntry & { lastOpenedAt: string })[] = + []; + + for (const project of args.projects) { + for (const workspace of project.workspaces) { + if (seen.has(workspace.id)) { + continue; + } + const isActive = + project.isCurrent && workspace.id === args.activeWorkspaceId; + const isRepresentativeWorkspace = + workspace.id === project.activeWorkspaceId; + const status = args.statusByWorkspaceId[workspace.id] ?? "idle"; + const isNoteworthy = + hasFleetTaskAttentionStatus(status) || + status === "error" || + status === "running"; + + if (!isActive && !isRepresentativeWorkspace && !isNoteworthy) { + continue; + } + + seen.add(workspace.id); + entries.push({ + projectPath: project.projectPath, + projectName: project.projectName, + workspaceId: workspace.id, + workspaceName: workspace.name, + branch: workspace.branch, + isDefault: workspace.isDefault, + isActive, + status, + lastOpenedAt: + args.recentProjectLastOpenedAtByPath[project.projectPath] ?? "", + }); + } + } + + entries.sort((left, right) => { + if (left.isActive !== right.isActive) { + return left.isActive ? -1 : 1; + } + const statusDelta = + SIDEBAR_ACTIVE_WORKSPACE_STATUS_RANK[left.status] - + SIDEBAR_ACTIVE_WORKSPACE_STATUS_RANK[right.status]; + if (statusDelta !== 0) { + return statusDelta; + } + return right.lastOpenedAt.localeCompare(left.lastOpenedAt); + }); + + return entries.slice(0, limit).map(({ lastOpenedAt: _lastOpenedAt, ...entry }) => entry); +} + export function buildVisibleWorkspaceShortcutTargets(args: { collapsed: boolean; collapsedByProjectPath: Record; diff --git a/src/components/layout/QuitConfirmationDialog.tsx b/src/components/layout/QuitConfirmationDialog.tsx index 62e894b7..b125ea61 100644 --- a/src/components/layout/QuitConfirmationDialog.tsx +++ b/src/components/layout/QuitConfirmationDialog.tsx @@ -47,7 +47,7 @@ export function QuitConfirmationDialog(props: QuitConfirmationDialogProps) { > { event.preventDefault(); confirmButtonRef.current?.focus(); diff --git a/src/components/layout/ResourcesPopover.tsx b/src/components/layout/ResourcesPopover.tsx index e2d62192..f35f15ae 100644 --- a/src/components/layout/ResourcesPopover.tsx +++ b/src/components/layout/ResourcesPopover.tsx @@ -189,7 +189,7 @@ export function MemoryUsagePopover({ collapsed }: { collapsed?: boolean }) { side="right" align="start" sideOffset={12} - className="w-80 gap-0 overflow-hidden border border-border/80 bg-card/96 p-0 shadow-2xl supports-backdrop-filter:backdrop-blur-xl" + className="w-80 gap-0 overflow-hidden border border-border/80 bg-card p-0 shadow-2xl" onOpenAutoFocus={(e) => e.preventDefault()} > {/* Header */} diff --git a/src/components/layout/SettingsDialog.tsx b/src/components/layout/SettingsDialog.tsx index 11469853..9f90697d 100644 --- a/src/components/layout/SettingsDialog.tsx +++ b/src/components/layout/SettingsDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { Folder, X } from "lucide-react"; +import { ArrowLeft, Folder } from "lucide-react"; import { useShallow } from "zustand/react/shallow"; -import { Button, Card } from "@/components/ui"; +import { Button } from "@/components/ui"; import { Breadcrumb, BreadcrumbItem, @@ -26,7 +26,7 @@ import { cn } from "@/lib/utils"; import { useAppStore } from "@/store/app.store"; import { captureCurrentProjectState } from "@/store/project.utils"; import { settingsSectionGroups, settingsSections, type SectionId } from "./settings-dialog.schema"; -import { resolveSettingsProjectSelection, shouldCloseSettingsDialogFromMouseDown } from "./settings-dialog.utils"; +import { resolveSettingsProjectSelection } from "./settings-dialog.utils"; import { SettingsDialogSectionContent } from "./settings-dialog-sections"; interface SettingsDialogProps { @@ -38,6 +38,11 @@ interface SettingsDialogProps { const sectionsById = Object.fromEntries(settingsSections.map((section) => [section.id, section])) as Record; +const IS_MAC = + typeof window !== "undefined" && window.api?.platform === "darwin"; +/** Keep this aligned with the native traffic-light placement in `electron/main/window.ts`. */ +const MAC_TRAFFIC_LIGHT_CLEARANCE = 40; + export function SettingsDialog(args: SettingsDialogProps) { const { initialProjectPath, initialSection, open, onOpenChange } = args; const [activeSection, setActiveSection] = useState("general"); @@ -141,32 +146,33 @@ export function SettingsDialog(args: SettingsDialogProps) { return (
{ - if (!shouldCloseSettingsDialogFromMouseDown({ - target: event.target, - currentTarget: event.currentTarget, - })) { - return; - } - onOpenChange({ open: false }); - }} > - event.stopPropagation()} + - - - + + +
+ +
{settingsSectionGroups.map((group) => ( @@ -261,47 +267,36 @@ export function SettingsDialog(args: SettingsDialogProps) {
-
-
- - - - Settings - - - - - {activeSectionData.label} - - - - -
- -
-
+
+
+ + + + Settings + + + + + {activeSectionData.label} + + + + +
-
-
- -
+
+
+
-
- - +
+ +
); } diff --git a/src/components/layout/TerminalDock.tsx b/src/components/layout/TerminalDock.tsx index 49c321be..48315f85 100644 --- a/src/components/layout/TerminalDock.tsx +++ b/src/components/layout/TerminalDock.tsx @@ -556,7 +556,7 @@ export function TerminalDock() {
setTerminalToRename(null)} > diff --git a/src/components/layout/TopBarFileSearch.tsx b/src/components/layout/TopBarFileSearch.tsx index 27b7328c..4da59ac4 100644 --- a/src/components/layout/TopBarFileSearch.tsx +++ b/src/components/layout/TopBarFileSearch.tsx @@ -245,7 +245,7 @@ export function TopBarFileSearch({ noDragStyle }: TopBarFileSearchProps) {
{isOpen ? (
diff --git a/src/components/layout/WorkspaceInformationPanel.tsx b/src/components/layout/WorkspaceInformationPanel.tsx index b0e2960f..7e2321a5 100644 --- a/src/components/layout/WorkspaceInformationPanel.tsx +++ b/src/components/layout/WorkspaceInformationPanel.tsx @@ -8,12 +8,14 @@ import { ChevronRight, Circle, CircleDot, + CloudUpload, ExternalLink, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Globe, + GripVertical, Hash, Link, MessageSquarePlus, @@ -26,7 +28,31 @@ import { UserRound, X, } from "lucide-react"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; import { useShallow } from "zustand/react/shallow"; import { Accordion, @@ -55,6 +81,7 @@ import { Calendar } from "@/components/ui/calendar"; import { Switch } from "@/components/ui/switch"; import { changeWorkspaceInfoCustomFieldType, + createWorkspaceAmplifyLink, createWorkspaceConfluencePage, createWorkspaceFigmaResource, createWorkspaceInfoCustomField, @@ -66,6 +93,7 @@ import { createWorkspaceTodoItem, cycleWorkspaceTodoStatus, resolveWorkspaceTodoStatus, + extractAmplifyLinkReference, extractConfluencePageReference, extractFigmaResourceReference, extractGitHubPullRequestReference, @@ -103,6 +131,7 @@ import { import { cn } from "@/lib/utils"; import { useAppStore } from "@/store/app.store"; import { extractPlanTodoItems } from "@/lib/plans"; +import { EditorMarkdownPreview } from "./editor-markdown-preview"; import { WorkspacePlansSection } from "./WorkspacePlansSection"; // --------------------------------------------------------------------------- @@ -147,6 +176,7 @@ const WORKSPACE_INFORMATION_SECTION_IDS = [ "jira", "confluence", "storybook", + "amplify", "slack", "figma", "custom", @@ -196,6 +226,48 @@ function readStoredWorkspaceInformationSections(): WorkspaceInformationSectionId } } +const WORKSPACE_INFORMATION_SECTION_ORDER_STORAGE_KEY = + "stave:workspace-information-section-order:v1"; + +/** "overview" (Summary) always leads; the rest follow the stored/default order. */ +function normalizeWorkspaceInformationSectionOrder( + stored: unknown, +): WorkspaceInformationSectionId[] { + const valid = Array.isArray(stored) + ? stored.filter((value): value is WorkspaceInformationSectionId => + WORKSPACE_INFORMATION_SECTION_IDS.includes( + value as WorkspaceInformationSectionId, + ), + ) + : []; + const seen = new Set(valid); + const merged = [ + ...valid, + ...WORKSPACE_INFORMATION_SECTION_IDS.filter((id) => !seen.has(id)), + ]; + return [ + "overview", + ...merged.filter((id): id is WorkspaceInformationSectionId => id !== "overview"), + ]; +} + +function readStoredWorkspaceInformationSectionOrder(): WorkspaceInformationSectionId[] { + if (typeof window === "undefined") { + return normalizeWorkspaceInformationSectionOrder(null); + } + + try { + const raw = window.localStorage.getItem( + WORKSPACE_INFORMATION_SECTION_ORDER_STORAGE_KEY, + ); + return normalizeWorkspaceInformationSectionOrder( + raw ? JSON.parse(raw) : null, + ); + } catch { + return normalizeWorkspaceInformationSectionOrder(null); + } +} + function formatFigmaKindLabel( kind?: "file" | "design" | "proto" | "board" | "slides" | "unknown", ) { @@ -424,13 +496,41 @@ function SectionHeader(props: { action?: ReactNode; children: ReactNode; first?: boolean; + order?: number; }) { + const sortable = useSortable({ + id: props.value, + disabled: props.value === "overview", + }); + const style: CSSProperties = { + order: props.order, + transform: CSS.Transform.toString(sortable.transform), + transition: sortable.transition, + }; + return (
+ {props.value !== "overview" ? ( + + ) : null}
@@ -1048,6 +1148,94 @@ function EmptyHint(props: { children: ReactNode }) { ); } +// --------------------------------------------------------------------------- +// Notes — markdown preview by default, click to edit +// --------------------------------------------------------------------------- + +function NotesSectionBody(props: { + notes: string; + onChange: (value: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(props.notes); + + const startEditing = () => { + setDraft(props.notes); + setIsEditing(true); + }; + + const commit = () => { + setIsEditing(false); + if (draft !== props.notes) { + props.onChange(draft); + } + }; + + const cancel = () => { + setIsEditing(false); + }; + + if (isEditing) { + return ( +