mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 20:00:48 +00:00
Add Foundry mobile layout with Tauri iOS/Android support
- Add responsive mobile layout with bottom pill tab bar, swipe navigation, and task list as home screen - Add platform detection (useIsMobile hook) with viewport breakpoint and VITE_MOBILE build flag - Mobile-optimize settings/billing/account pages (single-column layout with horizontal tabs) - Add iOS safe area inset handling with 47px minimum padding - Scaffold Tauri v2 mobile targets (iOS/Android) with platform-gated sidecar and capabilities - Add notification sound support and mobile build script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
436eb4a3a3
commit
f464fa96ad
68 changed files with 8006 additions and 631 deletions
|
|
@ -13,7 +13,7 @@
|
|||
<script>if(window.__TAURI_INTERNALS__)document.documentElement.dataset.tauri="1"</script>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<title>Foundry</title>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
BIN
foundry/packages/frontend/public/sounds/notification-1.mp3
Normal file
BIN
foundry/packages/frontend/public/sounds/notification-1.mp3
Normal file
Binary file not shown.
BIN
foundry/packages/frontend/public/sounds/notification-2.mp3
Normal file
BIN
foundry/packages/frontend/public/sounds/notification-2.mp3
Normal file
Binary file not shown.
|
|
@ -2,8 +2,10 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
|||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
|
||||
import type { WorkbenchPresence } from "@sandbox-agent/foundry-shared";
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { useAgentDoneNotification } from "../lib/notification-sound";
|
||||
|
||||
import { DiffContent } from "./mock-layout/diff-content";
|
||||
import { MessageList } from "./mock-layout/message-list";
|
||||
|
|
@ -11,15 +13,17 @@ import { PromptComposer } from "./mock-layout/prompt-composer";
|
|||
import { RightSidebar } from "./mock-layout/right-sidebar";
|
||||
import { Sidebar } from "./mock-layout/sidebar";
|
||||
import { TabStrip } from "./mock-layout/tab-strip";
|
||||
import { TerminalPane } from "./mock-layout/terminal-pane";
|
||||
import { TerminalPane, type ProcessTab } from "./mock-layout/terminal-pane";
|
||||
import { TranscriptHeader } from "./mock-layout/transcript-header";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
|
||||
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell, Tooltip } from "./mock-layout/ui";
|
||||
import {
|
||||
buildDisplayMessages,
|
||||
diffPath,
|
||||
diffTabId,
|
||||
formatThinkingDuration,
|
||||
isDiffTab,
|
||||
isTerminalTab,
|
||||
terminalTabId,
|
||||
buildHistoryEvents,
|
||||
type Task,
|
||||
type HistoryEvent,
|
||||
|
|
@ -27,8 +31,10 @@ import {
|
|||
type Message,
|
||||
type ModelId,
|
||||
} from "./mock-layout/view-model";
|
||||
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { activeMockOrganization, activeMockUser, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { useIsMobile } from "../lib/platform";
|
||||
import { getTaskWorkbenchClient } from "../lib/workbench";
|
||||
import { MobileLayout } from "./mock-layout/mobile-layout";
|
||||
|
||||
function firstAgentTabId(task: Task): string | null {
|
||||
return task.tabs[0]?.id ?? null;
|
||||
|
|
@ -58,12 +64,127 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
|||
if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) {
|
||||
return tabId;
|
||||
}
|
||||
if (isTerminalTab(tabId)) {
|
||||
return tabId;
|
||||
}
|
||||
}
|
||||
|
||||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||
}
|
||||
|
||||
function TypingIndicator({ presence, currentUserId }: { presence: WorkbenchPresence[]; currentUserId: string | null }) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const typingMembers = presence.filter((member) => member.typing && member.memberId !== currentUserId);
|
||||
const isTyping = typingMembers.length > 0;
|
||||
const [animState, setAnimState] = useState<"in" | "out" | "hidden">(isTyping ? "in" : "hidden");
|
||||
const lastMembersRef = useRef(typingMembers);
|
||||
|
||||
if (isTyping) {
|
||||
lastMembersRef.current = typingMembers;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
setAnimState("in");
|
||||
} else if (lastMembersRef.current.length > 0) {
|
||||
setAnimState("out");
|
||||
}
|
||||
}, [isTyping]);
|
||||
|
||||
if (animState === "hidden") return null;
|
||||
|
||||
const members = lastMembersRef.current;
|
||||
if (members.length === 0) return null;
|
||||
const label =
|
||||
members.length === 1
|
||||
? `${members[0]!.name} is typing`
|
||||
: members.length === 2
|
||||
? `${members[0]!.name} & ${members[1]!.name} are typing`
|
||||
: `${members[0]!.name} & ${members.length - 1} others are typing`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
padding: "6px 20px",
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
animationName: animState === "in" ? "hf-typing-in" : "hf-typing-out",
|
||||
animationDuration: "0.2s",
|
||||
animationTimingFunction: "ease-out",
|
||||
animationFillMode: "forwards",
|
||||
})}
|
||||
onAnimationEnd={() => {
|
||||
if (animState === "out") setAnimState("hidden");
|
||||
}}
|
||||
>
|
||||
<div className={css({ display: "flex", alignItems: "center" })}>
|
||||
{members.slice(0, 3).map((member) =>
|
||||
member.avatarUrl ? (
|
||||
<img
|
||||
key={member.memberId}
|
||||
src={member.avatarUrl}
|
||||
alt=""
|
||||
className={css({
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
border: `1.5px solid ${t.surfacePrimary}`,
|
||||
marginLeft: "-4px",
|
||||
":first-child": { marginLeft: 0 },
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={member.memberId}
|
||||
className={css({
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.borderMedium,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "9px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
border: `1.5px solid ${t.surfacePrimary}`,
|
||||
marginLeft: "-4px",
|
||||
":first-child": { marginLeft: 0 },
|
||||
})}
|
||||
>
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<span className={css({ fontSize: "12px", color: t.textTertiary })}>
|
||||
{label}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={css({
|
||||
animationName: "hf-dot-fade",
|
||||
animationDuration: "1.4s",
|
||||
animationIterationCount: "infinite",
|
||||
animationFillMode: "both",
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
})}
|
||||
>
|
||||
.
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TranscriptPanel = memo(function TranscriptPanel({
|
||||
workspaceId,
|
||||
taskWorkbenchClient,
|
||||
task,
|
||||
activeTabId,
|
||||
|
|
@ -80,7 +201,18 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
rightSidebarCollapsed,
|
||||
onToggleRightSidebar,
|
||||
onNavigateToUsage,
|
||||
terminalTabOpen,
|
||||
onOpenTerminalTab,
|
||||
onCloseTerminalTab,
|
||||
terminalProcessTabs,
|
||||
onTerminalProcessTabsChange,
|
||||
terminalActiveTabId,
|
||||
onTerminalActiveTabIdChange,
|
||||
terminalCustomNames,
|
||||
onTerminalCustomNamesChange,
|
||||
mobile,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
|
|
@ -97,8 +229,20 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
rightSidebarCollapsed?: boolean;
|
||||
onToggleRightSidebar?: () => void;
|
||||
onNavigateToUsage?: () => void;
|
||||
terminalTabOpen?: boolean;
|
||||
onOpenTerminalTab?: () => void;
|
||||
onCloseTerminalTab?: () => void;
|
||||
terminalProcessTabs?: ProcessTab[];
|
||||
onTerminalProcessTabsChange?: (tabs: ProcessTab[]) => void;
|
||||
terminalActiveTabId?: string | null;
|
||||
onTerminalActiveTabIdChange?: (id: string | null) => void;
|
||||
terminalCustomNames?: Record<string, string>;
|
||||
onTerminalCustomNamesChange?: (names: Record<string, string>) => void;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const t = useFoundryTokens();
|
||||
const transcriptAppSnapshot = useMockAppSnapshot();
|
||||
const currentUser = activeMockUser(transcriptAppSnapshot);
|
||||
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
|
||||
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
|
|
@ -111,9 +255,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null;
|
||||
const activeAgentTab = activeDiff ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null);
|
||||
const activeTerminal = activeTabId && isTerminalTab(activeTabId) ? true : false;
|
||||
const activeAgentTab = activeDiff || activeTerminal ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null);
|
||||
const promptTab = task.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? task.tabs[0] ?? null;
|
||||
const isTerminal = task.status === "archived";
|
||||
useAgentDoneNotification(promptTab?.status);
|
||||
const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]);
|
||||
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
|
||||
const draft = promptTab?.draft.text ?? "";
|
||||
|
|
@ -271,7 +417,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
(tabId: string) => {
|
||||
onSetActiveTabId(tabId);
|
||||
|
||||
if (!isDiffTab(tabId)) {
|
||||
if (!isDiffTab(tabId) && !isTerminalTab(tabId)) {
|
||||
onSetLastAgentTabId(tabId);
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (tab?.unread) {
|
||||
|
|
@ -448,28 +594,30 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
|
||||
return (
|
||||
<SPanel>
|
||||
<TranscriptHeader
|
||||
task={task}
|
||||
activeTab={activeAgentTab}
|
||||
editingField={editingField}
|
||||
editValue={editValue}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEditingField={startEditingField}
|
||||
onCommitEditingField={commitEditingField}
|
||||
onCancelEditingField={cancelEditingField}
|
||||
onSetActiveTabUnread={(unread) => {
|
||||
if (activeAgentTab) {
|
||||
setTabUnread(activeAgentTab.id, unread);
|
||||
}
|
||||
}}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onSidebarPeekStart={onSidebarPeekStart}
|
||||
onSidebarPeekEnd={onSidebarPeekEnd}
|
||||
rightSidebarCollapsed={rightSidebarCollapsed}
|
||||
onToggleRightSidebar={onToggleRightSidebar}
|
||||
onNavigateToUsage={onNavigateToUsage}
|
||||
/>
|
||||
{!mobile && (
|
||||
<TranscriptHeader
|
||||
task={task}
|
||||
activeTab={activeAgentTab}
|
||||
editingField={editingField}
|
||||
editValue={editValue}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEditingField={startEditingField}
|
||||
onCommitEditingField={commitEditingField}
|
||||
onCancelEditingField={cancelEditingField}
|
||||
onSetActiveTabUnread={(unread) => {
|
||||
if (activeAgentTab) {
|
||||
setTabUnread(activeAgentTab.id, unread);
|
||||
}
|
||||
}}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onSidebarPeekStart={onSidebarPeekStart}
|
||||
onSidebarPeekEnd={onSidebarPeekEnd}
|
||||
rightSidebarCollapsed={rightSidebarCollapsed}
|
||||
onToggleRightSidebar={onToggleRightSidebar}
|
||||
onNavigateToUsage={onNavigateToUsage}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
|
|
@ -478,11 +626,15 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
flexDirection: "column",
|
||||
backgroundColor: t.surfacePrimary,
|
||||
overflow: "hidden",
|
||||
borderTopLeftRadius: "12px",
|
||||
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
|
||||
borderBottomLeftRadius: "24px",
|
||||
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
...(mobile
|
||||
? {}
|
||||
: {
|
||||
borderTopLeftRadius: "12px",
|
||||
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
|
||||
borderBottomLeftRadius: "24px",
|
||||
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<TabStrip
|
||||
|
|
@ -500,9 +652,26 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
onCloseTab={closeTab}
|
||||
onCloseDiffTab={closeDiffTab}
|
||||
onAddTab={addTab}
|
||||
terminalTabOpen={terminalTabOpen}
|
||||
onCloseTerminalTab={onCloseTerminalTab}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
/>
|
||||
{activeDiff ? (
|
||||
{activeTerminal ? (
|
||||
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TerminalPane
|
||||
workspaceId={workspaceId}
|
||||
taskId={task.id}
|
||||
isExpanded
|
||||
hideHeader
|
||||
processTabs={terminalProcessTabs}
|
||||
onProcessTabsChange={onTerminalProcessTabsChange}
|
||||
activeProcessTabId={terminalActiveTabId}
|
||||
onActiveProcessTabIdChange={onTerminalActiveTabIdChange}
|
||||
customTabNames={terminalCustomNames}
|
||||
onCustomTabNamesChange={onTerminalCustomNamesChange}
|
||||
/>
|
||||
</div>
|
||||
) : activeDiff ? (
|
||||
<DiffContent
|
||||
filePath={activeDiff}
|
||||
file={task.fileChanges.find((file) => file.path === activeDiff)}
|
||||
|
|
@ -563,25 +732,30 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
void copyMessage(message);
|
||||
}}
|
||||
thinkingTimerLabel={thinkingTimerLabel}
|
||||
userName={currentUser?.name ?? null}
|
||||
userAvatarUrl={currentUser?.avatarUrl ?? null}
|
||||
/>
|
||||
</ScrollBody>
|
||||
)}
|
||||
{!isTerminal && promptTab ? (
|
||||
<PromptComposer
|
||||
draft={draft}
|
||||
textareaRef={textareaRef}
|
||||
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
|
||||
attachments={attachments}
|
||||
defaultModel={defaultModel}
|
||||
model={promptTab.model}
|
||||
isRunning={promptTab.status === "running"}
|
||||
onDraftChange={(value) => updateDraft(value, attachments)}
|
||||
onSend={sendMessage}
|
||||
onStop={stopAgent}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onChangeModel={changeModel}
|
||||
onSetDefaultModel={setDefaultModel}
|
||||
/>
|
||||
{!isTerminal && !activeTerminal && promptTab ? (
|
||||
<>
|
||||
<TypingIndicator presence={task.presence} currentUserId={currentUser?.id ?? null} />
|
||||
<PromptComposer
|
||||
draft={draft}
|
||||
textareaRef={textareaRef}
|
||||
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
|
||||
attachments={attachments}
|
||||
defaultModel={defaultModel}
|
||||
model={promptTab.model}
|
||||
isRunning={promptTab.status === "running"}
|
||||
onDraftChange={(value) => updateDraft(value, attachments)}
|
||||
onSend={sendMessage}
|
||||
onStop={stopAgent}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onChangeModel={changeModel}
|
||||
onSetDefaultModel={setDefaultModel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SPanel>
|
||||
|
|
@ -670,6 +844,14 @@ const RightRail = memo(function RightRail({
|
|||
onRevertFile,
|
||||
onPublishPr,
|
||||
onToggleSidebar,
|
||||
onOpenTerminalTab,
|
||||
terminalTabOpen,
|
||||
terminalProcessTabs,
|
||||
onTerminalProcessTabsChange,
|
||||
terminalActiveTabId,
|
||||
onTerminalActiveTabIdChange,
|
||||
terminalCustomNames,
|
||||
onTerminalCustomNamesChange,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
task: Task;
|
||||
|
|
@ -679,6 +861,14 @@ const RightRail = memo(function RightRail({
|
|||
onRevertFile: (path: string) => void;
|
||||
onPublishPr: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
onOpenTerminalTab?: () => void;
|
||||
terminalTabOpen?: boolean;
|
||||
terminalProcessTabs?: ProcessTab[];
|
||||
onTerminalProcessTabsChange?: (tabs: ProcessTab[]) => void;
|
||||
terminalActiveTabId?: string | null;
|
||||
onTerminalActiveTabIdChange?: (id: string | null) => void;
|
||||
terminalCustomNames?: Record<string, string>;
|
||||
onTerminalCustomNamesChange?: (names: Record<string, string>) => void;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -761,6 +951,13 @@ const RightRail = memo(function RightRail({
|
|||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
...(terminalTabOpen
|
||||
? {
|
||||
borderBottomRightRadius: "12px",
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
overflow: "hidden",
|
||||
}
|
||||
: {}),
|
||||
})}
|
||||
>
|
||||
<RightSidebar
|
||||
|
|
@ -775,14 +972,14 @@ const RightRail = memo(function RightRail({
|
|||
</div>
|
||||
<div
|
||||
className={css({
|
||||
height: `${terminalHeight}px`,
|
||||
minHeight: "43px",
|
||||
height: terminalTabOpen ? 0 : `${terminalHeight}px`,
|
||||
minHeight: terminalTabOpen ? 0 : "43px",
|
||||
backgroundColor: t.surfacePrimary,
|
||||
overflow: "hidden",
|
||||
borderBottomRightRadius: "12px",
|
||||
borderRight: `1px solid ${t.borderDefault}`,
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
display: "flex",
|
||||
borderRight: terminalTabOpen ? "none" : `1px solid ${t.borderDefault}`,
|
||||
borderBottom: terminalTabOpen ? "none" : `1px solid ${t.borderDefault}`,
|
||||
display: terminalTabOpen ? "none" : "flex",
|
||||
flexDirection: "column",
|
||||
})}
|
||||
>
|
||||
|
|
@ -802,6 +999,13 @@ const RightRail = memo(function RightRail({
|
|||
onCollapse={() => {
|
||||
setTerminalHeight(43);
|
||||
}}
|
||||
onOpenTerminalTab={onOpenTerminalTab}
|
||||
processTabs={terminalProcessTabs}
|
||||
onProcessTabsChange={onTerminalProcessTabsChange}
|
||||
activeProcessTabId={terminalActiveTabId}
|
||||
onActiveProcessTabIdChange={onTerminalActiveTabIdChange}
|
||||
customTabNames={terminalCustomNames}
|
||||
onCustomTabNamesChange={onTerminalCustomNamesChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -909,6 +1113,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } });
|
||||
}
|
||||
}, [activeOrg, navigate]);
|
||||
const navigateToSettings = useCallback(() => {
|
||||
if (activeOrg) {
|
||||
void navigate({ to: "/organizations/$organizationId/settings" as never, params: { organizationId: activeOrg.id } as never });
|
||||
}
|
||||
}, [activeOrg, navigate]);
|
||||
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
|
||||
const projects = useMemo(() => {
|
||||
if (!projectOrder) return rawProjects;
|
||||
|
|
@ -922,6 +1131,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
|
||||
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
|
||||
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
|
||||
const [terminalTabOpenByTask, setTerminalTabOpenByTask] = useState<Record<string, boolean>>({});
|
||||
const [terminalProcessTabsByTask, setTerminalProcessTabsByTask] = useState<Record<string, ProcessTab[]>>({});
|
||||
const [terminalActiveTabIdByTask, setTerminalActiveTabIdByTask] = useState<Record<string, string | null>>({});
|
||||
const [terminalCustomNamesByTask, setTerminalCustomNamesByTask] = useState<Record<string, Record<string, string>>>({});
|
||||
const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState("");
|
||||
const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH));
|
||||
const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH));
|
||||
|
|
@ -1021,6 +1234,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
}, [activeTask, tasks, navigate, workspaceId]);
|
||||
|
||||
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
|
||||
const terminalTabOpen = activeTask ? (terminalTabOpenByTask[activeTask.id] ?? false) : false;
|
||||
const terminalProcessTabs = activeTask ? (terminalProcessTabsByTask[activeTask.id] ?? []) : [];
|
||||
const terminalActiveTabId = activeTask ? (terminalActiveTabIdByTask[activeTask.id] ?? null) : null;
|
||||
const terminalCustomNames = activeTask ? (terminalCustomNamesByTask[activeTask.id] ?? {}) : {};
|
||||
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
|
||||
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
|
||||
|
||||
|
|
@ -1115,29 +1332,32 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
})();
|
||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||
|
||||
const createTask = useCallback(() => {
|
||||
void (async () => {
|
||||
const repoId = selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
const createTask = useCallback(
|
||||
(overrideRepoId?: string) => {
|
||||
void (async () => {
|
||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||
if (!repoId) {
|
||||
throw new Error("Cannot create a task without an available repo");
|
||||
}
|
||||
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
}, [navigate, selectedNewTaskRepoId, workspaceId]);
|
||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||
repoId,
|
||||
task: "New task",
|
||||
model: "gpt-4o",
|
||||
title: "New task",
|
||||
});
|
||||
await navigate({
|
||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||
params: {
|
||||
workspaceId,
|
||||
taskId,
|
||||
},
|
||||
search: { sessionId: tabId ?? undefined },
|
||||
});
|
||||
})();
|
||||
},
|
||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||
);
|
||||
|
||||
const openDiffTab = useCallback(
|
||||
(path: string) => {
|
||||
|
|
@ -1163,6 +1383,46 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
[activeTask],
|
||||
);
|
||||
|
||||
const openTerminalTab = useCallback(() => {
|
||||
if (!activeTask) return;
|
||||
setTerminalTabOpenByTask((current) => ({ ...current, [activeTask.id]: true }));
|
||||
setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: terminalTabId() }));
|
||||
}, [activeTask]);
|
||||
|
||||
const closeTerminalTab = useCallback(() => {
|
||||
if (!activeTask) return;
|
||||
setTerminalTabOpenByTask((current) => ({ ...current, [activeTask.id]: false }));
|
||||
const currentActive = activeTabIdByTask[activeTask.id];
|
||||
if (currentActive && isTerminalTab(currentActive)) {
|
||||
const fallback = lastAgentTabIdByTask[activeTask.id] ?? activeTask.tabs[0]?.id ?? null;
|
||||
setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: fallback }));
|
||||
}
|
||||
}, [activeTask, activeTabIdByTask, lastAgentTabIdByTask]);
|
||||
|
||||
const setTerminalProcessTabs = useCallback(
|
||||
(tabs: ProcessTab[]) => {
|
||||
if (!activeTask) return;
|
||||
setTerminalProcessTabsByTask((current) => ({ ...current, [activeTask.id]: tabs }));
|
||||
},
|
||||
[activeTask],
|
||||
);
|
||||
|
||||
const setTerminalActiveTabId = useCallback(
|
||||
(id: string | null) => {
|
||||
if (!activeTask) return;
|
||||
setTerminalActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: id }));
|
||||
},
|
||||
[activeTask],
|
||||
);
|
||||
|
||||
const setTerminalCustomNames = useCallback(
|
||||
(names: Record<string, string>) => {
|
||||
if (!activeTask) return;
|
||||
setTerminalCustomNamesByTask((current) => ({ ...current, [activeTask.id]: names }));
|
||||
},
|
||||
[activeTask],
|
||||
);
|
||||
|
||||
const selectTask = useCallback(
|
||||
(id: string) => {
|
||||
const task = tasks.find((candidate) => candidate.id === id) ?? null;
|
||||
|
|
@ -1265,6 +1525,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
[activeTask, lastAgentTabIdByTask],
|
||||
);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const isDesktop = !!import.meta.env.VITE_DESKTOP;
|
||||
const onDragMouseDown = useCallback((event: ReactPointerEvent) => {
|
||||
if (event.button !== 0) return;
|
||||
|
|
@ -1274,6 +1536,58 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
ipc.invoke("plugin:window|start_dragging").catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Mobile layout: single-panel stack navigation with bottom tab bar
|
||||
if (isMobile && activeTask) {
|
||||
return (
|
||||
<MobileLayout
|
||||
workspaceId={workspaceId}
|
||||
task={activeTask}
|
||||
tasks={tasks}
|
||||
projects={projects}
|
||||
repos={viewModel.repos}
|
||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||
onSelectTask={selectTask}
|
||||
onCreateTask={createTask}
|
||||
onMarkUnread={markTaskUnread}
|
||||
onRenameTask={renameTask}
|
||||
onRenameBranch={renameBranch}
|
||||
onReorderProjects={reorderProjects}
|
||||
taskOrderByProject={taskOrderByProject}
|
||||
onReorderTasks={reorderTasks}
|
||||
activeTabId={activeTabId}
|
||||
transcriptPanel={
|
||||
<TranscriptPanel
|
||||
workspaceId={workspaceId}
|
||||
taskWorkbenchClient={taskWorkbenchClient}
|
||||
task={activeTask}
|
||||
activeTabId={activeTabId}
|
||||
lastAgentTabId={lastAgentTabId}
|
||||
openDiffs={openDiffs}
|
||||
onSyncRouteSession={syncRouteSession}
|
||||
onSetActiveTabId={(tabId) => setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId }))}
|
||||
onSetLastAgentTabId={(tabId) => setLastAgentTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId }))}
|
||||
onSetOpenDiffs={(paths) => setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths }))}
|
||||
onNavigateToUsage={navigateToUsage}
|
||||
mobile
|
||||
/>
|
||||
}
|
||||
onOpenDiff={openDiffTab}
|
||||
onArchive={archiveTask}
|
||||
onRevertFile={revertFile}
|
||||
onPublishPr={publishPr}
|
||||
terminalProcessTabs={terminalProcessTabs}
|
||||
onTerminalProcessTabsChange={setTerminalProcessTabs}
|
||||
terminalActiveTabId={terminalActiveTabId}
|
||||
onTerminalActiveTabIdChange={setTerminalActiveTabId}
|
||||
terminalCustomNames={terminalCustomNames}
|
||||
onTerminalCustomNamesChange={setTerminalCustomNames}
|
||||
onOpenSettings={navigateToSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const dragRegion = isDesktop ? (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -1310,11 +1624,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
color: t.textPrimary,
|
||||
position: "relative",
|
||||
zIndex: 9999,
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
":hover": { color: t.textPrimary, backgroundColor: t.interactiveHover },
|
||||
});
|
||||
|
||||
const sidebarTransition = "width 200ms ease";
|
||||
|
|
@ -1370,15 +1684,19 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
{!leftSidebarOpen || !rightSidebarOpen ? (
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "8px 8px 0 8px" }}>
|
||||
{leftSidebarOpen ? null : (
|
||||
<div className={collapsedToggleClass} onClick={() => setLeftSidebarOpen(true)}>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
<Tooltip label="Toggle sidebar" placement="bottom">
|
||||
<div className={collapsedToggleClass} onClick={() => setLeftSidebarOpen(true)}>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{rightSidebarOpen ? null : (
|
||||
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
<Tooltip label="Toggle changes" placement="bottom">
|
||||
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -1409,7 +1727,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={createTask}
|
||||
onClick={() => createTask()}
|
||||
disabled={viewModel.repos.length === 0}
|
||||
style={{
|
||||
alignSelf: "center",
|
||||
|
|
@ -1543,6 +1861,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
|
||||
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
|
||||
<TranscriptPanel
|
||||
workspaceId={workspaceId}
|
||||
taskWorkbenchClient={taskWorkbenchClient}
|
||||
task={activeTask}
|
||||
activeTabId={activeTabId}
|
||||
|
|
@ -1568,6 +1887,15 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
rightSidebarCollapsed={!rightSidebarOpen}
|
||||
onToggleRightSidebar={() => setRightSidebarOpen(true)}
|
||||
onNavigateToUsage={navigateToUsage}
|
||||
terminalTabOpen={terminalTabOpen}
|
||||
onOpenTerminalTab={openTerminalTab}
|
||||
onCloseTerminalTab={closeTerminalTab}
|
||||
terminalProcessTabs={terminalProcessTabs}
|
||||
onTerminalProcessTabsChange={setTerminalProcessTabs}
|
||||
terminalActiveTabId={terminalActiveTabId}
|
||||
onTerminalActiveTabIdChange={setTerminalActiveTabId}
|
||||
terminalCustomNames={terminalCustomNames}
|
||||
onTerminalCustomNamesChange={setTerminalCustomNames}
|
||||
/>
|
||||
</div>
|
||||
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}
|
||||
|
|
@ -1592,6 +1920,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
onRevertFile={revertFile}
|
||||
onPublishPr={publishPr}
|
||||
onToggleSidebar={() => setRightSidebarOpen(false)}
|
||||
onOpenTerminalTab={openTerminalTab}
|
||||
terminalTabOpen={terminalTabOpen}
|
||||
terminalProcessTabs={terminalProcessTabs}
|
||||
onTerminalProcessTabsChange={setTerminalProcessTabs}
|
||||
terminalActiveTabId={terminalActiveTabId}
|
||||
onTerminalActiveTabIdChange={setTerminalActiveTabId}
|
||||
terminalCustomNames={terminalCustomNames}
|
||||
onTerminalCustomNamesChange={setTerminalCustomNames}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { LabelXSmall } from "baseui/typography";
|
|||
import { History } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { Tooltip } from "./ui";
|
||||
import { formatMessageTimestamp, type HistoryEvent } from "./view-model";
|
||||
|
||||
export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }: { events: HistoryEvent[]; onSelect: (event: HistoryEvent) => void }) {
|
||||
|
|
@ -41,29 +42,31 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
|||
gap: "6px",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setOpen((prev) => !prev);
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: open ? t.textSecondary : t.textTertiary,
|
||||
backgroundColor: open ? t.interactiveHover : "transparent",
|
||||
transition: "background 200ms ease, color 200ms ease",
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<History size={14} />
|
||||
</div>
|
||||
<Tooltip label="History" placement="left">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") setOpen((prev) => !prev);
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: open ? t.textSecondary : t.textTertiary,
|
||||
backgroundColor: open ? t.interactiveHover : "transparent",
|
||||
transition: "background 200ms ease, color 200ms ease",
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<History size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -14,11 +14,15 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
|||
messageRefs,
|
||||
copiedMessageId,
|
||||
onCopyMessage,
|
||||
userName,
|
||||
userAvatarUrl,
|
||||
}: {
|
||||
message: Message;
|
||||
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
copiedMessageId: string | null;
|
||||
onCopyMessage: (message: Message) => void;
|
||||
userName?: string | null;
|
||||
userAvatarUrl?: string | null;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -81,12 +85,52 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
|
|||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "10px",
|
||||
gap: "6px",
|
||||
justifyContent: isUser ? "flex-end" : "flex-start",
|
||||
minHeight: "16px",
|
||||
paddingLeft: isUser ? undefined : "2px",
|
||||
})}
|
||||
>
|
||||
{isUser && (userAvatarUrl || userName) ? (
|
||||
<>
|
||||
{userAvatarUrl ? (
|
||||
<img
|
||||
src={userAvatarUrl}
|
||||
alt=""
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
flexShrink: 0,
|
||||
})}
|
||||
/>
|
||||
) : userName ? (
|
||||
<div
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.borderMedium,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{userName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
) : null}
|
||||
{userName ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ fontWeight: 500 }}>
|
||||
{userName}
|
||||
</LabelXSmall>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{displayFooter ? (
|
||||
<LabelXSmall color={t.textTertiary} $style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}>
|
||||
{displayFooter}
|
||||
|
|
@ -130,6 +174,8 @@ export const MessageList = memo(function MessageList({
|
|||
copiedMessageId,
|
||||
onCopyMessage,
|
||||
thinkingTimerLabel,
|
||||
userName,
|
||||
userAvatarUrl,
|
||||
}: {
|
||||
tab: AgentTab | null | undefined;
|
||||
scrollRef: Ref<HTMLDivElement>;
|
||||
|
|
@ -139,6 +185,8 @@ export const MessageList = memo(function MessageList({
|
|||
copiedMessageId: string | null;
|
||||
onCopyMessage: (message: Message) => void;
|
||||
thinkingTimerLabel: string | null;
|
||||
userName?: string | null;
|
||||
userAvatarUrl?: string | null;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -238,7 +286,16 @@ export const MessageList = memo(function MessageList({
|
|||
return null;
|
||||
}
|
||||
|
||||
return <TranscriptMessageBody message={message} messageRefs={messageRefs} copiedMessageId={copiedMessageId} onCopyMessage={onCopyMessage} />;
|
||||
return (
|
||||
<TranscriptMessageBody
|
||||
message={message}
|
||||
messageRefs={messageRefs}
|
||||
copiedMessageId={copiedMessageId}
|
||||
onCopyMessage={onCopyMessage}
|
||||
userName={userName}
|
||||
userAvatarUrl={userAvatarUrl}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
|
||||
renderThinkingState={() => (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useStyletron } from "baseui";
|
||||
import { FileText, List, MessageSquare, Settings, Terminal as TerminalIcon } from "lucide-react";
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
|
||||
import type { WorkbenchProjectSection, WorkbenchRepo } from "@sandbox-agent/foundry-shared";
|
||||
import { RightSidebar } from "./right-sidebar";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { TerminalPane, type ProcessTab } from "./terminal-pane";
|
||||
import type { Task } from "./view-model";
|
||||
|
||||
type MobileView = "tasks" | "chat" | "changes" | "terminal";
|
||||
const VIEW_ORDER: MobileView[] = ["tasks", "chat", "changes", "terminal"];
|
||||
|
||||
const SWIPE_THRESHOLD = 50;
|
||||
const SWIPE_MAX_VERTICAL = 80;
|
||||
|
||||
interface MobileLayoutProps {
|
||||
workspaceId: string;
|
||||
task: Task;
|
||||
tasks: Task[];
|
||||
projects: WorkbenchProjectSection[];
|
||||
repos: WorkbenchRepo[];
|
||||
selectedNewTaskRepoId: string;
|
||||
onSelectNewTaskRepo: (id: string) => void;
|
||||
onSelectTask: (id: string) => void;
|
||||
onCreateTask: (repoId?: string) => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
onRenameBranch: (id: string) => void;
|
||||
onReorderProjects: (from: number, to: number) => void;
|
||||
taskOrderByProject: Record<string, string[]>;
|
||||
onReorderTasks: (projectId: string, from: number, to: number) => void;
|
||||
// Transcript panel (rendered by parent)
|
||||
transcriptPanel: React.ReactNode;
|
||||
// Diff/file actions
|
||||
onOpenDiff: (path: string) => void;
|
||||
onArchive: () => void;
|
||||
onRevertFile: (path: string) => void;
|
||||
onPublishPr: () => void;
|
||||
// Tab state
|
||||
activeTabId: string | null;
|
||||
// Terminal state
|
||||
terminalProcessTabs: ProcessTab[];
|
||||
onTerminalProcessTabsChange: (tabs: ProcessTab[]) => void;
|
||||
terminalActiveTabId: string | null;
|
||||
onTerminalActiveTabIdChange: (id: string | null) => void;
|
||||
terminalCustomNames: Record<string, string>;
|
||||
onTerminalCustomNamesChange: (names: Record<string, string>) => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export const MobileLayout = memo(function MobileLayout(props: MobileLayoutProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [activeView, setActiveView] = useState<MobileView>("tasks");
|
||||
|
||||
// Swipe gesture tracking
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) {
|
||||
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const start = touchStartRef.current;
|
||||
const touch = e.changedTouches[0];
|
||||
if (!start || !touch) return;
|
||||
touchStartRef.current = null;
|
||||
|
||||
const dx = touch.clientX - start.x;
|
||||
const dy = touch.clientY - start.y;
|
||||
|
||||
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > SWIPE_MAX_VERTICAL) return;
|
||||
|
||||
const currentIndex = VIEW_ORDER.indexOf(activeView);
|
||||
if (dx > 0 && currentIndex > 0) {
|
||||
// Swipe right -> go back
|
||||
setActiveView(VIEW_ORDER[currentIndex - 1]!);
|
||||
} else if (dx < 0 && currentIndex < VIEW_ORDER.length - 1) {
|
||||
// Swipe left -> go forward
|
||||
setActiveView(VIEW_ORDER[currentIndex + 1]!);
|
||||
}
|
||||
},
|
||||
[activeView],
|
||||
);
|
||||
|
||||
const handleSelectTask = useCallback(
|
||||
(id: string) => {
|
||||
props.onSelectTask(id);
|
||||
setActiveView("chat");
|
||||
},
|
||||
[props.onSelectTask],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100dvh",
|
||||
backgroundColor: t.surfacePrimary,
|
||||
paddingTop: "max(var(--safe-area-top), 47px)",
|
||||
overflow: "hidden",
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
})}
|
||||
>
|
||||
{/* Header - show task info when not on tasks view */}
|
||||
{activeView !== "tasks" && <MobileHeader task={props.task} />}
|
||||
|
||||
{/* Content area */}
|
||||
<div
|
||||
className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" })}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{activeView === "tasks" ? (
|
||||
<div className={css({ flex: 1, minHeight: 0, overflow: "auto" })}>
|
||||
<Sidebar
|
||||
projects={props.projects}
|
||||
newTaskRepos={props.repos}
|
||||
selectedNewTaskRepoId={props.selectedNewTaskRepoId}
|
||||
activeId={props.task.id}
|
||||
onSelect={handleSelectTask}
|
||||
onCreate={props.onCreateTask}
|
||||
onSelectNewTaskRepo={props.onSelectNewTaskRepo}
|
||||
onMarkUnread={props.onMarkUnread}
|
||||
onRenameTask={props.onRenameTask}
|
||||
onRenameBranch={props.onRenameBranch}
|
||||
onReorderProjects={props.onReorderProjects}
|
||||
taskOrderByProject={props.taskOrderByProject}
|
||||
onReorderTasks={props.onReorderTasks}
|
||||
onToggleSidebar={undefined}
|
||||
hideSettings
|
||||
panelStyle={{ backgroundColor: t.surfacePrimary }}
|
||||
/>
|
||||
</div>
|
||||
) : activeView === "chat" ? (
|
||||
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>{props.transcriptPanel}</div>
|
||||
) : activeView === "changes" ? (
|
||||
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>
|
||||
<RightSidebar
|
||||
task={props.task}
|
||||
activeTabId={props.activeTabId}
|
||||
onOpenDiff={props.onOpenDiff}
|
||||
onArchive={props.onArchive}
|
||||
onRevertFile={props.onRevertFile}
|
||||
onPublishPr={props.onPublishPr}
|
||||
mobile
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>
|
||||
<TerminalPane
|
||||
workspaceId={props.workspaceId}
|
||||
taskId={props.task.id}
|
||||
hideHeader
|
||||
processTabs={props.terminalProcessTabs}
|
||||
onProcessTabsChange={props.onTerminalProcessTabsChange}
|
||||
activeProcessTabId={props.terminalActiveTabId}
|
||||
onActiveProcessTabIdChange={props.onTerminalActiveTabIdChange}
|
||||
customTabNames={props.terminalCustomNames}
|
||||
onCustomTabNamesChange={props.onTerminalCustomNamesChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom tab bar - always fixed at bottom */}
|
||||
<MobileTabBar
|
||||
activeView={activeView}
|
||||
onViewChange={setActiveView}
|
||||
changesCount={Object.keys(props.task.diffs).length}
|
||||
onOpenSettings={props.onOpenSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function MobileHeader({ task }: { task: Task }) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "6px 12px",
|
||||
gap: "8px",
|
||||
flexShrink: 0,
|
||||
borderBottom: `1px solid ${t.borderDefault}`,
|
||||
minHeight: "40px",
|
||||
})}
|
||||
>
|
||||
<div className={css({ flex: 1, minWidth: 0, overflow: "hidden" })}>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: "14px",
|
||||
fontWeight: 600,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
})}
|
||||
>
|
||||
{task.title}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: "11px",
|
||||
color: t.textTertiary,
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
})}
|
||||
>
|
||||
{task.repoName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileTabBar({
|
||||
activeView,
|
||||
onViewChange,
|
||||
changesCount,
|
||||
onOpenSettings,
|
||||
}: {
|
||||
activeView: MobileView;
|
||||
onViewChange: (view: MobileView) => void;
|
||||
changesCount: number;
|
||||
onOpenSettings?: () => void;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
||||
const tabs: { id: MobileView; icon: React.ReactNode; badge?: number }[] = [
|
||||
{ id: "tasks", icon: <List size={20} /> },
|
||||
{ id: "chat", icon: <MessageSquare size={20} /> },
|
||||
{ id: "changes", icon: <FileText size={20} />, badge: changesCount > 0 ? changesCount : undefined },
|
||||
{ id: "terminal", icon: <TerminalIcon size={20} /> },
|
||||
];
|
||||
|
||||
const iconButtonClass = css({
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "42px",
|
||||
height: "42px",
|
||||
borderRadius: "12px",
|
||||
cursor: "pointer",
|
||||
position: "relative",
|
||||
transition: "background 150ms ease, color 150ms ease",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "8px 16px",
|
||||
paddingBottom: "calc(8px + var(--safe-area-bottom))",
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
{/* Pill container */}
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
backgroundColor: t.surfaceElevated,
|
||||
borderRadius: "16px",
|
||||
padding: "4px",
|
||||
})}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeView === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onViewChange(tab.id)}
|
||||
className={iconButtonClass}
|
||||
style={{
|
||||
background: isActive ? t.interactiveHover : "transparent",
|
||||
color: isActive ? t.textPrimary : t.textTertiary,
|
||||
}}
|
||||
>
|
||||
<div className={css({ position: "relative", display: "flex" })}>
|
||||
{tab.icon}
|
||||
{tab.badge ? (
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
top: "-4px",
|
||||
right: "-8px",
|
||||
backgroundColor: t.accent,
|
||||
color: "#fff",
|
||||
fontSize: "10px",
|
||||
fontWeight: 700,
|
||||
borderRadius: "8px",
|
||||
minWidth: "16px",
|
||||
height: "16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "0 4px",
|
||||
})}
|
||||
>
|
||||
{tab.badge}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{onOpenSettings && (
|
||||
<button type="button" onClick={onOpenSettings} className={iconButtonClass} style={{ color: t.textTertiary }}>
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@ import { LabelSmall } from "baseui/typography";
|
|||
import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, Tooltip, useContextMenu } from "./ui";
|
||||
import { type FileTreeNode, type Task, diffTabId } from "./view-model";
|
||||
|
||||
const FileTree = memo(function FileTree({
|
||||
|
|
@ -96,6 +96,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
onRevertFile,
|
||||
onPublishPr,
|
||||
onToggleSidebar,
|
||||
mobile,
|
||||
}: {
|
||||
task: Task;
|
||||
activeTabId: string | null;
|
||||
|
|
@ -104,6 +105,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
onRevertFile: (path: string) => void;
|
||||
onPublishPr: () => void;
|
||||
onToggleSidebar?: () => void;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -151,128 +153,138 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
|
||||
return (
|
||||
<SPanel $style={{ backgroundColor: t.surfacePrimary, minWidth: 0 }}>
|
||||
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, borderBottom: "none", overflow: "hidden" }}>
|
||||
<div ref={headerRef} className={css({ display: "flex", alignItems: "center", flex: 1, minWidth: 0, justifyContent: "flex-end", gap: "2px" })}>
|
||||
{!isTerminal ? (
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "2px", flexShrink: 1, minWidth: 0 })}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (pullRequestUrl) {
|
||||
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
{!mobile && (
|
||||
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, borderBottom: "none", overflow: "hidden" }}>
|
||||
<div ref={headerRef} className={css({ display: "flex", alignItems: "center", flex: 1, minWidth: 0, justifyContent: "flex-end", gap: "2px" })}>
|
||||
{!isTerminal ? (
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "2px", flexShrink: 1, minWidth: 0 })}>
|
||||
<Tooltip label={pullRequestUrl ? "Open PR" : "Publish PR"} placement="bottom">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (pullRequestUrl) {
|
||||
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
onPublishPr();
|
||||
}}
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>Push</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<Archive size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>Archive</span>}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onToggleSidebar ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggleSidebar}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onToggleSidebar();
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: t.textTertiary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelHeaderBar>
|
||||
onPublishPr();
|
||||
}}
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Push" placement="bottom">
|
||||
<button
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>Push</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Archive" placement="bottom">
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className={css({
|
||||
appearance: "none",
|
||||
WebkitAppearance: "none",
|
||||
background: "none",
|
||||
border: "none",
|
||||
margin: "0",
|
||||
boxSizing: "border-box",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: compact ? "4px 6px" : "4px 10px",
|
||||
borderRadius: "6px",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
color: t.textSecondary,
|
||||
cursor: "pointer",
|
||||
transition: "all 200ms ease",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<Archive size={12} style={{ flexShrink: 0 }} />
|
||||
{!compact && <span>Archive</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{onToggleSidebar ? (
|
||||
<Tooltip label="Toggle sidebar" placement="bottom">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggleSidebar}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onToggleSidebar();
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: t.textTertiary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelHeaderBar>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -280,9 +292,13 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderTop: `1px solid ${t.borderDefault}`,
|
||||
borderRight: `1px solid ${t.borderDefault}`,
|
||||
borderTopRightRadius: "12px",
|
||||
...(mobile
|
||||
? {}
|
||||
: {
|
||||
borderTop: `1px solid ${t.borderDefault}`,
|
||||
borderRight: `1px solid ${t.borderDefault}`,
|
||||
borderTopRightRadius: "12px",
|
||||
}),
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
|
|
@ -296,7 +312,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
height: "41px",
|
||||
minHeight: "41px",
|
||||
flexShrink: 0,
|
||||
borderTopRightRadius: "12px",
|
||||
...(mobile ? {} : { borderTopRightRadius: "12px" }),
|
||||
})}
|
||||
>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
CloudUpload,
|
||||
CreditCard,
|
||||
GitPullRequestDraft,
|
||||
ListChecks,
|
||||
List,
|
||||
LogOut,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
|
|
@ -18,14 +18,166 @@ import {
|
|||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { WorkbenchPresence } from "@sandbox-agent/foundry-shared";
|
||||
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
|
||||
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, Tooltip, useContextMenu } from "./ui";
|
||||
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import type { FoundryTokens } from "../../styles/tokens";
|
||||
|
||||
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||
|
||||
const AWAY_THRESHOLD_MS = 2 * 60 * 1000;
|
||||
|
||||
const PresenceAvatar = memo(function PresenceAvatar({ member, idx, isAway }: { member: WorkbenchPresence; idx: number; isAway: boolean }) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const label = `${member.name}${isAway ? " (away)" : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: "relative",
|
||||
marginLeft: idx > 0 ? "-5px" : "0",
|
||||
flexShrink: 0,
|
||||
":hover > div:last-child": {
|
||||
opacity: 1,
|
||||
transform: "translateX(-50%) translateY(0)",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
border: `1.5px solid ${t.surfacePrimary}`,
|
||||
overflow: "hidden",
|
||||
backgroundColor: t.interactiveHover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: isAway ? 0.35 : 1,
|
||||
filter: isAway ? "grayscale(1)" : "none",
|
||||
transition: "opacity 0.3s, filter 0.3s",
|
||||
})}
|
||||
>
|
||||
{member.avatarUrl ? (
|
||||
<img src={member.avatarUrl} alt={member.name} className={css({ width: "100%", height: "100%", objectFit: "cover", display: "block" })} />
|
||||
) : (
|
||||
<span className={css({ fontSize: "9px", fontWeight: 600, color: t.textTertiary })}>{member.name.charAt(0).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 6px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%) translateY(4px)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "rgba(32, 32, 32, 0.98)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
|
||||
color: "#e0e0e0",
|
||||
fontSize: "10px",
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
opacity: 0,
|
||||
transition: "opacity 150ms ease, transform 150ms ease",
|
||||
zIndex: 300,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const PresenceAvatars = memo(function PresenceAvatars({ presence }: { presence: WorkbenchPresence[] }) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const maxShow = 3;
|
||||
const visible = presence.slice(0, maxShow);
|
||||
const overflow = presence.length - maxShow;
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "6px", paddingLeft: "22px", paddingTop: "2px" })}>
|
||||
<div className={css({ display: "flex", alignItems: "center" })}>
|
||||
{visible.map((member, idx) => {
|
||||
const isAway = now - member.lastSeenAtMs > AWAY_THRESHOLD_MS;
|
||||
return <PresenceAvatar key={member.memberId} member={member} idx={idx} isAway={isAway} />;
|
||||
})}
|
||||
{overflow > 0 && (
|
||||
<div
|
||||
className={css({
|
||||
position: "relative",
|
||||
marginLeft: "-5px",
|
||||
flexShrink: 0,
|
||||
":hover > div:last-child": {
|
||||
opacity: 1,
|
||||
transform: "translateX(-50%) translateY(0)",
|
||||
pointerEvents: "auto",
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "50%",
|
||||
border: `1.5px solid ${t.surfacePrimary}`,
|
||||
backgroundColor: t.interactiveHover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: "8px", fontWeight: 600, color: t.textTertiary })}>+{overflow}</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
position: "absolute",
|
||||
bottom: "calc(100% + 6px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%) translateY(4px)",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "rgba(32, 32, 32, 0.98)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
|
||||
color: "#e0e0e0",
|
||||
fontSize: "10px",
|
||||
fontWeight: 500,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
opacity: 0,
|
||||
transition: "opacity 150ms ease, transform 150ms ease",
|
||||
zIndex: 300,
|
||||
})}
|
||||
>
|
||||
{presence
|
||||
.slice(maxShow)
|
||||
.map((m) => m.name)
|
||||
.join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{presence.length <= 2 && (
|
||||
<span className={css({ fontSize: "10px", color: t.textTertiary, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||
{presence.map((m) => m.name).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function projectInitial(label: string): string {
|
||||
const parts = label.split("/");
|
||||
const name = parts[parts.length - 1] ?? label;
|
||||
|
|
@ -55,13 +207,15 @@ export const Sidebar = memo(function Sidebar({
|
|||
taskOrderByProject,
|
||||
onReorderTasks,
|
||||
onToggleSidebar,
|
||||
hideSettings,
|
||||
panelStyle,
|
||||
}: {
|
||||
projects: ProjectSection[];
|
||||
newTaskRepos: Array<{ id: string; label: string }>;
|
||||
selectedNewTaskRepoId: string;
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onCreate: (repoId?: string) => void;
|
||||
onSelectNewTaskRepo: (repoId: string) => void;
|
||||
onMarkUnread: (id: string) => void;
|
||||
onRenameTask: (id: string) => void;
|
||||
|
|
@ -70,6 +224,8 @@ export const Sidebar = memo(function Sidebar({
|
|||
taskOrderByProject: Record<string, string[]>;
|
||||
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
||||
onToggleSidebar?: () => void;
|
||||
hideSettings?: boolean;
|
||||
panelStyle?: Record<string, string>;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
|
|
@ -90,6 +246,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
// Attach global mousemove/mouseup when dragging
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
document.body.style.cursor = "grabbing";
|
||||
const onMove = (e: MouseEvent) => {
|
||||
// Detect which element is under the cursor using data attributes
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
|
|
@ -132,6 +289,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
return () => {
|
||||
document.body.style.cursor = "";
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
|
@ -152,12 +310,12 @@ export const Sidebar = memo(function Sidebar({
|
|||
}, [createMenuOpen]);
|
||||
|
||||
return (
|
||||
<SPanel>
|
||||
<SPanel $style={panelStyle}>
|
||||
<style>{`
|
||||
[data-project-header]:hover [data-chevron] {
|
||||
[data-project-header] [data-chevron] {
|
||||
display: inline-flex !important;
|
||||
}
|
||||
[data-project-header]:hover [data-project-icon] {
|
||||
[data-project-header] [data-project-icon] {
|
||||
display: none !important;
|
||||
}
|
||||
`}</style>
|
||||
|
|
@ -175,6 +333,43 @@ export const Sidebar = memo(function Sidebar({
|
|||
})}
|
||||
>
|
||||
{onToggleSidebar ? (
|
||||
<Tooltip label="Toggle sidebar" placement="bottom">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggleSidebar}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onToggleSidebar();
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: t.textTertiary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PanelHeaderBar $style={{ backgroundColor: "transparent", borderBottom: "none" }}>
|
||||
<LabelSmall
|
||||
color={t.textPrimary}
|
||||
$style={{ fontWeight: 600, flex: 1, fontSize: "16px", display: "flex", alignItems: "center", gap: "6px", lineHeight: 1 }}
|
||||
>
|
||||
<List size={16} />
|
||||
Tasks
|
||||
</LabelSmall>
|
||||
{!import.meta.env.VITE_DESKTOP && onToggleSidebar ? (
|
||||
<Tooltip label="Toggle sidebar" placement="bottom">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
|
|
@ -197,84 +392,53 @@ export const Sidebar = memo(function Sidebar({
|
|||
>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PanelHeaderBar $style={{ backgroundColor: "transparent", borderBottom: "none" }}>
|
||||
<LabelSmall
|
||||
color={t.textPrimary}
|
||||
$style={{ fontWeight: 500, flex: 1, fontSize: "13px", display: "flex", alignItems: "center", gap: "6px", lineHeight: 1 }}
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks
|
||||
</LabelSmall>
|
||||
{!import.meta.env.VITE_DESKTOP && onToggleSidebar ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onToggleSidebar}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onToggleSidebar();
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
color: t.textTertiary,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<div ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
<Tooltip label="New task" placement="bottom">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-disabled={newTaskRepos.length === 0}
|
||||
onClick={() => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate();
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (newTaskRepos.length === 0) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
if (newTaskRepos.length === 1) {
|
||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||
onCreate(newTaskRepos[0]!.id);
|
||||
} else {
|
||||
setCreateMenuOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||
color: t.textPrimary,
|
||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "background 200ms ease",
|
||||
flexShrink: 0,
|
||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} style={{ display: "block" }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{createMenuOpen && newTaskRepos.length > 1 ? (
|
||||
<div
|
||||
className={css({
|
||||
|
|
@ -303,7 +467,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
onClick={() => {
|
||||
onSelectNewTaskRepo(repo.id);
|
||||
setCreateMenuOpen(false);
|
||||
onCreate();
|
||||
onCreate(repo.id);
|
||||
}}
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -442,9 +606,31 @@ export const Sidebar = memo(function Sidebar({
|
|||
>
|
||||
{projectInitial(project.label)}
|
||||
</span>
|
||||
<span className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })} data-chevron>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedProjects((current) => ({
|
||||
...current,
|
||||
[project.id]: !current[project.id],
|
||||
}));
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={css({
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "none",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
})}
|
||||
data-chevron
|
||||
>
|
||||
{isCollapsed ? <ChevronDown size={12} color={t.textTertiary} /> : <ChevronUp size={12} color={t.textTertiary} />}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<LabelSmall
|
||||
color={t.textSecondary}
|
||||
|
|
@ -468,7 +654,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
e.stopPropagation();
|
||||
setHoveredProjectId(null);
|
||||
onSelectNewTaskRepo(project.id);
|
||||
onCreate();
|
||||
onCreate(project.id);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className={css({
|
||||
|
|
@ -543,7 +729,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
position: "relative",
|
||||
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
||||
opacity: isTaskBeingDragged ? 0.4 : 1,
|
||||
cursor: "pointer",
|
||||
cursor: drag?.type === "task" ? "grabbing" : "pointer",
|
||||
transition: "all 150ms ease",
|
||||
"::before": {
|
||||
content: '""',
|
||||
|
|
@ -607,6 +793,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
{formatRelativeAge(task.updatedAtMs)}
|
||||
</LabelXSmall>
|
||||
</div>
|
||||
{task.presence.length > 0 && <PresenceAvatars presence={task.presence} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -658,7 +845,7 @@ export const Sidebar = memo(function Sidebar({
|
|||
/>
|
||||
</div>
|
||||
</ScrollBody>
|
||||
<SidebarFooter />
|
||||
{!hideSettings && <SidebarFooter />}
|
||||
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
|
||||
</SPanel>
|
||||
);
|
||||
|
|
@ -945,34 +1132,36 @@ function SidebarFooter() {
|
|||
</div>
|
||||
) : null}
|
||||
<div className={css({ padding: "8px" })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((prev) => {
|
||||
if (prev) setWorkspaceFlyoutOpen(false);
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: open ? t.interactiveHover : "transparent",
|
||||
color: open ? t.textPrimary : t.textTertiary,
|
||||
cursor: "pointer",
|
||||
transition: "all 160ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textSecondary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
<Tooltip label="Settings" placement="right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen((prev) => {
|
||||
if (prev) setWorkspaceFlyoutOpen(false);
|
||||
return !prev;
|
||||
});
|
||||
}}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "28px",
|
||||
height: "28px",
|
||||
borderRadius: "6px",
|
||||
border: "none",
|
||||
background: open ? t.interactiveHover : "transparent",
|
||||
color: open ? t.textPrimary : t.textTertiary,
|
||||
cursor: "pointer",
|
||||
transition: "all 160ms ease",
|
||||
":hover": {
|
||||
backgroundColor: t.interactiveHover,
|
||||
color: t.textSecondary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Settings size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { memo } from "react";
|
||||
import { useStyletron } from "baseui";
|
||||
import { LabelXSmall } from "baseui/typography";
|
||||
import { FileCode, Plus, X } from "lucide-react";
|
||||
import { FileCode, Plus, SquareTerminal, X } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
|
||||
import { diffTabId, fileName, type Task } from "./view-model";
|
||||
import { ContextMenuOverlay, TabAvatar, Tooltip, useContextMenu } from "./ui";
|
||||
import { diffTabId, fileName, terminalTabId, type Task } from "./view-model";
|
||||
|
||||
export const TabStrip = memo(function TabStrip({
|
||||
task,
|
||||
|
|
@ -22,6 +22,8 @@ export const TabStrip = memo(function TabStrip({
|
|||
onCloseTab,
|
||||
onCloseDiffTab,
|
||||
onAddTab,
|
||||
terminalTabOpen,
|
||||
onCloseTerminalTab,
|
||||
sidebarCollapsed,
|
||||
}: {
|
||||
task: Task;
|
||||
|
|
@ -38,6 +40,8 @@ export const TabStrip = memo(function TabStrip({
|
|||
onCloseTab: (tabId: string) => void;
|
||||
onCloseDiffTab: (path: string) => void;
|
||||
onAddTab: () => void;
|
||||
terminalTabOpen?: boolean;
|
||||
onCloseTerminalTab?: () => void;
|
||||
sidebarCollapsed?: boolean;
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
|
|
@ -216,21 +220,71 @@ export const TabStrip = memo(function TabStrip({
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={onAddTab}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 10px",
|
||||
cursor: "pointer",
|
||||
opacity: 0.4,
|
||||
lineHeight: 0,
|
||||
":hover": { opacity: 0.7 },
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} color={t.textTertiary} />
|
||||
</div>
|
||||
{terminalTabOpen
|
||||
? (() => {
|
||||
const tabId = terminalTabId();
|
||||
const isActive = tabId === activeTabId;
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
onClick={() => onSwitchTab(tabId)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
onCloseTerminalTab?.();
|
||||
}
|
||||
}}
|
||||
data-tab
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "4px 12px",
|
||||
marginTop: "6px",
|
||||
marginBottom: "6px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: isActive ? t.interactiveHover : "transparent",
|
||||
cursor: "pointer",
|
||||
transition: "color 200ms ease, background-color 200ms ease",
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textPrimary, backgroundColor: isActive ? t.interactiveHover : t.interactiveSubtle },
|
||||
})}
|
||||
>
|
||||
<SquareTerminal size={12} color={isActive ? t.textPrimary : t.textSecondary} />
|
||||
<LabelXSmall color={isActive ? t.textPrimary : t.textSecondary} $style={{ fontWeight: 500 }}>
|
||||
Terminal
|
||||
</LabelXSmall>
|
||||
<X
|
||||
size={11}
|
||||
color={t.textTertiary}
|
||||
data-tab-close
|
||||
className={css({ cursor: "pointer", opacity: 0 })}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCloseTerminalTab?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
<Tooltip label="New session" placement="bottom">
|
||||
<div
|
||||
onClick={onAddTab}
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 10px",
|
||||
cursor: "pointer",
|
||||
opacity: 0.4,
|
||||
lineHeight: 0,
|
||||
":hover": { opacity: 0.7 },
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<Plus size={14} color={t.textTertiary} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ProcessTerminal } from "@sandbox-agent/react";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useStyletron } from "baseui";
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react";
|
||||
import { ArrowUpLeft, ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { backendClient } from "../../lib/backend";
|
||||
|
|
@ -12,11 +12,21 @@ interface TerminalPaneProps {
|
|||
workspaceId: string;
|
||||
taskId: string | null;
|
||||
isExpanded?: boolean;
|
||||
hideHeader?: boolean;
|
||||
onExpand?: () => void;
|
||||
onCollapse?: () => void;
|
||||
onStartResize?: (e: React.PointerEvent) => void;
|
||||
onOpenTerminalTab?: () => void;
|
||||
processTabs?: ProcessTab[];
|
||||
onProcessTabsChange?: (tabs: ProcessTab[]) => void;
|
||||
activeProcessTabId?: string | null;
|
||||
onActiveProcessTabIdChange?: (id: string | null) => void;
|
||||
customTabNames?: Record<string, string>;
|
||||
onCustomTabNamesChange?: (names: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
export type { ProcessTab };
|
||||
|
||||
interface ProcessTab {
|
||||
id: string;
|
||||
processId: string;
|
||||
|
|
@ -94,15 +104,66 @@ function HeaderIconButton({
|
|||
);
|
||||
}
|
||||
|
||||
export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) {
|
||||
export function TerminalPane({
|
||||
workspaceId,
|
||||
taskId,
|
||||
isExpanded,
|
||||
hideHeader,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onStartResize,
|
||||
onOpenTerminalTab,
|
||||
processTabs: controlledProcessTabs,
|
||||
onProcessTabsChange,
|
||||
activeProcessTabId: controlledActiveTabId,
|
||||
onActiveProcessTabIdChange,
|
||||
customTabNames: controlledCustomTabNames,
|
||||
onCustomTabNamesChange,
|
||||
}: TerminalPaneProps) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [processTabs, setProcessTabs] = useState<ProcessTab[]>([]);
|
||||
const [internalActiveTabId, setInternalActiveTabId] = useState<string | null>(null);
|
||||
const [internalProcessTabs, setInternalProcessTabs] = useState<ProcessTab[]>([]);
|
||||
const [creatingProcess, setCreatingProcess] = useState(false);
|
||||
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);
|
||||
const [terminalClient, setTerminalClient] = useState<SandboxAgent | null>(null);
|
||||
const [customTabNames, setCustomTabNames] = useState<Record<string, string>>({});
|
||||
const [internalCustomTabNames, setInternalCustomTabNames] = useState<Record<string, string>>({});
|
||||
|
||||
const processTabs = controlledProcessTabs ?? internalProcessTabs;
|
||||
const setProcessTabs = useCallback(
|
||||
(update: ProcessTab[] | ((prev: ProcessTab[]) => ProcessTab[])) => {
|
||||
if (onProcessTabsChange) {
|
||||
const next = typeof update === "function" ? update(controlledProcessTabs ?? []) : update;
|
||||
onProcessTabsChange(next);
|
||||
} else {
|
||||
setInternalProcessTabs(update);
|
||||
}
|
||||
},
|
||||
[onProcessTabsChange, controlledProcessTabs],
|
||||
);
|
||||
const activeTabId = controlledActiveTabId !== undefined ? controlledActiveTabId : internalActiveTabId;
|
||||
const setActiveTabId = useCallback(
|
||||
(id: string | null) => {
|
||||
if (onActiveProcessTabIdChange) {
|
||||
onActiveProcessTabIdChange(id);
|
||||
} else {
|
||||
setInternalActiveTabId(id);
|
||||
}
|
||||
},
|
||||
[onActiveProcessTabIdChange],
|
||||
);
|
||||
const customTabNames = controlledCustomTabNames ?? internalCustomTabNames;
|
||||
const setCustomTabNames = useCallback(
|
||||
(update: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => {
|
||||
if (onCustomTabNamesChange) {
|
||||
const next = typeof update === "function" ? update(controlledCustomTabNames ?? {}) : update;
|
||||
onCustomTabNamesChange(next);
|
||||
} else {
|
||||
setInternalCustomTabNames(update);
|
||||
}
|
||||
},
|
||||
[onCustomTabNamesChange, controlledCustomTabNames],
|
||||
);
|
||||
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
|
|
@ -135,7 +196,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
setProcessTabs((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(d.fromIdx, 1);
|
||||
next.splice(d.overIdx!, 0, moved);
|
||||
if (moved) next.splice(d.overIdx!, 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
|
@ -306,43 +367,48 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
};
|
||||
}, [terminalClient]);
|
||||
|
||||
// Only reset on taskId change when using internal (uncontrolled) state.
|
||||
// When controlled, the parent (MockLayout) owns per-task state via keyed records.
|
||||
useEffect(() => {
|
||||
setActiveTabId(null);
|
||||
setProcessTabs([]);
|
||||
}, [taskId]);
|
||||
if (!controlledProcessTabs) {
|
||||
setActiveTabId(null);
|
||||
setProcessTabs([]);
|
||||
}
|
||||
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const processes = processesQuery.data?.processes ?? [];
|
||||
|
||||
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
|
||||
setProcessTabs((current) => {
|
||||
const existing = current.find((tab) => tab.processId === process.id);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextTab: ProcessTab = {
|
||||
id: `terminal:${process.id}`,
|
||||
processId: process.id,
|
||||
title: formatProcessTabTitle(process, current.length + 1),
|
||||
};
|
||||
setActiveTabId(nextTab.id);
|
||||
return [...current, nextTab];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTerminalTab = useCallback((tabId: string) => {
|
||||
setProcessTabs((current) => {
|
||||
const next = current.filter((tab) => tab.id !== tabId);
|
||||
setActiveTabId((currentActive) => {
|
||||
if (currentActive === tabId) {
|
||||
return next.length > 0 ? next[next.length - 1]!.id : null;
|
||||
const openTerminalTab = useCallback(
|
||||
(process: SandboxProcessRecord) => {
|
||||
setProcessTabs((current) => {
|
||||
const existing = current.find((tab) => tab.processId === process.id);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
return current;
|
||||
}
|
||||
return currentActive;
|
||||
|
||||
const nextTab: ProcessTab = {
|
||||
id: `terminal:${process.id}`,
|
||||
processId: process.id,
|
||||
title: formatProcessTabTitle(process, current.length + 1),
|
||||
};
|
||||
setActiveTabId(nextTab.id);
|
||||
return [...current, nextTab];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
},
|
||||
[setProcessTabs, setActiveTabId],
|
||||
);
|
||||
|
||||
const closeTerminalTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const next = processTabs.filter((tab) => tab.id !== tabId);
|
||||
setProcessTabs(next);
|
||||
if (activeTabId === tabId) {
|
||||
setActiveTabId(next.length > 0 ? next[next.length - 1]!.id : null);
|
||||
}
|
||||
},
|
||||
[processTabs, activeTabId, setProcessTabs, setActiveTabId],
|
||||
);
|
||||
|
||||
const spawnTerminal = useCallback(async () => {
|
||||
if (!activeSandbox?.sandboxId) {
|
||||
|
|
@ -527,25 +593,27 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
overflow: "hidden",
|
||||
})}
|
||||
>
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
onPointerDown={onStartResize}
|
||||
className={css({
|
||||
height: "3px",
|
||||
flexShrink: 0,
|
||||
cursor: "ns-resize",
|
||||
position: "relative",
|
||||
"::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "-2px",
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "7px",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{/* Full-width header bar */}
|
||||
{/* Resize handle — hidden when in tab view */}
|
||||
{!hideHeader && (
|
||||
<div
|
||||
onPointerDown={onStartResize}
|
||||
className={css({
|
||||
height: "3px",
|
||||
flexShrink: 0,
|
||||
cursor: "ns-resize",
|
||||
position: "relative",
|
||||
"::before": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "-2px",
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: "7px",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{/* Header bar — in tab view, only show action buttons (no title/expand/chevron) */}
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
|
|
@ -554,13 +622,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
minHeight: "39px",
|
||||
maxHeight: "39px",
|
||||
padding: "0 14px",
|
||||
borderTop: `1px solid ${t.borderDefault}`,
|
||||
borderTop: hideHeader ? "none" : `1px solid ${t.borderDefault}`,
|
||||
backgroundColor: t.surfacePrimary,
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
<SquareTerminal size={14} color={t.textTertiary} />
|
||||
<span className={css({ fontSize: "12px", fontWeight: 600, color: t.textSecondary })}>Terminal</span>
|
||||
{!hideHeader && (
|
||||
<>
|
||||
<SquareTerminal size={14} color={t.textTertiary} />
|
||||
<span className={css({ fontSize: "12px", fontWeight: 600, color: t.textSecondary })}>Terminal</span>
|
||||
</>
|
||||
)}
|
||||
<div className={css({ flex: 1 })} />
|
||||
<div className={css({ display: "flex", alignItems: "center", gap: "2px" })}>
|
||||
<HeaderIconButton
|
||||
|
|
@ -585,14 +657,21 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
|||
>
|
||||
<Trash2 size={13} />
|
||||
</HeaderIconButton>
|
||||
<HeaderIconButton css={css} t={t} label={isExpanded ? "Collapse terminal" : "Expand terminal"} onClick={isExpanded ? onCollapse : onExpand}>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</HeaderIconButton>
|
||||
{!hideHeader && onOpenTerminalTab ? (
|
||||
<HeaderIconButton css={css} t={t} label="Open terminal in tab" onClick={onOpenTerminalTab}>
|
||||
<ArrowUpLeft size={13} />
|
||||
</HeaderIconButton>
|
||||
) : null}
|
||||
{!hideHeader && (
|
||||
<HeaderIconButton css={css} t={t} label={isExpanded ? "Collapse terminal" : "Expand terminal"} onClick={isExpanded ? onCollapse : onExpand}>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
|
||||
</HeaderIconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column body: terminal left, list right — hidden when no tabs */}
|
||||
{processTabs.length > 0 && (
|
||||
{/* Two-column body: terminal left, list right — visible when expanded or when tabs exist */}
|
||||
{(processTabs.length > 0 || hideHeader) && (
|
||||
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "row" })}>
|
||||
{/* Left: terminal content */}
|
||||
<div className={css({ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" })}>{renderBody()}</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { LabelSmall } from "baseui/typography";
|
|||
import { Clock, PanelLeft, PanelRight } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { PanelHeaderBar } from "./ui";
|
||||
import { PanelHeaderBar, Tooltip } from "./ui";
|
||||
import { type AgentTab, type Task } from "./view-model";
|
||||
|
||||
export const TranscriptHeader = memo(function TranscriptHeader({
|
||||
|
|
@ -50,25 +50,27 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
return (
|
||||
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, borderBottom: "none", paddingLeft: needsTrafficLightInset ? "74px" : "14px" }}>
|
||||
{sidebarCollapsed && onToggleSidebar ? (
|
||||
<div
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
onClick={onToggleSidebar}
|
||||
onMouseEnter={onSidebarPeekStart}
|
||||
onMouseLeave={onSidebarPeekEnd}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
<Tooltip label="Toggle sidebar" placement="bottom">
|
||||
<div
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
onClick={onToggleSidebar}
|
||||
onMouseEnter={onSidebarPeekStart}
|
||||
onMouseLeave={onSidebarPeekEnd}
|
||||
>
|
||||
<PanelLeft size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{editingField === "title" ? (
|
||||
<input
|
||||
|
|
@ -190,23 +192,25 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
|||
<span>{task.minutesUsed ?? 0} min used</span>
|
||||
</div>
|
||||
{rightSidebarCollapsed && onToggleRightSidebar ? (
|
||||
<div
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
onClick={onToggleRightSidebar}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
<Tooltip label="Toggle changes" placement="bottom">
|
||||
<div
|
||||
className={css({
|
||||
width: "26px",
|
||||
height: "26px",
|
||||
borderRadius: "6px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
cursor: "pointer",
|
||||
color: t.textTertiary,
|
||||
flexShrink: 0,
|
||||
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
|
||||
})}
|
||||
onClick={onToggleRightSidebar}
|
||||
>
|
||||
<PanelRight size={14} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</PanelHeaderBar>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, useCallback, useEffect, useState, type MouseEvent } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { styled, useStyletron } from "baseui";
|
||||
import { GitPullRequest, GitPullRequestDraft } from "lucide-react";
|
||||
|
||||
|
|
@ -210,6 +211,115 @@ export const ScrollBody = styled("div", () => ({
|
|||
flexDirection: "column" as const,
|
||||
}));
|
||||
|
||||
export const Tooltip = memo(function Tooltip({
|
||||
label,
|
||||
children,
|
||||
placement = "bottom",
|
||||
}: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
const el = triggerRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
let top: number;
|
||||
let left: number;
|
||||
if (placement === "bottom") {
|
||||
top = rect.bottom + 6;
|
||||
left = rect.left + rect.width / 2;
|
||||
} else if (placement === "top") {
|
||||
top = rect.top - 6;
|
||||
left = rect.left + rect.width / 2;
|
||||
} else if (placement === "left") {
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.left - 6;
|
||||
} else {
|
||||
top = rect.top + rect.height / 2;
|
||||
left = rect.right + 6;
|
||||
}
|
||||
setPos({ top, left });
|
||||
}, [placement]);
|
||||
|
||||
const hide = useCallback(() => setPos(null), []);
|
||||
|
||||
// Clamp tooltip position after it renders so it stays within the viewport
|
||||
useEffect(() => {
|
||||
if (!pos) return;
|
||||
const tip = tooltipRef.current;
|
||||
if (!tip) return;
|
||||
const tipRect = tip.getBoundingClientRect();
|
||||
const pad = 8;
|
||||
let adjustLeft = 0;
|
||||
let adjustTop = 0;
|
||||
if (tipRect.right > window.innerWidth - pad) {
|
||||
adjustLeft = window.innerWidth - pad - tipRect.right;
|
||||
}
|
||||
if (tipRect.left < pad) {
|
||||
adjustLeft = pad - tipRect.left;
|
||||
}
|
||||
if (tipRect.bottom > window.innerHeight - pad) {
|
||||
adjustTop = window.innerHeight - pad - tipRect.bottom;
|
||||
}
|
||||
if (tipRect.top < pad) {
|
||||
adjustTop = pad - tipRect.top;
|
||||
}
|
||||
if (adjustLeft !== 0 || adjustTop !== 0) {
|
||||
setPos((prev) => prev && { top: prev.top + adjustTop, left: prev.left + adjustLeft });
|
||||
}
|
||||
}, [pos]);
|
||||
|
||||
const transform =
|
||||
placement === "bottom"
|
||||
? "translateX(-50%)"
|
||||
: placement === "top"
|
||||
? "translateX(-50%) translateY(-100%)"
|
||||
: placement === "left"
|
||||
? "translateX(-100%) translateY(-50%)"
|
||||
: "translateY(-50%)";
|
||||
|
||||
return (
|
||||
<div ref={triggerRef} onMouseEnter={show} onMouseLeave={hide} className={css({ display: "inline-flex" })}>
|
||||
{children}
|
||||
{pos &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={css({
|
||||
position: "fixed",
|
||||
top: `${pos.top}px`,
|
||||
left: `${pos.left}px`,
|
||||
transform,
|
||||
zIndex: 99999,
|
||||
pointerEvents: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "6px",
|
||||
backgroundColor: "rgba(32, 32, 32, 0.98)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
|
||||
color: "#e0e0e0",
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
lineHeight: "1.3",
|
||||
whiteSpace: "nowrap",
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const HEADER_HEIGHT = "42px";
|
||||
export const PROMPT_TEXTAREA_MIN_HEIGHT = 56;
|
||||
export const PROMPT_TEXTAREA_MAX_HEIGHT = 100;
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ export function providerAgent(provider: string): AgentKind {
|
|||
}
|
||||
|
||||
const DIFF_PREFIX = "diff:";
|
||||
const TERMINAL_PREFIX = "terminal:";
|
||||
|
||||
export function isDiffTab(id: string): boolean {
|
||||
return id.startsWith(DIFF_PREFIX);
|
||||
|
|
@ -115,6 +116,14 @@ export function diffTabId(path: string): string {
|
|||
return `${DIFF_PREFIX}${path}`;
|
||||
}
|
||||
|
||||
export function isTerminalTab(id: string): boolean {
|
||||
return id.startsWith(TERMINAL_PREFIX);
|
||||
}
|
||||
|
||||
export function terminalTabId(): string {
|
||||
return `${TERMINAL_PREFIX}main`;
|
||||
}
|
||||
|
||||
export function fileName(path: string): string {
|
||||
return path.split("/").pop() ?? path;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type FoundryBillingPlanId, type FoundryOrganization, type FoundryOrganizationMember, type FoundryUser } from "@sandbox-agent/foundry-shared";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users } from "lucide-react";
|
||||
import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users, Volume2 } from "lucide-react";
|
||||
import { NOTIFICATION_SOUND_OPTIONS, previewNotificationSound, useNotificationSound } from "../lib/notification-sound";
|
||||
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||
import { isMockFrontendClient } from "../lib/env";
|
||||
import { useIsMobile } from "../lib/platform";
|
||||
import { useColorMode, useFoundryTokens } from "../app/theme";
|
||||
import type { FoundryTokens } from "../styles/tokens";
|
||||
import { appSurfaceStyle, primaryButtonStyle, secondaryButtonStyle, subtleButtonStyle, cardStyle, badgeStyle, inputStyle } from "../styles/shared-styles";
|
||||
|
|
@ -124,7 +126,7 @@ function statusBadge(t: FoundryTokens, organization: FoundryOrganization) {
|
|||
|
||||
function githubBadge(t: FoundryTokens, organization: FoundryOrganization) {
|
||||
if (organization.github.installationStatus === "connected") {
|
||||
return <span style={badgeStyle(t, "rgba(46, 160, 67, 0.16)", "#b7f0c3")}>GitHub connected</span>;
|
||||
return <span style={badgeStyle(t, "rgba(46, 160, 67, 0.16)", "#1a7f37")}>GitHub connected</span>;
|
||||
}
|
||||
if (organization.github.installationStatus === "reconnect_required") {
|
||||
return <span style={badgeStyle(t, "rgba(255, 193, 7, 0.18)", "#ffe6a6")}>Reconnect required</span>;
|
||||
|
|
@ -164,9 +166,42 @@ function MemberRow({ member }: { member: FoundryOrganizationMember }) {
|
|||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: "12px" }}>{member.name}</div>
|
||||
<div style={{ color: t.textSecondary, fontSize: "11px" }}>{member.email}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", overflow: "hidden" }}>
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt={member.name}
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
backgroundColor: t.interactiveHover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
}}
|
||||
>
|
||||
{member.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ overflow: "hidden" }}>
|
||||
<div style={{ fontWeight: 500, fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{member.name}</div>
|
||||
<div style={{ color: t.textSecondary, fontSize: "11px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: t.textSecondary, fontSize: "12px", textTransform: "capitalize" }}>{member.role}</div>
|
||||
<div>
|
||||
|
|
@ -551,16 +586,130 @@ function SettingsLayout({
|
|||
const user = activeMockUser(snapshot);
|
||||
const navigate = useNavigate();
|
||||
const t = useFoundryTokens();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const navSections: Array<{ section: SettingsSection; icon: React.ReactNode; label: string }> = [
|
||||
{ section: "settings", icon: <Settings size={13} />, label: "Settings" },
|
||||
{ section: "members", icon: <Users size={13} />, label: "Members" },
|
||||
{ section: "billing", icon: <CreditCard size={13} />, label: "Billing & Invoices" },
|
||||
{ section: "billing", icon: <CreditCard size={13} />, label: "Billing" },
|
||||
{ section: "docs", icon: <FileText size={13} />, label: "Docs" },
|
||||
];
|
||||
|
||||
const goBack = () => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
})();
|
||||
};
|
||||
|
||||
const handleNavClick = (item: (typeof navSections)[0]) => {
|
||||
if (item.section === "billing") {
|
||||
void navigate({ to: billingPath(organization) });
|
||||
} else if (onSectionChange) {
|
||||
onSectionChange(item.section);
|
||||
} else {
|
||||
void navigate({ to: settingsPath(organization) });
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...appSurfaceStyle(t),
|
||||
height: "100dvh",
|
||||
maxHeight: "100dvh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingTop: "max(var(--safe-area-top), 47px)",
|
||||
}}
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
style={{
|
||||
...subtleButtonStyle(t),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: "15px", fontWeight: 600 }}>{organization.settings.displayName}</div>
|
||||
<div style={{ fontSize: "11px", color: t.textMuted }}>{planCatalog[organization.billing.planId]?.label ?? "Free"} Plan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile tab strip */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
gap: "2px",
|
||||
padding: "6px 12px",
|
||||
overflowX: "auto",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
}}
|
||||
>
|
||||
{navSections.map((item) => {
|
||||
const isActive = activeSection === item.section;
|
||||
return (
|
||||
<button
|
||||
key={item.section}
|
||||
type="button"
|
||||
onClick={() => handleNavClick(item)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "5px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
border: "none",
|
||||
background: isActive ? t.interactiveHover : "transparent",
|
||||
color: isActive ? t.textPrimary : t.textMuted,
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
whiteSpace: "nowrap",
|
||||
fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 16px 40px" }}>
|
||||
<div style={{ maxWidth: "560px" }}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={appSurfaceStyle(t)}>
|
||||
<div style={{ ...appSurfaceStyle(t), height: "100dvh", maxHeight: "100dvh" }}>
|
||||
<DesktopDragRegion />
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||
{/* Left nav */}
|
||||
|
|
@ -579,12 +728,7 @@ function SettingsLayout({
|
|||
{/* Back to workspace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.selectOrganization(organization.id);
|
||||
await navigate({ to: workspacePath(organization) });
|
||||
})();
|
||||
}}
|
||||
onClick={goBack}
|
||||
style={{
|
||||
...subtleButtonStyle(t),
|
||||
display: "flex",
|
||||
|
|
@ -612,15 +756,7 @@ function SettingsLayout({
|
|||
icon={item.icon}
|
||||
label={item.label}
|
||||
active={activeSection === item.section}
|
||||
onClick={() => {
|
||||
if (item.section === "billing") {
|
||||
void navigate({ to: billingPath(organization) });
|
||||
} else if (onSectionChange) {
|
||||
onSectionChange(item.section);
|
||||
} else {
|
||||
void navigate({ to: settingsPath(organization) });
|
||||
}
|
||||
}}
|
||||
onClick={() => handleNavClick(item)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -692,6 +828,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
|
|||
|
||||
<AppearanceSection />
|
||||
|
||||
<NotificationSoundSection />
|
||||
|
||||
<SettingsContentSection
|
||||
title="GitHub"
|
||||
description={`Connected as ${organization.github.connectedAccount}. ${organization.github.importedRepoCount} repos imported.`}
|
||||
|
|
@ -1090,6 +1228,7 @@ export function MockAccountSettingsPage() {
|
|||
const user = activeMockUser(snapshot);
|
||||
const navigate = useNavigate();
|
||||
const t = useFoundryTokens();
|
||||
const isMobile = useIsMobile();
|
||||
const [name, setName] = useState(user?.name ?? "");
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
|
||||
|
|
@ -1098,8 +1237,168 @@ export function MockAccountSettingsPage() {
|
|||
setEmail(user?.email ?? "");
|
||||
}, [user?.name, user?.email]);
|
||||
|
||||
const accountContent = (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 2px", fontSize: "15px", fontWeight: 600 }}>Account</h1>
|
||||
<p style={{ margin: 0, fontSize: "11px", color: t.textMuted }}>Manage your personal account settings.</p>
|
||||
</div>
|
||||
|
||||
<SettingsContentSection title="Profile">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "4px" }}>
|
||||
{user?.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt={user.name}
|
||||
style={{
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
borderRadius: "50%",
|
||||
objectFit: "cover",
|
||||
border: `1px solid ${t.borderSubtle}`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "48px",
|
||||
height: "48px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: t.interactiveHover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
border: `1px solid ${t.borderSubtle}`,
|
||||
}}
|
||||
>
|
||||
{(user?.name ?? "U").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontSize: "13px", fontWeight: 600 }}>{user?.name ?? "User"}</div>
|
||||
<div style={{ fontSize: "11px", color: t.textMuted }}>@{user?.githubLogin ?? ""}</div>
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Display name</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} style={inputStyle(t)} />
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Email</span>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle(t)} />
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>GitHub</span>
|
||||
<input value={`@${user?.githubLogin ?? ""}`} readOnly style={{ ...inputStyle(t), color: t.textMuted }} />
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" style={primaryButtonStyle(t)}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Sessions" description="Manage your active sessions across devices.">
|
||||
<SettingsRow label="Current session" description="This device — signed in via GitHub OAuth." />
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Sign out" description="Sign out of Foundry on this device.">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.signOut();
|
||||
await navigate({ to: "/signin" });
|
||||
})();
|
||||
}}
|
||||
style={{ ...secondaryButtonStyle(t), display: "inline-flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<LogOut size={12} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Danger zone">
|
||||
<SettingsRow
|
||||
label="Delete account"
|
||||
description="Permanently delete your account and all data."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...secondaryButtonStyle(t),
|
||||
borderColor: "rgba(255, 110, 110, 0.24)",
|
||||
color: t.statusError,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsContentSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...appSurfaceStyle(t),
|
||||
height: "100dvh",
|
||||
maxHeight: "100dvh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingTop: "max(var(--safe-area-top), 47px)",
|
||||
}}
|
||||
>
|
||||
{/* Mobile header */}
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
padding: "8px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate({ to: "/" })}
|
||||
style={{
|
||||
...subtleButtonStyle(t),
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
padding: 0,
|
||||
borderRadius: "8px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<div style={{ fontSize: "15px", fontWeight: 600 }}>Account</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "20px 16px 40px" }}>
|
||||
<div style={{ maxWidth: "560px" }}>{accountContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={appSurfaceStyle(t)}>
|
||||
<div style={{ ...appSurfaceStyle(t), height: "100dvh", maxHeight: "100dvh" }}>
|
||||
<DesktopDragRegion />
|
||||
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
|
||||
{/* Left nav */}
|
||||
|
|
@ -1131,9 +1430,32 @@ export function MockAccountSettingsPage() {
|
|||
Back to workspace
|
||||
</button>
|
||||
|
||||
<div style={{ padding: "2px 10px 12px", display: "flex", flexDirection: "column", gap: "1px" }}>
|
||||
<span style={{ fontSize: "12px", fontWeight: 600 }}>{user?.name ?? "User"}</span>
|
||||
<span style={{ fontSize: "10px", color: t.textMuted }}>{user?.email ?? ""}</span>
|
||||
<div style={{ padding: "2px 10px 12px", display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
{user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.name} style={{ width: "24px", height: "24px", borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
borderRadius: "50%",
|
||||
flexShrink: 0,
|
||||
backgroundColor: t.interactiveHover,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: t.textSecondary,
|
||||
}}
|
||||
>
|
||||
{(user?.name ?? "U").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1px", overflow: "hidden" }}>
|
||||
<span style={{ fontSize: "12px", fontWeight: 600 }}>{user?.name ?? "User"}</span>
|
||||
<span style={{ fontSize: "10px", color: t.textMuted }}>{user?.email ?? ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsNavItem icon={<Settings size={13} />} label="General" active onClick={() => {}} />
|
||||
|
|
@ -1141,77 +1463,7 @@ export function MockAccountSettingsPage() {
|
|||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "80px 36px 40px" }}>
|
||||
<div style={{ maxWidth: "560px" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
|
||||
<div>
|
||||
<h1 style={{ margin: "0 0 2px", fontSize: "15px", fontWeight: 600 }}>Account</h1>
|
||||
<p style={{ margin: 0, fontSize: "11px", color: t.textMuted }}>Manage your personal account settings.</p>
|
||||
</div>
|
||||
|
||||
<SettingsContentSection title="Profile">
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Display name</span>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} style={inputStyle(t)} />
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Email</span>
|
||||
<input value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle(t)} />
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: "4px" }}>
|
||||
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>GitHub</span>
|
||||
<input value={`@${user?.githubLogin ?? ""}`} readOnly style={{ ...inputStyle(t), color: t.textMuted }} />
|
||||
</label>
|
||||
<div>
|
||||
<button type="button" style={primaryButtonStyle(t)}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Sessions" description="Manage your active sessions across devices.">
|
||||
<SettingsRow label="Current session" description="This device — signed in via GitHub OAuth." />
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Sign out" description="Sign out of Foundry on this device.">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
await client.signOut();
|
||||
await navigate({ to: "/signin" });
|
||||
})();
|
||||
}}
|
||||
style={{ ...secondaryButtonStyle(t), display: "inline-flex", alignItems: "center", gap: "6px" }}
|
||||
>
|
||||
<LogOut size={12} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</SettingsContentSection>
|
||||
|
||||
<SettingsContentSection title="Danger zone">
|
||||
<SettingsRow
|
||||
label="Delete account"
|
||||
description="Permanently delete your account and all data."
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...secondaryButtonStyle(t),
|
||||
borderColor: "rgba(255, 110, 110, 0.24)",
|
||||
color: t.statusError,
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingsContentSection>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ maxWidth: "560px" }}>{accountContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1238,7 +1490,7 @@ function AppearanceSection() {
|
|||
height: "20px",
|
||||
borderRadius: "10px",
|
||||
border: "1px solid rgba(128, 128, 128, 0.3)",
|
||||
background: isDark ? t.borderDefault : t.accent,
|
||||
background: isDark ? t.borderDefault : t.textPrimary,
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
transition: "background 0.2s",
|
||||
|
|
@ -1260,7 +1512,7 @@ function AppearanceSection() {
|
|||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isDark ? <Moon size={8} /> : <Sun size={8} color={t.accent} />}
|
||||
{isDark ? <Moon size={8} /> : <Sun size={8} color={t.textPrimary} />}
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
|
@ -1268,3 +1520,130 @@ function AppearanceSection() {
|
|||
</SettingsContentSection>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSoundSection() {
|
||||
const t = useFoundryTokens();
|
||||
const [selected, setSelected] = useNotificationSound();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedLabel = NOTIFICATION_SOUND_OPTIONS.find((o) => o.id === selected)?.label ?? "None";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SettingsContentSection title="Notifications" description="Play a sound when the agent finishes and needs your input.">
|
||||
<SettingsRow
|
||||
label="Completion sound"
|
||||
description={selected === "none" ? "No sound will play." : `"${selectedLabel}" will play when the agent is done.`}
|
||||
action={
|
||||
<div ref={containerRef} style={{ position: "relative", display: "flex", alignItems: "center", gap: "6px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
padding: "5px 10px",
|
||||
borderRadius: "6px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
background: t.interactiveSubtle,
|
||||
color: t.textPrimary,
|
||||
fontSize: "11px",
|
||||
fontWeight: 500,
|
||||
fontFamily: "inherit",
|
||||
cursor: "pointer",
|
||||
minWidth: "90px",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{selectedLabel}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={t.textTertiary} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={open ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "calc(100% + 4px)",
|
||||
right: 0,
|
||||
minWidth: "160px",
|
||||
backgroundColor: "rgba(32, 32, 32, 0.98)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderRadius: "10px",
|
||||
border: `1px solid ${t.borderDefault}`,
|
||||
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px ${t.interactiveSubtle}`,
|
||||
padding: "4px 0",
|
||||
zIndex: 200,
|
||||
}}
|
||||
>
|
||||
{NOTIFICATION_SOUND_OPTIONS.map((option) => {
|
||||
const isActive = option.id === selected;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected(option.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = t.borderSubtle;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = "transparent";
|
||||
}}
|
||||
style={
|
||||
{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: "8px",
|
||||
width: "calc(100% - 8px)",
|
||||
padding: "6px 12px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: isActive ? t.textPrimary : t.textSecondary,
|
||||
fontSize: "12px",
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: "inherit",
|
||||
cursor: "pointer",
|
||||
borderRadius: "6px",
|
||||
margin: "0 4px",
|
||||
boxSizing: "border-box",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.id !== "none" && (
|
||||
<Volume2
|
||||
size={11}
|
||||
color={isActive ? t.accent : t.textTertiary}
|
||||
style={{ cursor: "pointer", flexShrink: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
previewNotificationSound(option.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</SettingsContentSection>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
foundry/packages/frontend/src/lib/notification-sound.ts
Normal file
77
foundry/packages/frontend/src/lib/notification-sound.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
|
||||
export type NotificationSoundId = "none" | "chime" | "ping";
|
||||
|
||||
const SOUNDS: Record<Exclude<NotificationSoundId, "none">, { label: string; src: string }> = {
|
||||
chime: { label: "Chime", src: "/sounds/notification-1.mp3" },
|
||||
ping: { label: "Ping", src: "/sounds/notification-2.mp3" },
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "foundry:notification-sound";
|
||||
|
||||
let currentValue: NotificationSoundId = (localStorage.getItem(STORAGE_KEY) as NotificationSoundId) || "none";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(): NotificationSoundId {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function setNotificationSound(id: NotificationSoundId) {
|
||||
currentValue = id;
|
||||
localStorage.setItem(STORAGE_KEY, id);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function useNotificationSound(): [NotificationSoundId, (id: NotificationSoundId) => void] {
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot);
|
||||
return [value, setNotificationSound];
|
||||
}
|
||||
|
||||
export function playNotificationSound() {
|
||||
const id = getSnapshot();
|
||||
if (id === "none") return;
|
||||
const sound = SOUNDS[id];
|
||||
if (!sound) return;
|
||||
const audio = new Audio(sound.src);
|
||||
audio.volume = 0.6;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
export function previewNotificationSound(id: NotificationSoundId) {
|
||||
if (id === "none") return;
|
||||
const sound = SOUNDS[id];
|
||||
if (!sound) return;
|
||||
const audio = new Audio(sound.src);
|
||||
audio.volume = 0.6;
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
export function useAgentDoneNotification(status: "running" | "idle" | "error" | undefined) {
|
||||
const prevStatus = useRef(status);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevStatus.current;
|
||||
prevStatus.current = status;
|
||||
|
||||
if (prev === "running" && status === "idle") {
|
||||
playNotificationSound();
|
||||
}
|
||||
}, [status]);
|
||||
}
|
||||
|
||||
export const NOTIFICATION_SOUND_OPTIONS: { id: NotificationSoundId; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "chime", label: "Chime" },
|
||||
{ id: "ping", label: "Ping" },
|
||||
];
|
||||
55
foundry/packages/frontend/src/lib/platform.ts
Normal file
55
foundry/packages/frontend/src/lib/platform.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
/** True when built with VITE_MOBILE=1 (Tauri mobile build) */
|
||||
export const isNativeMobile = !!import.meta.env.VITE_MOBILE;
|
||||
|
||||
/** True when built with VITE_DESKTOP=1 (Tauri desktop build) */
|
||||
export const isNativeDesktop = !!import.meta.env.VITE_DESKTOP;
|
||||
|
||||
/** True when running inside any Tauri shell */
|
||||
export const isNativeApp = isNativeMobile || isNativeDesktop;
|
||||
|
||||
function getIsMobileViewport(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
}
|
||||
|
||||
let currentIsMobile = isNativeMobile || getIsMobileViewport();
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
mql.addEventListener("change", (e) => {
|
||||
const next = isNativeMobile || e.matches;
|
||||
if (next !== currentIsMobile) {
|
||||
currentIsMobile = next;
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
return () => {
|
||||
listeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return currentIsMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the app should render in mobile layout.
|
||||
* This is true when:
|
||||
* - Built with VITE_MOBILE=1 (always mobile), OR
|
||||
* - Viewport width is below 768px (responsive web)
|
||||
*
|
||||
* Re-renders when the viewport crosses the breakpoint.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@
|
|||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: var(--f-surface-primary, #000000);
|
||||
color: var(--f-text-primary, #ffffff);
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
html,
|
||||
|
|
@ -44,6 +48,41 @@ a {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes hf-dot-fade {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hf-typing-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 40px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hf-typing-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
max-height: 40px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue