feat(voice): implement turn-detection and real-time audio streaming
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
deriveWorkspaceTabPresentation,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import { encodeFilePathForPathSegment } from "@/utils/host-routes";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
@@ -163,9 +164,19 @@ export function WorkspaceDesktopTabsRow({
|
||||
: presentation.label;
|
||||
|
||||
const contextMenuTestId = `workspace-tab-context-${tab.key}`;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabs.length - 1;
|
||||
const isOnlyTab = tabs.length <= 1;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab,
|
||||
index,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase: contextMenuTestId,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsToLeft,
|
||||
onCloseTabsAfter: onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.key}>
|
||||
@@ -285,64 +296,21 @@ export function WorkspaceDesktopTabsRow({
|
||||
</Tooltip>
|
||||
|
||||
<ContextMenuContent align="start" width={DROPDOWN_WIDTH} testID={contextMenuTestId}>
|
||||
{tab.kind === "agent" ? (
|
||||
<>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<ContextMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-resume-command`}
|
||||
onSelect={() => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
}}
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
Copy resume command
|
||||
{entry.label}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-copy-agent-id`}
|
||||
onSelect={() => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
}}
|
||||
>
|
||||
Copy agent id
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-left`}
|
||||
disabled={isFirstTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToLeft(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the left
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-right`}
|
||||
disabled={isLastTab}
|
||||
onSelect={() => {
|
||||
void onCloseTabsToRight(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close to the right
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close-others`}
|
||||
disabled={isOnlyTab}
|
||||
onSelect={() => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close other tabs
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
testID={`${contextMenuTestId}-close`}
|
||||
onSelect={() => {
|
||||
void onCloseTab(tab.tabId);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ContextMenuItem>
|
||||
)
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
@@ -143,6 +145,7 @@ function buildOpenIntentKey(input: {
|
||||
}
|
||||
|
||||
type MobileWorkspaceTabSwitcherProps = {
|
||||
tabs: WorkspaceTabDescriptor[];
|
||||
activeTabKey: string;
|
||||
activeTabLabel: string;
|
||||
activeTabPresentation: WorkspaceTabPresentation | null;
|
||||
@@ -151,9 +154,16 @@ type MobileWorkspaceTabSwitcherProps = {
|
||||
tabPresentationsByKey: Map<string, WorkspaceTabPresentation>;
|
||||
onSelectSwitcherTab: (key: string) => void;
|
||||
onSelectNewTabOption: (key: typeof NEW_TAB_AGENT_OPTION_ID) => void;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabs,
|
||||
activeTabKey,
|
||||
activeTabLabel,
|
||||
activeTabPresentation,
|
||||
@@ -162,15 +172,29 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
tabPresentationsByKey,
|
||||
onSelectSwitcherTab,
|
||||
onSelectNewTabOption,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsAbove,
|
||||
onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
}: MobileWorkspaceTabSwitcherProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const anchorRef = useRef<View>(null);
|
||||
const tabIndexByKey = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
tabs.forEach((tab, index) => {
|
||||
map.set(tab.key, index);
|
||||
});
|
||||
return map;
|
||||
}, [tabs]);
|
||||
|
||||
return (
|
||||
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
|
||||
<Pressable
|
||||
ref={anchorRef}
|
||||
testID="workspace-tab-switcher-trigger"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.switcherTrigger,
|
||||
(hovered || pressed || isOpen) && styles.switcherTriggerActive,
|
||||
@@ -242,12 +266,71 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
|
||||
const presentation =
|
||||
tabPresentationsByKey.get(option.id) ??
|
||||
deriveWorkspaceTabPresentation({ tab });
|
||||
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries =
|
||||
tabIndex < 0
|
||||
? []
|
||||
: buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab,
|
||||
index: tabIndex,
|
||||
tabCount: tabs.length,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore: onCloseTabsAbove,
|
||||
onCloseTabsAfter: onCloseTabsBelow,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
return (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
trailingAccessory={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open menu for ${presentation.label}`}
|
||||
hitSlop={8}
|
||||
style={({ open, pressed }) => [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(open || pressed) && styles.mobileTabMenuTriggerActive,
|
||||
]}
|
||||
>
|
||||
<Ellipsis
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -587,6 +670,29 @@ function WorkspaceScreenContent({
|
||||
[normalizedServerId, normalizedWorkspaceId, openIntent]
|
||||
);
|
||||
|
||||
const openWorkspaceDraftTab = useCallback(
|
||||
(input?: { draftId?: string; focus?: boolean }) => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: trimNonEmpty(input?.draftId) ?? generateDraftId(),
|
||||
});
|
||||
if (tabId && input?.focus !== false) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
[focusTab, normalizedServerId, normalizedWorkspaceId, openDraftTab]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentOpenIntentKey) {
|
||||
if (resolvedOpenIntentKey !== null) {
|
||||
@@ -619,24 +725,11 @@ function WorkspaceScreenContent({
|
||||
|
||||
if (openIntent.kind === "draft") {
|
||||
const draftId = openIntent.draftId.trim();
|
||||
const tabId =
|
||||
draftId === "new"
|
||||
? openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId: generateDraftId(),
|
||||
})
|
||||
: openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
const tabId = openWorkspaceDraftTab({
|
||||
...(draftId === "new" ? {} : { draftId }),
|
||||
focus: true,
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
return;
|
||||
@@ -653,18 +746,12 @@ function WorkspaceScreenContent({
|
||||
: { kind: "file", path: openIntent.path },
|
||||
});
|
||||
if (tabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
});
|
||||
setResolvedOpenIntentKey(intentKey);
|
||||
}
|
||||
}, [
|
||||
currentOpenIntentKey,
|
||||
focusTab,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
openOrFocusTab,
|
||||
persistenceKey,
|
||||
normalizedServerId,
|
||||
@@ -821,6 +908,10 @@ function WorkspaceScreenContent({
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (openIntent) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (workspaceAgents.length > 0 || terminals.length > 0) {
|
||||
emptyWorkspaceSeedRef.current = null;
|
||||
return;
|
||||
@@ -834,20 +925,12 @@ function WorkspaceScreenContent({
|
||||
return;
|
||||
}
|
||||
emptyWorkspaceSeedRef.current = workspaceKey;
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
openWorkspaceDraftTab();
|
||||
}, [
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openDraftTab,
|
||||
openIntent,
|
||||
openWorkspaceDraftTab,
|
||||
persistenceKey,
|
||||
terminals.length,
|
||||
tabs.length,
|
||||
@@ -924,19 +1007,8 @@ function WorkspaceScreenContent({
|
||||
}, [activeTabPresentation]);
|
||||
|
||||
const handleCreateDraftTab = useCallback(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
const draftId = generateDraftId();
|
||||
const tabId = openDraftTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
draftId,
|
||||
});
|
||||
if (tabId) {
|
||||
navigateToTabId(tabId);
|
||||
}
|
||||
}, [navigateToTabId, normalizedServerId, normalizedWorkspaceId, openDraftTab]);
|
||||
openWorkspaceDraftTab();
|
||||
}, [openWorkspaceDraftTab]);
|
||||
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
if (createTerminalMutation.isPending) {
|
||||
@@ -1375,29 +1447,23 @@ function WorkspaceScreenContent({
|
||||
}
|
||||
|
||||
if (target.kind === "draft") {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
return (
|
||||
<WorkspaceDraftAgentTab
|
||||
key={`${normalizedServerId}:${normalizedWorkspaceId}:${tabId}`}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
tabId={activeTabId ?? target.draftId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
onOpenWorkspaceFile={handleOpenFileFromChat}
|
||||
onCreated={(agentSnapshot) => {
|
||||
const tabId = activeTabId ?? target.draftId;
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
|
||||
const nextTabId = retargetWorkspaceTab({
|
||||
retargetWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId,
|
||||
target: { kind: "agent", agentId: agentSnapshot.id },
|
||||
});
|
||||
if (nextTabId) {
|
||||
focusTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabId: nextTabId,
|
||||
});
|
||||
}
|
||||
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentSnapshot.id, normalized);
|
||||
@@ -1635,6 +1701,7 @@ function WorkspaceScreenContent({
|
||||
|
||||
{isMobile ? (
|
||||
<MobileWorkspaceTabSwitcher
|
||||
tabs={tabs}
|
||||
activeTabKey={activeTabKey}
|
||||
activeTabLabel={activeTabLabel}
|
||||
activeTabPresentation={activeTabPresentation}
|
||||
@@ -1643,6 +1710,12 @@ function WorkspaceScreenContent({
|
||||
tabPresentationsByKey={tabPresentationsByKey}
|
||||
onSelectSwitcherTab={handleSelectSwitcherTab}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
onCopyResumeCommand={handleCopyResumeCommand}
|
||||
onCopyAgentId={handleCopyAgentId}
|
||||
onCloseTab={handleCloseTabById}
|
||||
onCloseTabsAbove={handleCloseTabsToLeft}
|
||||
onCloseTabsBelow={handleCloseTabsToRight}
|
||||
onCloseOtherTabs={handleCloseOtherTabs}
|
||||
/>
|
||||
) : (
|
||||
<WorkspaceDesktopTabsRow
|
||||
@@ -1868,6 +1941,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
mobileTabMenuTrigger: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
mobileTabMenuTriggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tabsContainer: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
function createAgentTab(): WorkspaceTabDescriptor {
|
||||
return {
|
||||
key: "agent_123",
|
||||
tabId: "agent_123",
|
||||
kind: "agent",
|
||||
agentId: "agent-123",
|
||||
provider: "codex",
|
||||
label: "Agent 123",
|
||||
subtitle: "/repo",
|
||||
titleState: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildWorkspaceTabMenuEntries", () => {
|
||||
it("uses desktop tab ordering labels for desktop menus", () => {
|
||||
const onCopyResumeCommand = vi.fn();
|
||||
const onCopyAgentId = vi.fn();
|
||||
const onCloseTab = vi.fn();
|
||||
const onCloseTabsBefore = vi.fn();
|
||||
const onCloseTabsAfter = vi.fn();
|
||||
const onCloseOtherTabs = vi.fn();
|
||||
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "desktop",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-context-agent_123",
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close to the left",
|
||||
"Close to the right",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses stacked ordering labels for mobile menus", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: createAgentTab(),
|
||||
index: 1,
|
||||
tabCount: 3,
|
||||
menuTestIDBase: "workspace-tab-menu-agent_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(
|
||||
entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)
|
||||
).toEqual([
|
||||
"Copy resume command",
|
||||
"Copy agent id",
|
||||
"Close tabs above",
|
||||
"Close tabs below",
|
||||
"Close other tabs",
|
||||
"Close",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits agent copy actions for non-agent tabs", () => {
|
||||
const entries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
tab: {
|
||||
key: "draft_123",
|
||||
tabId: "draft_123",
|
||||
kind: "draft",
|
||||
draftId: "draft_123",
|
||||
label: "New agent",
|
||||
subtitle: "/repo",
|
||||
},
|
||||
index: 0,
|
||||
tabCount: 1,
|
||||
menuTestIDBase: "workspace-tab-menu-draft_123",
|
||||
onCopyResumeCommand: vi.fn(),
|
||||
onCopyAgentId: vi.fn(),
|
||||
onCloseTab: vi.fn(),
|
||||
onCloseTabsBefore: vi.fn(),
|
||||
onCloseTabsAfter: vi.fn(),
|
||||
onCloseOtherTabs: vi.fn(),
|
||||
});
|
||||
|
||||
expect(entries.some((entry) => entry.kind === "item" && entry.label === "Copy agent id")).toBe(
|
||||
false
|
||||
);
|
||||
expect(entries.some((entry) => entry.kind === "separator")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
|
||||
export type WorkspaceTabMenuSurface = "desktop" | "mobile";
|
||||
|
||||
export type WorkspaceTabMenuEntry =
|
||||
| {
|
||||
kind: "item";
|
||||
key: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
testID: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| {
|
||||
kind: "separator";
|
||||
key: string;
|
||||
};
|
||||
|
||||
interface BuildWorkspaceTabMenuEntriesInput {
|
||||
surface: WorkspaceTabMenuSurface;
|
||||
tab: WorkspaceTabDescriptor;
|
||||
index: number;
|
||||
tabCount: number;
|
||||
menuTestIDBase: string;
|
||||
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
|
||||
onCopyAgentId: (agentId: string) => Promise<void> | void;
|
||||
onCloseTab: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function buildCloseBeforeLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs above" : "Close to the left";
|
||||
}
|
||||
|
||||
function buildCloseAfterLabel(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "Close tabs below" : "Close to the right";
|
||||
}
|
||||
|
||||
function buildCloseBeforeTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-above" : "close-left";
|
||||
}
|
||||
|
||||
function buildCloseAfterTestIDSuffix(surface: WorkspaceTabMenuSurface): string {
|
||||
return surface === "mobile" ? "close-below" : "close-right";
|
||||
}
|
||||
|
||||
export function buildWorkspaceTabMenuEntries(
|
||||
input: BuildWorkspaceTabMenuEntriesInput
|
||||
): WorkspaceTabMenuEntry[] {
|
||||
const {
|
||||
surface,
|
||||
tab,
|
||||
index,
|
||||
tabCount,
|
||||
menuTestIDBase,
|
||||
onCopyResumeCommand,
|
||||
onCopyAgentId,
|
||||
onCloseTab,
|
||||
onCloseTabsBefore,
|
||||
onCloseTabsAfter,
|
||||
onCloseOtherTabs,
|
||||
} = input;
|
||||
const isFirstTab = index === 0;
|
||||
const isLastTab = index === tabCount - 1;
|
||||
const isOnlyTab = tabCount <= 1;
|
||||
const entries: WorkspaceTabMenuEntry[] = [];
|
||||
|
||||
if (tab.kind === "agent") {
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-resume-command",
|
||||
label: "Copy resume command",
|
||||
testID: `${menuTestIDBase}-copy-resume-command`,
|
||||
onSelect: () => {
|
||||
void onCopyResumeCommand(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "copy-agent-id",
|
||||
label: "Copy agent id",
|
||||
testID: `${menuTestIDBase}-copy-agent-id`,
|
||||
onSelect: () => {
|
||||
void onCopyAgentId(tab.agentId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "separator",
|
||||
key: "copy-separator",
|
||||
});
|
||||
}
|
||||
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-before",
|
||||
label: buildCloseBeforeLabel(surface),
|
||||
disabled: isFirstTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseBeforeTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsBefore(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-after",
|
||||
label: buildCloseAfterLabel(surface),
|
||||
disabled: isLastTab,
|
||||
testID: `${menuTestIDBase}-${buildCloseAfterTestIDSuffix(surface)}`,
|
||||
onSelect: () => {
|
||||
void onCloseTabsAfter(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close-others",
|
||||
label: "Close other tabs",
|
||||
disabled: isOnlyTab,
|
||||
testID: `${menuTestIDBase}-close-others`,
|
||||
onSelect: () => {
|
||||
void onCloseOtherTabs(tab.tabId);
|
||||
},
|
||||
});
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: "close",
|
||||
label: "Close",
|
||||
testID: `${menuTestIDBase}-close`,
|
||||
onSelect: () => {
|
||||
void onCloseTab(tab.tabId);
|
||||
},
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ describe("deriveWorkspaceTabModel", () => {
|
||||
});
|
||||
|
||||
expect(model.tabs.map((tab) => tab.descriptor.kind)).toEqual(["draft", "agent", "file"]);
|
||||
expect(model.tabs[0]?.descriptor.label).toBe("New Agent");
|
||||
});
|
||||
|
||||
it("applies stored order and appends newly-derived tabs deterministically", () => {
|
||||
|
||||
@@ -169,8 +169,8 @@ export function deriveWorkspaceTabModel(input: {
|
||||
tabId: tab.tabId,
|
||||
kind: "draft",
|
||||
draftId: tab.target.draftId,
|
||||
label: "Draft",
|
||||
subtitle: "Draft",
|
||||
label: "New Agent",
|
||||
subtitle: "New Agent",
|
||||
},
|
||||
});
|
||||
continue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Bot, Check, FileText, Pencil, Terminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
@@ -124,6 +124,7 @@ type WorkspaceTabOptionRowProps = {
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
trailingAccessory?: ReactNode;
|
||||
};
|
||||
|
||||
export function WorkspaceTabOptionRow({
|
||||
@@ -131,30 +132,36 @@ export function WorkspaceTabOptionRow({
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
trailingAccessory,
|
||||
}: WorkspaceTabOptionRowProps): ReactElement {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionRow,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.optionRow, active && styles.optionRowActive]}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={({ hovered = false, pressed }) => [
|
||||
styles.optionMainPressable,
|
||||
(hovered || pressed || active) && styles.optionRowActive,
|
||||
]}
|
||||
>
|
||||
<View style={styles.optionLeadingSlot}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={selected || active} />
|
||||
</View>
|
||||
<View style={styles.optionContent}>
|
||||
<Text numberOfLines={1} style={styles.optionLabel}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
{selected ? (
|
||||
<View style={styles.optionTrailingSlot}>
|
||||
<Check size={16} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{trailingAccessory ? (
|
||||
<View style={styles.optionTrailingAccessorySlot}>{trailingAccessory}</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,12 +185,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
minHeight: 36,
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
borderRadius: 0,
|
||||
marginHorizontal: theme.spacing[1],
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
optionMainPressable: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
optionRowActive: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
@@ -204,6 +220,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
width: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginLeft: "auto",
|
||||
},
|
||||
optionTrailingAccessorySlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user