mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4b94acda | ||
|
|
954046f671 | ||
|
|
ff89c85255 | ||
|
|
1d8a138d0d | ||
|
|
a5c6acb73b |
@@ -1,74 +0,0 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
|
||||
## What is Paseo
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
|
||||
## Core philosophy
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
### Projects and workspaces
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
|
||||
### Inside a workspace
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
|
||||
### The daemon
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
|
||||
This architecture means:
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
|
||||
## Target user
|
||||
|
||||
Anyone who builds software:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
|
||||
## What compounds over time
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
|
||||
## Strategic bets
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
Generated
-36
@@ -25,7 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"concurrently": "^9.2.1",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
@@ -15185,31 +15184,6 @@
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/connect": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz",
|
||||
@@ -32573,16 +32547,6 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
||||
+1
-2
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
@@ -59,7 +59,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"concurrently": "^9.2.1",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"knip": "^5.82.1",
|
||||
"patch-package": "^8.0.1",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --prefix ../expo-two-way-audio",
|
||||
"eas-build-post-install": "npm run build:workspace-deps",
|
||||
"android": "npm run android:development",
|
||||
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
|
||||
|
||||
@@ -120,7 +120,6 @@ export function AgentInputArea({
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||
const dictationCancelKeys = useShortcutKeys("dictation-cancel");
|
||||
const isDictationReady =
|
||||
isConnected &&
|
||||
(agentDirectoryStatus === "ready" ||
|
||||
@@ -594,14 +593,12 @@ export function AgentInputArea({
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
{dictationCancelKeys ? (
|
||||
<Shortcut chord={dictationCancelKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Interrupt</Text>
|
||||
<Shortcut keys={["Esc"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null;
|
||||
|
||||
const rightContent = (
|
||||
@@ -628,9 +625,7 @@ export function AgentInputArea({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Voice mode</Text>
|
||||
{voiceToggleKeys ? (
|
||||
<Shortcut chord={voiceToggleKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
{voiceToggleKeys && <Shortcut keys={voiceToggleKeys} style={styles.tooltipShortcut} />}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -178,7 +178,7 @@ export function CommandCenter() {
|
||||
</View>
|
||||
</View>
|
||||
{action.shortcutKeys ? (
|
||||
<Shortcut chord={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</CommandCenterRow>
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { GitAction, GitActions } from "@/components/git-actions-policy";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
@@ -19,7 +17,6 @@ interface GitActionsSplitButtonProps {
|
||||
|
||||
export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
if (action.status === "pending") return action.pendingLabel;
|
||||
@@ -80,11 +77,6 @@ export function GitActionsSplitButton({ gitActions }: GitActionsSplitButtonProps
|
||||
<DropdownMenuItem
|
||||
testID={`changes-menu-${action.id}`}
|
||||
leading={action.icon}
|
||||
trailing={
|
||||
action.id === "archive-worktree" && archiveShortcutKeys
|
||||
? <Shortcut chord={archiveShortcutKeys} />
|
||||
: undefined
|
||||
}
|
||||
disabled={action.disabled}
|
||||
status={action.status}
|
||||
pendingLabel={action.pendingLabel}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function KeyboardShortcutsDialog() {
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Shortcuts"
|
||||
title="Keyboard shortcuts"
|
||||
visible={open}
|
||||
onClose={() => setOpen(false)}
|
||||
testID="keyboard-shortcuts-dialog"
|
||||
|
||||
@@ -565,7 +565,7 @@ function MobileSidebar({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
{newAgentKeys && <Shortcut keys={newAgentKeys} />}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -745,7 +745,7 @@ function DesktopSidebar({
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
{newAgentKeys ? <Shortcut chord={newAgentKeys} /> : null}
|
||||
<Shortcut keys={["⌘", "⇧", "O"]} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -33,7 +33,6 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import {
|
||||
markScrollInvestigationEvent,
|
||||
@@ -211,10 +210,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
markScrollInvestigationRender(investigationComponentId);
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const sendKeys = useShortcutKeys("message-input-send");
|
||||
const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle");
|
||||
const dictationToggleKeys = useShortcutKeys("dictation-toggle");
|
||||
const queueKeys = useShortcutKeys("message-input-queue");
|
||||
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT);
|
||||
const rootRef = useRef<View | null>(null);
|
||||
const inputWrapperRef = useRef<View | null>(null);
|
||||
@@ -1011,16 +1006,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
: "Mute voice"
|
||||
: "Dictation"}
|
||||
</Text>
|
||||
{(isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys) ? (
|
||||
<Shortcut
|
||||
chord={
|
||||
(isRealtimeVoiceForCurrentAgent
|
||||
? voiceMuteToggleKeys
|
||||
: dictationToggleKeys)!
|
||||
}
|
||||
style={styles.tooltipShortcut}
|
||||
/>
|
||||
) : null}
|
||||
<Shortcut
|
||||
keys={isRealtimeVoiceForCurrentAgent ? ["Space"] : ["mod", "D"]}
|
||||
style={styles.tooltipShortcut}
|
||||
/>
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1043,9 +1032,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Queue</Text>
|
||||
{queueKeys ? (
|
||||
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
|
||||
) : null}
|
||||
<Shortcut keys={["mod", "Enter"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -1068,7 +1055,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Send</Text>
|
||||
{sendKeys ? <Shortcut chord={sendKeys} style={styles.tooltipShortcut} /> : null}
|
||||
<Shortcut keys={["Enter"]} style={styles.tooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -27,12 +27,12 @@ import { type GestureType } from "react-native-gesture-handler";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import {
|
||||
Archive,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Monitor,
|
||||
MoreVertical,
|
||||
Plus,
|
||||
@@ -48,6 +48,7 @@ import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarStateBucket,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
@@ -72,19 +73,12 @@ import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -97,12 +91,6 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo
|
||||
|
||||
const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey;
|
||||
const EMPTY_WORKSPACES = new Map();
|
||||
const WORKSPACE_STATUS_DOT_WIDTH = 14;
|
||||
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
|
||||
open: "Open",
|
||||
merged: "Merged",
|
||||
closed: "Closed",
|
||||
};
|
||||
|
||||
interface SidebarWorkspaceListProps {
|
||||
projects: SidebarProjectEntry[];
|
||||
@@ -128,7 +116,6 @@ interface ProjectHeaderRowProps {
|
||||
onPress: () => void;
|
||||
serverId: string | null;
|
||||
canCreateWorktree: boolean;
|
||||
isProjectActive?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
onWorktreeCreated?: (workspaceId: string) => void;
|
||||
shortcutNumber?: number | null;
|
||||
@@ -158,53 +145,22 @@ interface WorkspaceRowInnerProps {
|
||||
onArchive?: () => void;
|
||||
onCopyBranchName?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
function WorkspacePrBadge({ hint }: { hint: PrHint }) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
event.stopPropagation();
|
||||
void openExternalUrl(hint.url);
|
||||
},
|
||||
[hint.url],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={`${hint.state} pull request #${hint.number}`}
|
||||
hitSlop={4}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={handlePress}
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
style={({ pressed }) => [
|
||||
styles.workspacePrBadge,
|
||||
pressed && styles.workspacePrBadgePressed,
|
||||
]}
|
||||
>
|
||||
<GitPullRequest size={12} color={activeColor} />
|
||||
<Text
|
||||
style={[
|
||||
styles.workspacePrBadgeText,
|
||||
{ color: activeColor },
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]}
|
||||
</Text>
|
||||
{isHovered && <ExternalLink size={10} color={activeColor} />}
|
||||
</Pressable>
|
||||
);
|
||||
function resolveStatusDotColor(input: {
|
||||
theme: ReturnType<typeof useUnistyles>["theme"];
|
||||
bucket: SidebarStateBucket;
|
||||
}) {
|
||||
const { theme, bucket } = input;
|
||||
return bucket === "needs_input"
|
||||
? theme.colors.palette.amber[500]
|
||||
: bucket === "failed"
|
||||
? theme.colors.palette.red[500]
|
||||
: bucket === "running"
|
||||
? theme.colors.palette.blue[500]
|
||||
: bucket === "attention"
|
||||
? theme.colors.palette.green[500]
|
||||
: theme.colors.border;
|
||||
}
|
||||
|
||||
function WorkspaceStatusIndicator({
|
||||
@@ -217,48 +173,38 @@ function WorkspaceStatusIndicator({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const color = resolveStatusDotColor({ theme, bucket });
|
||||
const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket });
|
||||
const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done";
|
||||
|
||||
if (loading) {
|
||||
if (isIdle) {
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
: null;
|
||||
if (!KindIcon) return null;
|
||||
|
||||
const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false });
|
||||
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<KindIcon size={14} color={theme.colors.foregroundMuted} />
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||
) : shouldShowSyncedLoader ? (
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
) : bucket === "needs_input" ? (
|
||||
<CircleHelp size={14} color={theme.colors.palette.amber[500]} />
|
||||
) : bucket === "attention" ? (
|
||||
<Check size={14} color={theme.colors.palette.green[500]} />
|
||||
) : (
|
||||
<View style={[styles.workspaceStatusDotFill, { backgroundColor: color }]} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -278,72 +224,29 @@ function ProjectLeadingVisual({
|
||||
showChevron?: boolean;
|
||||
isArchiving?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName);
|
||||
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase();
|
||||
const activeWorkspace = workspace;
|
||||
const shouldShowWorkspaceStatus =
|
||||
activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done");
|
||||
const shouldShowSyncedLoader = activeWorkspace
|
||||
? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket })
|
||||
: false;
|
||||
|
||||
if (showChevron && chevron !== null) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const projectIcon = iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!shouldShowWorkspaceStatus || !activeWorkspace) {
|
||||
return <View style={styles.projectLeadingVisualSlot}>{projectIcon}</View>;
|
||||
}
|
||||
|
||||
if (isArchiving) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<ActivityIndicator size={8} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowSyncedLoader) {
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
<SyncedLoader size={11} color={theme.colors.palette.amber[500]} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const dotColor = getStatusDotColor({
|
||||
theme,
|
||||
bucket: activeWorkspace.statusBucket,
|
||||
showDoneAsInactive: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{projectIcon}
|
||||
{dotColor ? (
|
||||
<View
|
||||
style={[
|
||||
styles.statusDotOverlay,
|
||||
{
|
||||
backgroundColor: dotColor,
|
||||
borderColor: theme.colors.surface0,
|
||||
},
|
||||
]}
|
||||
{showChevron && chevron !== null ? (
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={activeWorkspace.statusBucket}
|
||||
workspaceKind={activeWorkspace.workspaceKind}
|
||||
loading={isArchiving}
|
||||
/>
|
||||
) : null}
|
||||
) : iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
<View style={styles.projectIconFallback}>
|
||||
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -364,17 +267,14 @@ function NewWorktreeButton({
|
||||
visible,
|
||||
loading = false,
|
||||
testID,
|
||||
showShortcutHint = false,
|
||||
}: {
|
||||
displayName: string;
|
||||
onPress: () => void;
|
||||
visible: boolean;
|
||||
loading?: boolean;
|
||||
testID: string;
|
||||
showShortcutHint?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const newWorktreeKeys = useShortcutKeys("new-worktree");
|
||||
|
||||
return (
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? "auto" : "none"}>
|
||||
@@ -409,13 +309,8 @@ function NewWorktreeButton({
|
||||
}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.projectActionTooltipRow}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
{showShortcutHint && newWorktreeKeys ? (
|
||||
<Shortcut chord={newWorktreeKeys} style={styles.projectActionTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
@@ -649,7 +544,6 @@ function ProjectHeaderRow({
|
||||
onPress,
|
||||
serverId,
|
||||
canCreateWorktree,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
@@ -700,17 +594,6 @@ function ProjectHeaderRow({
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-new-${project.projectKey}`,
|
||||
actions: ["worktree.new"],
|
||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
createWorktreeMutation.mutate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -753,7 +636,6 @@ function ProjectHeaderRow({
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
@@ -829,16 +711,10 @@ function WorkspaceRowInner({
|
||||
onArchive,
|
||||
onCopyBranchName,
|
||||
onCopyPath,
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowInnerProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
});
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -873,99 +749,91 @@ function WorkspaceRowInner({
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
<View
|
||||
{...(dragHandleProps?.attributes as any)}
|
||||
{...(dragHandleProps?.listeners as any)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
workspaceKind={workspace.workspaceKind}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.workspaceBranchText,
|
||||
isHovered && styles.workspaceBranchTextHovered,
|
||||
isCreating && styles.workspaceBranchTextCreating,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{workspace.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.workspaceRowRight}>
|
||||
{isCreating ? <Text style={styles.workspaceCreatingText}>Creating...</Text> : null}
|
||||
{onArchive && (isHovered || isMobile) ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={({ hovered = false }) => [
|
||||
styles.kebabButton,
|
||||
hovered && styles.kebabButtonHovered,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Workspace actions"
|
||||
testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MoreVertical
|
||||
size={14}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={200}>
|
||||
{onCopyPath ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-copy-path-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyPath}
|
||||
>
|
||||
Copy path
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onCopyBranchName ? (
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
trailing={archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
testID={`sidebar-workspace-menu-copy-branch-name-${workspace.workspaceKey}`}
|
||||
leading={<Copy size={14} color={theme.colors.foregroundMuted} />}
|
||||
onSelect={onCopyBranchName}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
Copy branch name
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
testID={`sidebar-workspace-menu-archive-${workspace.workspaceKey}`}
|
||||
leading={<Archive size={14} color={theme.colors.foregroundMuted} />}
|
||||
status={archiveStatus}
|
||||
pendingLabel={archivePendingLabel}
|
||||
onSelect={onArchive}
|
||||
>
|
||||
{archiveLabel ?? "Archive"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : workspace.diffStat ? (
|
||||
<View style={styles.diffStatRow}>
|
||||
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
|
||||
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{prHint ? (
|
||||
<View style={styles.workspacePrBadgeRow}>
|
||||
<WorkspacePrBadge hint={prHint} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -1035,7 +903,7 @@ function WorkspaceRowWithMenu({
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Archive worktree?",
|
||||
message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`,
|
||||
message: `Archive "${workspace.name}"?\n\nThis removes the worktree from the sidebar.`,
|
||||
confirmLabel: "Archive",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
@@ -1123,23 +991,6 @@ function WorkspaceRowWithMenu({
|
||||
toast.copied("Branch name copied");
|
||||
}, [toast, workspace.name]);
|
||||
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-worktree");
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-archive-${workspace.workspaceKey}`,
|
||||
actions: ["worktree.archive"],
|
||||
enabled: selected && !isArchiving,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
if (isWorktree) {
|
||||
handleArchiveWorktree();
|
||||
} else {
|
||||
handleArchiveWorkspace();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
@@ -1159,7 +1010,6 @@ function WorkspaceRowWithMenu({
|
||||
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
|
||||
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
|
||||
onCopyPath={handleCopyPath}
|
||||
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1332,7 +1182,6 @@ function FlattenedProjectRow({
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
isProjectActive = false,
|
||||
}: {
|
||||
project: SidebarProjectEntry;
|
||||
displayName: string;
|
||||
@@ -1347,7 +1196,6 @@ function FlattenedProjectRow({
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
return (
|
||||
@@ -1378,7 +1226,6 @@ function FlattenedProjectRow({
|
||||
onPress={onPress}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
shortcutNumber={shortcutNumber}
|
||||
@@ -1480,13 +1327,6 @@ function ProjectBlock({
|
||||
[activeWorkspaceSelection, collapsed, project, serverId],
|
||||
);
|
||||
|
||||
const isProjectActive = useMemo(() => {
|
||||
if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) {
|
||||
return false;
|
||||
}
|
||||
return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId);
|
||||
}, [serverId, activeWorkspaceSelection, project.workspaces]);
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(
|
||||
item: SidebarWorkspaceEntry,
|
||||
@@ -1579,7 +1419,6 @@ function ProjectBlock({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isProjectActive={isProjectActive}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -1593,7 +1432,6 @@ function ProjectBlock({
|
||||
onPress={onToggleCollapsed}
|
||||
serverId={serverId}
|
||||
canCreateWorktree={rowModel.trailingAction === "new_worktree"}
|
||||
isProjectActive={isProjectActive}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
@@ -1962,7 +1800,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectBlock: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
workspaceListContainer: {},
|
||||
workspaceListContainer: {
|
||||
marginLeft: theme.spacing[3],
|
||||
paddingLeft: theme.spacing[2],
|
||||
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||
borderLeftColor: theme.colors.surface1,
|
||||
},
|
||||
emptyText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
@@ -1972,7 +1815,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectRow: {
|
||||
minHeight: 36,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
marginBottom: theme.spacing[1],
|
||||
flexDirection: "row",
|
||||
@@ -2018,7 +1861,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
projectLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
flexShrink: 0,
|
||||
@@ -2041,7 +1883,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
@@ -2082,37 +1923,20 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionTooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
projectActionTooltipShortcut: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[3] + theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspaceRowMain: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
width: "100%",
|
||||
},
|
||||
workspaceRowLeft: {
|
||||
flexDirection: "row",
|
||||
@@ -2152,22 +1976,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: WORKSPACE_STATUS_DOT_WIDTH,
|
||||
width: 14,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
},
|
||||
statusDotOverlay: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
workspaceStatusDotFill: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: 1,
|
||||
},
|
||||
workspaceArchivingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -2187,7 +2006,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "300",
|
||||
lineHeight: 20,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
@@ -2199,23 +2017,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
workspacePrBadgeRow: {
|
||||
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
|
||||
},
|
||||
workspacePrBadge: {
|
||||
alignSelf: "flex-start",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspacePrBadgePressed: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
workspacePrBadgeText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 14,
|
||||
},
|
||||
workspaceCreatingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -2230,12 +2031,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
diffStatAdditions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#2d8a4e",
|
||||
color: theme.colors.palette.green[400],
|
||||
},
|
||||
diffStatDeletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#c43a31",
|
||||
color: theme.colors.palette.red[500],
|
||||
},
|
||||
kebabButton: {
|
||||
padding: 2,
|
||||
|
||||
@@ -617,9 +617,9 @@ export default function TerminalEmulator({
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
overscrollBehavior: "none",
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -11,6 +15,8 @@ import {
|
||||
normalizeTerminalTransportKey,
|
||||
resolvePendingModifierDataInput,
|
||||
} from "@/utils/terminal-keys";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
@@ -23,10 +29,14 @@ import TerminalEmulator from "./terminal-emulator";
|
||||
interface TerminalPaneProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
terminalId: string;
|
||||
selectedTerminalId: string | null;
|
||||
onSelectedTerminalIdChange?: (terminalId: string | null) => void;
|
||||
hideHeader?: boolean;
|
||||
manageTerminalDirectorySubscription?: boolean;
|
||||
}
|
||||
|
||||
const MAX_OUTPUT_CHARS = 200_000;
|
||||
const TERMINAL_TAB_MAX_WIDTH = 220;
|
||||
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
|
||||
|
||||
const MODIFIER_LABELS = {
|
||||
@@ -69,6 +79,8 @@ type PendingTerminalInput =
|
||||
};
|
||||
};
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
const EMPTY_MODIFIERS: ModifierState = {
|
||||
ctrl: false,
|
||||
shift: false,
|
||||
@@ -79,10 +91,30 @@ function terminalScopeKey(input: { serverId: string; cwd: string }): string {
|
||||
return `${input.serverId}:${input.cwd}`;
|
||||
}
|
||||
|
||||
function TerminalCloseGradient({ color, gradientId }: { color: string; gradientId: string }) {
|
||||
return (
|
||||
<View style={styles.terminalTabCloseGradient} pointerEvents="none">
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<Stop offset="0%" stopColor={color} stopOpacity={0} />
|
||||
<Stop offset="10%" stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="100%" stopColor={color} stopOpacity={1} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalPane({
|
||||
serverId,
|
||||
cwd,
|
||||
terminalId,
|
||||
selectedTerminalId,
|
||||
onSelectedTerminalIdChange,
|
||||
hideHeader = false,
|
||||
manageTerminalDirectorySubscription = true,
|
||||
}: TerminalPaneProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
const { theme } = useUnistyles();
|
||||
@@ -97,10 +129,12 @@ export function TerminalPane({
|
||||
enabled: isMobile,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const workspaceTerminalSession = useMemo(
|
||||
@@ -123,20 +157,20 @@ export function TerminalPane({
|
||||
getOutputSessionState,
|
||||
);
|
||||
const selectedOutputState = useMemo(() => {
|
||||
if (outputState.selectedTerminalId === terminalId) {
|
||||
if (outputState.selectedTerminalId === selectedTerminalId) {
|
||||
return outputState;
|
||||
}
|
||||
|
||||
return {
|
||||
...outputState,
|
||||
selectedTerminalId: terminalId,
|
||||
snapshotText: outputSession.readSnapshot({ terminalId }),
|
||||
selectedTerminalId,
|
||||
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
|
||||
snapshotSequence: 0,
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
}, [outputSession, outputState, terminalId]);
|
||||
}, [outputSession, outputState, selectedTerminalId]);
|
||||
const [activeStream, setActiveStream] = useState<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
@@ -146,14 +180,68 @@ export function TerminalPane({
|
||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||
const terminalIdRef = useRef<string>(terminalId);
|
||||
const [hoveredTerminalId, setHoveredTerminalId] = useState<string | null>(null);
|
||||
const [hoveredCloseTerminalId, setHoveredCloseTerminalId] = useState<string | null>(null);
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
const lastAutoFocusKeyRef = useRef<string | null>(null);
|
||||
|
||||
const updateSelectedTerminalId = useCallback(
|
||||
(next: string | null) => {
|
||||
onSelectedTerminalIdChange?.(next);
|
||||
},
|
||||
[onSelectedTerminalIdChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
terminalIdRef.current = terminalId;
|
||||
}, [terminalId]);
|
||||
selectedTerminalIdRef.current = selectedTerminalId;
|
||||
}, [selectedTerminalId]);
|
||||
|
||||
const clearHoverOutTimeout = useCallback(() => {
|
||||
if (!hoverOutTimeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(hoverOutTimeoutRef.current);
|
||||
hoverOutTimeoutRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTerminalTabHoverIn = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
setHoveredTerminalId(terminalId);
|
||||
},
|
||||
[clearHoverOutTimeout],
|
||||
);
|
||||
|
||||
const handleTerminalTabHoverOut = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
hoverOutTimeoutRef.current = setTimeout(() => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
|
||||
}, 50);
|
||||
},
|
||||
[clearHoverOutTimeout],
|
||||
);
|
||||
|
||||
const handleTerminalCloseHoverIn = useCallback(
|
||||
(terminalId: string) => {
|
||||
clearHoverOutTimeout();
|
||||
setHoveredTerminalId(terminalId);
|
||||
setHoveredCloseTerminalId(terminalId);
|
||||
},
|
||||
[clearHoverOutTimeout],
|
||||
);
|
||||
|
||||
const handleTerminalCloseHoverOut = useCallback((terminalId: string) => {
|
||||
setHoveredCloseTerminalId((current) => (current === terminalId ? null : current));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearHoverOutTimeout();
|
||||
}, [clearHoverOutTimeout]);
|
||||
|
||||
const requestTerminalFocus = useCallback(() => {
|
||||
setFocusRequestToken((current) => current + 1);
|
||||
@@ -163,19 +251,19 @@ export function TerminalPane({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile || !isScreenFocused || !terminalId) {
|
||||
if (isMobile || !isScreenFocused || !selectedTerminalId) {
|
||||
lastAutoFocusKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFocusKey = `${scopeKey}:${terminalId}`;
|
||||
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
|
||||
if (lastAutoFocusKeyRef.current === nextFocusKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastAutoFocusKeyRef.current = nextFocusKey;
|
||||
requestTerminalFocus();
|
||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, terminalId]);
|
||||
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
@@ -214,7 +302,7 @@ export function TerminalPane({
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!terminalId) {
|
||||
if (!selectedTerminalId) {
|
||||
return;
|
||||
}
|
||||
// Navigation transitions can temporarily report stale dimensions.
|
||||
@@ -230,9 +318,23 @@ export function TerminalPane({
|
||||
clearTimeout(handle);
|
||||
}
|
||||
};
|
||||
}, [requestTerminalReflow, terminalId]),
|
||||
}, [requestTerminalReflow, selectedTerminalId]),
|
||||
);
|
||||
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled: Boolean(client && isConnected && cwd.startsWith("/")),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(cwd);
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !isScreenFocused) {
|
||||
return;
|
||||
@@ -256,10 +358,110 @@ export function TerminalPane({
|
||||
});
|
||||
}, [client, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manageTerminalDirectorySubscription || !client || !isConnected || !cwd.startsWith("/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = client.on("terminals_changed", (message) => {
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd });
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
client.unsubscribeTerminals({ cwd });
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
cwd,
|
||||
isConnected,
|
||||
manageTerminalDirectorySubscription,
|
||||
queryClient,
|
||||
terminalsQueryKey,
|
||||
]);
|
||||
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(cwd);
|
||||
},
|
||||
onSuccess: (payload) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
|
||||
return {
|
||||
cwd: current?.cwd ?? cwd,
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
});
|
||||
updateSelectedTerminalId(createdTerminal.id);
|
||||
requestTerminalFocus();
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const killTerminalMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
onSuccess: (_, terminalId) => {
|
||||
setHoveredTerminalId((current) => (current === terminalId ? null : current));
|
||||
outputSession.clearTerminal({ terminalId });
|
||||
if (selectedTerminalIdRef.current === terminalId) {
|
||||
updateSelectedTerminalId(null);
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
lastReportedSizeRef.current = null;
|
||||
}, [scopeKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const terminalIds = terminals.map((terminal) => terminal.id);
|
||||
outputSession.prune({ terminalIds });
|
||||
streamControllerRef.current?.pruneResumeOffsets({ terminalIds });
|
||||
}, [outputSession, terminals]);
|
||||
|
||||
const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => {
|
||||
setIsAttaching(status.isAttaching);
|
||||
setStreamError(status.error);
|
||||
@@ -299,7 +501,7 @@ export function TerminalPane({
|
||||
|
||||
streamControllerRef.current = controller;
|
||||
controller.setTerminal({
|
||||
terminalId: isScreenFocused ? terminalIdRef.current : null,
|
||||
terminalId: isScreenFocused ? selectedTerminalIdRef.current : null,
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -319,21 +521,29 @@ export function TerminalPane({
|
||||
|
||||
useEffect(() => {
|
||||
pendingTerminalInputRef.current = [];
|
||||
const nextTerminalId = isScreenFocused ? terminalId : null;
|
||||
const nextSelectedTerminalId = isScreenFocused ? selectedTerminalId : null;
|
||||
outputSession.setSelectedTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
terminalId: nextSelectedTerminalId,
|
||||
});
|
||||
streamControllerRef.current?.setTerminal({
|
||||
terminalId: nextTerminalId,
|
||||
terminalId: nextSelectedTerminalId,
|
||||
});
|
||||
}, [isScreenFocused, outputSession, terminalId]);
|
||||
}, [isScreenFocused, outputSession, selectedTerminalId]);
|
||||
|
||||
const activeStreamId =
|
||||
activeStream && activeStream.terminalId === terminalId ? activeStream.streamId : null;
|
||||
activeStream && activeStream.terminalId === selectedTerminalId ? activeStream.streamId : null;
|
||||
const getCurrentActiveStreamId = useCallback(() => {
|
||||
return streamControllerRef.current?.getActiveStreamId() ?? null;
|
||||
}, []);
|
||||
|
||||
const selectedTerminal = useMemo(
|
||||
() => terminals.find((terminal) => terminal.id === selectedTerminalId) ?? null,
|
||||
[terminals, selectedTerminalId],
|
||||
);
|
||||
const handleCreateTerminal = useCallback(() => {
|
||||
createTerminalMutation.mutate();
|
||||
}, [createTerminalMutation]);
|
||||
|
||||
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
|
||||
const queue = pendingTerminalInputRef.current;
|
||||
queue.push(entry);
|
||||
@@ -348,13 +558,13 @@ export function TerminalPane({
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentTerminalId = terminalIdRef.current;
|
||||
if (!currentTerminalId) {
|
||||
const terminalId = selectedTerminalIdRef.current;
|
||||
if (!terminalId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entry.type === "data") {
|
||||
client.sendTerminalInput(currentTerminalId, {
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: entry.data,
|
||||
});
|
||||
@@ -365,7 +575,7 @@ export function TerminalPane({
|
||||
if (encoded.length === 0) {
|
||||
return true;
|
||||
}
|
||||
client.sendTerminalInput(currentTerminalId, {
|
||||
client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: encoded,
|
||||
});
|
||||
@@ -401,6 +611,29 @@ export function TerminalPane({
|
||||
flushPendingTerminalInput();
|
||||
}, [activeStreamId, flushPendingTerminalInput]);
|
||||
|
||||
const handleCloseTerminal = useCallback(
|
||||
async (terminalId: string) => {
|
||||
if (killTerminalMutation.isPending && killTerminalMutation.variables === terminalId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await confirmDialog({
|
||||
title: "Close terminal?",
|
||||
message: "Any running process in this terminal will be stopped immediately.",
|
||||
confirmLabel: "Close",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
killTerminalMutation.mutate(terminalId);
|
||||
},
|
||||
[killTerminalMutation],
|
||||
);
|
||||
|
||||
const clearPendingModifiers = useCallback(() => {
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
}, []);
|
||||
@@ -413,7 +646,7 @@ export function TerminalPane({
|
||||
alt: boolean;
|
||||
meta?: boolean;
|
||||
}): boolean => {
|
||||
if (!client || !terminalIdRef.current) {
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "key",
|
||||
input: {
|
||||
@@ -477,7 +710,7 @@ export function TerminalPane({
|
||||
}
|
||||
}
|
||||
|
||||
if (!client || !terminalIdRef.current) {
|
||||
if (!client || !selectedTerminalIdRef.current) {
|
||||
enqueuePendingTerminalInput({
|
||||
type: "data",
|
||||
data,
|
||||
@@ -508,7 +741,7 @@ export function TerminalPane({
|
||||
const handleTerminalResize = useCallback(
|
||||
async (input: { rows: number; cols: number }) => {
|
||||
const { rows, cols } = input;
|
||||
if (!client || !terminalId || rows <= 0 || cols <= 0) {
|
||||
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
||||
return;
|
||||
}
|
||||
const normalizedRows = Math.floor(rows);
|
||||
@@ -518,13 +751,13 @@ export function TerminalPane({
|
||||
return;
|
||||
}
|
||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||
client.sendTerminalInput(terminalId, {
|
||||
client.sendTerminalInput(selectedTerminalId, {
|
||||
type: "resize",
|
||||
rows: normalizedRows,
|
||||
cols: normalizedCols,
|
||||
});
|
||||
},
|
||||
[client, terminalId],
|
||||
[client, selectedTerminalId],
|
||||
);
|
||||
|
||||
const handleTerminalKey = useCallback(
|
||||
@@ -583,10 +816,119 @@ export function TerminalPane({
|
||||
);
|
||||
}
|
||||
|
||||
const queryError = terminalsQuery.error instanceof Error ? terminalsQuery.error.message : null;
|
||||
const isCreating = createTerminalMutation.isPending;
|
||||
const createError =
|
||||
createTerminalMutation.error instanceof Error ? createTerminalMutation.error.message : null;
|
||||
const closeError =
|
||||
killTerminalMutation.error instanceof Error ? killTerminalMutation.error.message : null;
|
||||
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||
{!hideHeader ? (
|
||||
<View style={styles.header} testID="terminals-header">
|
||||
<ScrollView
|
||||
horizontal
|
||||
style={styles.tabsScroll}
|
||||
contentContainerStyle={styles.tabsContent}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
>
|
||||
{terminals.map((terminal) => {
|
||||
const isActive = terminal.id === selectedTerminalId;
|
||||
const isTabHovered = hoveredTerminalId === terminal.id;
|
||||
const isCloseHovered = hoveredCloseTerminalId === terminal.id;
|
||||
const isClosingTerminal =
|
||||
killTerminalMutation.isPending && killTerminalMutation.variables === terminal.id;
|
||||
const shouldShowCloseButton = isTabHovered || isCloseHovered || isClosingTerminal;
|
||||
const gradientId = `terminal-close-gradient-${terminal.id.replace(
|
||||
/[^a-zA-Z0-9_-]/g,
|
||||
"-",
|
||||
)}`;
|
||||
return (
|
||||
<Pressable
|
||||
key={terminal.id}
|
||||
testID={`terminal-tab-${terminal.id}`}
|
||||
onPress={() => updateSelectedTerminalId(terminal.id)}
|
||||
onHoverIn={() => handleTerminalTabHoverIn(terminal.id)}
|
||||
onHoverOut={() => handleTerminalTabHoverOut(terminal.id)}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.terminalTab,
|
||||
isActive && styles.terminalTabActive,
|
||||
shouldShowCloseButton && styles.terminalTabHovered,
|
||||
(pressed || hovered) && styles.terminalTabHovered,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.terminalTabText, isActive && styles.terminalTabTextActive]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{terminal.name}
|
||||
</Text>
|
||||
<Pressable
|
||||
testID={`terminal-close-${terminal.id}`}
|
||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||
disabled={!shouldShowCloseButton || isClosingTerminal}
|
||||
onHoverIn={() => handleTerminalCloseHoverIn(terminal.id)}
|
||||
onHoverOut={() => handleTerminalCloseHoverOut(terminal.id)}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleCloseTerminal(terminal.id);
|
||||
}}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.terminalTabCloseButton,
|
||||
shouldShowCloseButton
|
||||
? styles.terminalTabCloseButtonShown
|
||||
: styles.terminalTabCloseButtonHidden,
|
||||
]}
|
||||
>
|
||||
{({ hovered = false, pressed = false }) => {
|
||||
const iconColor =
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||
return (
|
||||
<>
|
||||
<TerminalCloseGradient
|
||||
color={theme.colors.surface2}
|
||||
gradientId={gradientId}
|
||||
/>
|
||||
<View style={styles.terminalTabCloseIcon}>
|
||||
{isClosingTerminal ? (
|
||||
<ActivityIndicator size={12} color={iconColor} />
|
||||
) : (
|
||||
<X size={12} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable
|
||||
testID="terminals-create-button"
|
||||
onPress={handleCreateTerminal}
|
||||
disabled={isCreating}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.headerIconButton,
|
||||
(hovered || pressed) && styles.headerIconButtonHovered,
|
||||
]}
|
||||
>
|
||||
{isCreating ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<Plus size={16} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.outputContainer}>
|
||||
{isScreenFocused ? (
|
||||
{selectedTerminal && isScreenFocused ? (
|
||||
<View style={styles.terminalGestureContainer}>
|
||||
<TerminalEmulator
|
||||
dom={{
|
||||
@@ -599,7 +941,7 @@ export function TerminalPane({
|
||||
automaticallyAdjustContentInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
streamKey={`${scopeKey}:${terminalId}`}
|
||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||
initialOutputText={selectedOutputState.snapshotText}
|
||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||
outputChunkText={selectedOutputState.chunkText}
|
||||
@@ -630,8 +972,12 @@ export function TerminalPane({
|
||||
resizeRequestToken={resizeRequestToken}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
) : selectedTerminal ? (
|
||||
<View style={styles.terminalGestureContainer} />
|
||||
) : (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.stateText}>No terminal selected</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAttaching && isScreenFocused ? (
|
||||
@@ -641,10 +987,10 @@ export function TerminalPane({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{streamError ? (
|
||||
{combinedError ? (
|
||||
<View style={styles.errorRow}>
|
||||
<Text style={styles.statusError} numberOfLines={2}>
|
||||
{streamError}
|
||||
{combinedError}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -702,6 +1048,100 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
header: {
|
||||
minHeight: 48,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
tabsScroll: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
tabsContent: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingRight: theme.spacing[2],
|
||||
},
|
||||
terminalTab: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
justifyContent: "center",
|
||||
maxWidth: TERMINAL_TAB_MAX_WIDTH,
|
||||
minWidth: 96,
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
terminalTabHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
terminalTabActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
terminalTabText: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
terminalTabTextActive: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
terminalTabCloseButton: {
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 32,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "center",
|
||||
paddingRight: theme.spacing[2],
|
||||
},
|
||||
terminalTabCloseButtonShown: {
|
||||
opacity: 1,
|
||||
},
|
||||
terminalTabCloseButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
terminalTabCloseGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
overflow: "hidden",
|
||||
zIndex: 0,
|
||||
},
|
||||
terminalTabCloseIcon: {
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headerActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
headerIconButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
headerIconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
outputContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type ViewStyle,
|
||||
type StyleProp,
|
||||
} from "react-native";
|
||||
import Animated, { Keyframe, runOnJS } from "react-native-reanimated";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Check, CheckCircle } from "lucide-react-native";
|
||||
|
||||
@@ -226,22 +226,6 @@ export function DropdownMenuTrigger({
|
||||
);
|
||||
}
|
||||
|
||||
function getTransformOrigin(placement: Placement, alignment: Alignment): string {
|
||||
const vertical = placement === "bottom" ? "top" : placement === "top" ? "bottom" : "center";
|
||||
const horizontal = alignment === "start" ? "left" : alignment === "end" ? "right" : "center";
|
||||
return `${vertical} ${horizontal}`;
|
||||
}
|
||||
|
||||
const contentEntering = new Keyframe({
|
||||
0: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||
100: { opacity: 1, transform: [{ scale: 1 }] },
|
||||
}).duration(150);
|
||||
|
||||
const contentExiting = new Keyframe({
|
||||
0: { opacity: 1, transform: [{ scale: 1 }] },
|
||||
100: { opacity: 0, transform: [{ scale: 0.97 }] },
|
||||
}).duration(100);
|
||||
|
||||
export function DropdownMenuContent({
|
||||
children,
|
||||
side = "bottom",
|
||||
@@ -265,22 +249,9 @@ export function DropdownMenuContent({
|
||||
testID?: string;
|
||||
}>): ReactElement | null {
|
||||
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
|
||||
|
||||
// Keep Modal mounted during exit animation
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setModalVisible(true);
|
||||
setClosing(false);
|
||||
} else if (modalVisible) {
|
||||
setClosing(true);
|
||||
}
|
||||
}, [open, modalVisible]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
@@ -343,7 +314,6 @@ export function DropdownMenuContent({
|
||||
// For fullWidth, x is simply the horizontal padding to center on screen
|
||||
const x = fullWidth ? horizontalPadding : result.x;
|
||||
setPosition({ x, y: result.y });
|
||||
setActualPlacement(result.actualPlacement);
|
||||
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
|
||||
|
||||
const handleContentLayout = useCallback(
|
||||
@@ -354,7 +324,7 @@ export function DropdownMenuContent({
|
||||
[],
|
||||
);
|
||||
|
||||
if (!modalVisible) return null;
|
||||
if (!open) return null;
|
||||
|
||||
const { width: screenWidth } = Dimensions.get("window");
|
||||
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||
@@ -367,7 +337,7 @@ export function DropdownMenuContent({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
visible={open}
|
||||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent={Platform.OS === "android"}
|
||||
@@ -381,38 +351,30 @@ export function DropdownMenuContent({
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing ? (
|
||||
<Animated.View
|
||||
entering={contentEntering}
|
||||
exiting={contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(setModalVisible)(false);
|
||||
}
|
||||
})}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleContentLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
transformOrigin: getTransformOrigin(actualPlacement, align),
|
||||
},
|
||||
]}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleContentLayout}
|
||||
style={[
|
||||
styles.content,
|
||||
resolvedWidthStyle,
|
||||
{
|
||||
position: "absolute",
|
||||
top: position?.y ?? -9999,
|
||||
left: position?.x ?? -9999,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
showsVerticalScrollIndicator
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
{children}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
@@ -597,9 +559,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
left: 0,
|
||||
},
|
||||
content: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
overflow: "hidden",
|
||||
shadowColor: "#000",
|
||||
@@ -642,16 +604,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderColor: "transparent",
|
||||
},
|
||||
itemHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemSelectedInteractive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
itemSelectedAccent: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
|
||||
@@ -6,46 +6,22 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
export function Shortcut({
|
||||
keys,
|
||||
chord,
|
||||
style,
|
||||
textStyle,
|
||||
}: {
|
||||
keys?: ShortcutKey[];
|
||||
chord?: ShortcutKey[][];
|
||||
keys: ShortcutKey[];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement {
|
||||
const displayChord = chord ?? (keys ? [keys] : []);
|
||||
const shortcutOs = getShortcutOs();
|
||||
const singleCombo = displayChord[0];
|
||||
|
||||
if (!singleCombo) {
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
if (displayChord.length === 1) {
|
||||
return (
|
||||
<View style={[styles.badge, style]}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(singleCombo, shortcutOs)}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.sequence, style]}>
|
||||
{displayChord.map(function (combo, index) {
|
||||
return (
|
||||
<View key={`${combo.join("+")}-${index}`} style={styles.badge}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(combo, shortcutOs)}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
<View style={[styles.root, style]}>
|
||||
<Text style={[styles.text, textStyle]}>{formatShortcut(keys, getShortcutOs())}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
root: {
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -53,12 +29,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
sequence: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
text: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
|
||||
@@ -49,6 +49,11 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import {
|
||||
shouldAutoReplayQueuedAgentMessage,
|
||||
takeQueuedAgentMessageReplay,
|
||||
type QueuedAgentReplaySource,
|
||||
} from "@/contexts/session-queued-message-replay";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -272,7 +277,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const previousAgentStatusRef = useRef<Map<string, AgentLifecycleStatus>>(new Map());
|
||||
const sendAgentMessageRef = useRef<
|
||||
((agentId: string, message: string, images?: AttachmentMetadata[]) => Promise<void>) | null
|
||||
((
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
messageId?: string,
|
||||
) => Promise<void>) | null
|
||||
>(null);
|
||||
const sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
@@ -349,7 +359,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const applyAuthoritativeAgentSnapshot = useCallback(
|
||||
(agent: Agent) => {
|
||||
(agent: Agent, options?: { source?: QueuedAgentReplaySource }) => {
|
||||
setAgents(serverId, (prev) => {
|
||||
const current = prev.get(agent.id);
|
||||
if (current && agent.updatedAt.getTime() < current.updatedAt.getTime()) {
|
||||
@@ -423,17 +433,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
|
||||
const prevStatus = previousAgentStatusRef.current.get(agent.id);
|
||||
if (prevStatus === "running" && agent.status !== "running") {
|
||||
if (
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: prevStatus,
|
||||
nextStatus: agent.status,
|
||||
source: options?.source ?? "live",
|
||||
})
|
||||
) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agent.id);
|
||||
if (queue && queue.length > 0) {
|
||||
const [next, ...rest] = queue;
|
||||
const replay = takeQueuedAgentMessageReplay(session?.queuedMessages.get(agent.id));
|
||||
if (replay) {
|
||||
if (sendAgentMessageRef.current) {
|
||||
void sendAgentMessageRef.current(agent.id, next.text, next.images);
|
||||
void sendAgentMessageRef.current(
|
||||
agent.id,
|
||||
replay.text,
|
||||
replay.images,
|
||||
replay.messageId,
|
||||
);
|
||||
}
|
||||
setQueuedMessages(serverId, (prev) => {
|
||||
const updated = new Map(prev);
|
||||
updated.set(agent.id, rest);
|
||||
updated.set(agent.id, replay.remainingQueue);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -516,31 +536,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const handleAppResumed = useCallback(
|
||||
(awayMs: number) => {
|
||||
scheduleAuthoritativeRevalidation();
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agentId = session?.focusedAgentId;
|
||||
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
|
||||
if (agentId && cursor) {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (awayMs < HISTORY_STALE_AFTER_MS) {
|
||||
return;
|
||||
}
|
||||
bumpHistorySyncGeneration(serverId);
|
||||
},
|
||||
[bumpHistorySyncGeneration, client, scheduleAuthoritativeRevalidation, serverId],
|
||||
[bumpHistorySyncGeneration, scheduleAuthoritativeRevalidation, serverId],
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
@@ -741,7 +742,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}),
|
||||
};
|
||||
|
||||
applyAuthoritativeAgentSnapshot(agent);
|
||||
applyAuthoritativeAgentSnapshot(agent, { source: "live" });
|
||||
},
|
||||
[
|
||||
applyAuthoritativeAgentSnapshot,
|
||||
@@ -791,10 +792,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
if (payload.agent) {
|
||||
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
|
||||
applyAuthoritativeAgentSnapshot({
|
||||
...normalized,
|
||||
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
||||
});
|
||||
applyAuthoritativeAgentSnapshot(
|
||||
{
|
||||
...normalized,
|
||||
projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null,
|
||||
},
|
||||
{ source: "hydrate" },
|
||||
);
|
||||
}
|
||||
|
||||
// Call pure reducer
|
||||
@@ -1488,11 +1492,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
]);
|
||||
|
||||
const sendAgentMessage = useCallback(
|
||||
async (agentId: string, message: string, images?: AttachmentMetadata[]) => {
|
||||
const messageId = generateMessageId();
|
||||
async (
|
||||
agentId: string,
|
||||
message: string,
|
||||
images?: AttachmentMetadata[],
|
||||
messageId?: string,
|
||||
) => {
|
||||
const resolvedMessageId = messageId ?? generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
id: resolvedMessageId,
|
||||
text: message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
@@ -1526,7 +1535,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
void client
|
||||
.sendAgentMessage(agentId, message, {
|
||||
messageId,
|
||||
messageId: resolvedMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
shouldAutoReplayQueuedAgentMessage,
|
||||
takeQueuedAgentMessageReplay,
|
||||
} from "./session-queued-message-replay";
|
||||
|
||||
describe("takeQueuedAgentMessageReplay", () => {
|
||||
it("preserves the queued message id for idempotent replay", () => {
|
||||
const replay = takeQueuedAgentMessageReplay([
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "resume this",
|
||||
images: [
|
||||
{
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachment:img-1",
|
||||
fileName: "test.png",
|
||||
byteSize: 12,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
text: "second",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(replay).toEqual({
|
||||
messageId: "msg-1",
|
||||
text: "resume this",
|
||||
images: [
|
||||
{
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachment:img-1",
|
||||
fileName: "test.png",
|
||||
byteSize: 12,
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
remainingQueue: [{ id: "msg-2", text: "second" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when the queue is empty", () => {
|
||||
expect(takeQueuedAgentMessageReplay([])).toBeNull();
|
||||
expect(takeQueuedAgentMessageReplay(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("only auto-replays queued messages for live running-to-idle transitions", () => {
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "running",
|
||||
nextStatus: "idle",
|
||||
source: "live",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "running",
|
||||
nextStatus: "idle",
|
||||
source: "hydrate",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldAutoReplayQueuedAgentMessage({
|
||||
previousStatus: "idle",
|
||||
nextStatus: "idle",
|
||||
source: "live",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
export interface QueuedAgentMessageReplayItem {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: AttachmentMetadata[];
|
||||
}
|
||||
|
||||
export interface QueuedAgentMessageReplay {
|
||||
messageId: string;
|
||||
text: string;
|
||||
images?: AttachmentMetadata[];
|
||||
remainingQueue: QueuedAgentMessageReplayItem[];
|
||||
}
|
||||
|
||||
export type QueuedAgentReplaySource = "hydrate" | "live";
|
||||
|
||||
export function shouldAutoReplayQueuedAgentMessage(input: {
|
||||
previousStatus: string | undefined;
|
||||
nextStatus: string;
|
||||
source: QueuedAgentReplaySource;
|
||||
}): boolean {
|
||||
return input.source === "live" && input.previousStatus === "running" && input.nextStatus !== "running";
|
||||
}
|
||||
|
||||
export function takeQueuedAgentMessageReplay(
|
||||
queue: readonly QueuedAgentMessageReplayItem[] | undefined,
|
||||
): QueuedAgentMessageReplay | null {
|
||||
if (!queue || queue.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [next, ...remainingQueue] = queue;
|
||||
return {
|
||||
messageId: next.id,
|
||||
text: next.text,
|
||||
images: next.images,
|
||||
remainingQueue,
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import type { CheckoutPrStatusResponse } from "@server/shared/messages";
|
||||
|
||||
const CHECKOUT_PR_STATUS_STALE_TIME = 20_000;
|
||||
const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000;
|
||||
|
||||
function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
@@ -16,48 +15,6 @@ interface UseCheckoutPrStatusQueryOptions {
|
||||
}
|
||||
|
||||
export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
export interface PrHint {
|
||||
url: string;
|
||||
number: number;
|
||||
state: "open" | "merged" | "closed";
|
||||
}
|
||||
|
||||
function parsePullRequestNumber(url: string): number | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(number) ? number : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null {
|
||||
const status = payload.status;
|
||||
if (!status?.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const number = parsePullRequestNumber(status.url);
|
||||
if (number === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: status.url,
|
||||
number,
|
||||
state: status.isMerged || status.state === "merged"
|
||||
? "merged"
|
||||
: status.state === "open"
|
||||
? "open"
|
||||
: "closed",
|
||||
};
|
||||
}
|
||||
|
||||
export function useCheckoutPrStatusQuery({
|
||||
serverId,
|
||||
@@ -91,28 +48,3 @@ export function useCheckoutPrStatusQuery({
|
||||
refresh: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspacePrHint({
|
||||
serverId,
|
||||
cwd,
|
||||
enabled = true,
|
||||
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
|
||||
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
return await client.checkoutPrStatus(cwd);
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd && enabled,
|
||||
staleTime: CHECKOUT_PR_STATUS_STALE_TIME,
|
||||
refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL,
|
||||
select: selectWorkspacePrHint,
|
||||
});
|
||||
|
||||
return query.data ?? null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
@@ -91,7 +91,7 @@ export type CommandCenterActionItem = {
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
};
|
||||
|
||||
export type CommandCenterItem =
|
||||
@@ -107,7 +107,7 @@ export type CommandCenterItem =
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[][] | undefined {
|
||||
): ShortcutKey[] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
@@ -115,9 +115,8 @@ function resolveActionShortcutKeys(
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : undefined;
|
||||
if (override) return comboStringToShortcutKeys(override);
|
||||
return getDefaultKeysForAction(actionId, platform) ?? undefined;
|
||||
}
|
||||
|
||||
export function useCommandCenter() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
} from "@/keyboard/actions";
|
||||
import { canToggleFileExplorerShortcut } from "@/keyboard/keyboard-shortcut-routing";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
type ChordState,
|
||||
resolveKeyboardShortcut,
|
||||
buildEffectiveBindings,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardShortcut, buildEffectiveBindings } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
@@ -45,11 +41,6 @@ export function useKeyboardShortcuts({
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const chordStateRef = useRef<ChordState>({
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
});
|
||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
||||
const activeServerId =
|
||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
||||
@@ -162,21 +153,6 @@ export function useKeyboardShortcuts({
|
||||
id: "workspace.tab.new",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "worktree.archive":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "worktree.archive",
|
||||
scope: "sidebar",
|
||||
});
|
||||
case "worktree.new":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "worktree.new",
|
||||
scope: "sidebar",
|
||||
});
|
||||
case "workspace.terminal.new":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.terminal.new",
|
||||
scope: "workspace",
|
||||
});
|
||||
case "workspace.tab.close.current":
|
||||
return keyboardActionDispatcher.dispatch({
|
||||
id: "workspace.tab.close-current",
|
||||
@@ -280,11 +256,6 @@ export function useKeyboardShortcuts({
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
if (store.capturingShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key ?? "";
|
||||
if (key === "Alt" && !event.shiftKey) {
|
||||
useKeyboardShortcutsStore.getState().setAltDown(true);
|
||||
@@ -299,11 +270,12 @@ export function useKeyboardShortcuts({
|
||||
}
|
||||
}
|
||||
|
||||
const store = useKeyboardShortcutsStore.getState();
|
||||
const focusScope = resolveKeyboardFocusScope({
|
||||
target: event.target,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
});
|
||||
const result = resolveKeyboardShortcut({
|
||||
const match = resolveKeyboardShortcut({
|
||||
event,
|
||||
context: {
|
||||
isMac,
|
||||
@@ -316,41 +288,25 @@ export function useKeyboardShortcuts({
|
||||
toggleFileExplorer,
|
||||
}),
|
||||
},
|
||||
chordState: chordStateRef.current,
|
||||
onChordReset: () => {
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
},
|
||||
bindings,
|
||||
});
|
||||
|
||||
chordStateRef.current = result.nextChordState;
|
||||
|
||||
if (result.preventDefault) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (!result.match) {
|
||||
if (!match) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handled = handleAction({
|
||||
action: result.match.action,
|
||||
payload: result.match.payload,
|
||||
action: match.action,
|
||||
payload: match.payload,
|
||||
event,
|
||||
});
|
||||
if (!handled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.match.preventDefault) {
|
||||
if (match.preventDefault) {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (result.match.stopPropagation) {
|
||||
if (match.stopPropagation) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
@@ -374,14 +330,6 @@ export function useKeyboardShortcuts({
|
||||
window.addEventListener("blur", handleBlurOrHide);
|
||||
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||
return () => {
|
||||
if (chordStateRef.current.timeoutId !== null) {
|
||||
clearTimeout(chordStateRef.current.timeoutId);
|
||||
chordStateRef.current = {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
}
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("keyup", handleKeyUp, true);
|
||||
window.removeEventListener("blur", handleBlurOrHide);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { comboStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
export function useShortcutKeys(actionId: string): ShortcutKey[] | null {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
@@ -18,10 +18,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override) {
|
||||
return chordStringToShortcutKeys(override);
|
||||
return comboStringToShortcutKeys(override);
|
||||
}
|
||||
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : null;
|
||||
return getDefaultKeysForAction(actionId, platform);
|
||||
}, [actionId, overrides, isMac, isDesktop]);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ export type KeyboardFocusScope =
|
||||
|
||||
export type MessageInputKeyboardActionKind =
|
||||
| "focus"
|
||||
| "send"
|
||||
| "queue"
|
||||
| "dictation-toggle"
|
||||
| "dictation-cancel"
|
||||
| "voice-toggle"
|
||||
@@ -37,9 +35,6 @@ export type KeyboardActionId =
|
||||
| "sidebar.toggle.right"
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive"
|
||||
| "message-input.action";
|
||||
|
||||
export type KeyboardShortcutPayload =
|
||||
|
||||
@@ -20,10 +20,7 @@ export type KeyboardActionId =
|
||||
| "workspace.pane.move-tab.right"
|
||||
| "workspace.pane.move-tab.up"
|
||||
| "workspace.pane.move-tab.down"
|
||||
| "workspace.pane.close"
|
||||
| "workspace.terminal.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive";
|
||||
| "workspace.pane.close";
|
||||
|
||||
export type KeyboardActionDefinition =
|
||||
| { id: "message-input.focus"; scope: KeyboardActionScope }
|
||||
@@ -45,10 +42,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.pane.move-tab.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.up"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.move-tab.down"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope };
|
||||
|
||||
export type KeyboardActionHandler = {
|
||||
handlerId: string;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
buildEffectiveBindings,
|
||||
resolveKeyboardShortcut,
|
||||
type ChordState,
|
||||
type KeyboardShortcutContext,
|
||||
type ParsedShortcutBinding,
|
||||
} from "./keyboard-shortcuts";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
|
||||
@@ -34,30 +31,6 @@ function shortcutContext(
|
||||
};
|
||||
}
|
||||
|
||||
function initialChordState(): ChordState {
|
||||
return {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveShortcut(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
chordState?: ChordState;
|
||||
onChordReset?: () => void;
|
||||
bindings?: readonly ParsedShortcutBinding[];
|
||||
}) {
|
||||
return resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
chordState: input.chordState ?? initialChordState(),
|
||||
onChordReset: input.onChordReset ?? (() => undefined),
|
||||
...(input.bindings ? { bindings: input.bindings } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function expectShortcutResolution(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
@@ -66,33 +39,29 @@ function expectShortcutResolution(input: {
|
||||
preventDefault?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}) {
|
||||
const result = resolveShortcut({
|
||||
event: input.event,
|
||||
context: input.context,
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
});
|
||||
|
||||
expect(result.match?.action).toBe(input.action);
|
||||
expect(match?.action).toBe(input.action);
|
||||
if ("payload" in input) {
|
||||
expect(result.match?.payload).toEqual(input.payload);
|
||||
expect(match?.payload).toEqual(input.payload);
|
||||
}
|
||||
expect(result.match?.preventDefault).toBe(input.preventDefault ?? true);
|
||||
expect(result.match?.stopPropagation).toBe(input.stopPropagation ?? true);
|
||||
expect(result.preventDefault).toBe(false);
|
||||
expect(result.nextChordState).toEqual(initialChordState());
|
||||
expect(match?.preventDefault).toBe(input.preventDefault ?? true);
|
||||
expect(match?.stopPropagation).toBe(input.stopPropagation ?? true);
|
||||
}
|
||||
|
||||
function expectNoShortcutResolution(input: {
|
||||
event: Partial<KeyboardEvent>;
|
||||
context?: Partial<KeyboardShortcutContext>;
|
||||
}) {
|
||||
const result = resolveShortcut({
|
||||
event: input.event,
|
||||
context: input.context,
|
||||
const match = resolveKeyboardShortcut({
|
||||
event: keyboardEvent(input.event),
|
||||
context: shortcutContext(input.context),
|
||||
});
|
||||
|
||||
expect(result.match).toBeNull();
|
||||
expect(result.preventDefault).toBe(false);
|
||||
expect(result.nextChordState).toEqual(initialChordState());
|
||||
expect(match).toBeNull();
|
||||
}
|
||||
|
||||
type MatchingShortcutCase = {
|
||||
@@ -369,66 +338,6 @@ describe("keyboard-shortcuts", () => {
|
||||
it.each(nonMatchingCases)("$name", ({ event, context }) => {
|
||||
expectNoShortcutResolution({ event, context });
|
||||
});
|
||||
|
||||
it("prefers advancing chord candidates over single-combo matches on the same prefix", () => {
|
||||
const bindings = buildEffectiveBindings({
|
||||
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
|
||||
});
|
||||
const chordBindingIndex = bindings.findIndex(
|
||||
(binding) => binding.id === "workspace-terminal-new-ctrl-shift-t-non-mac",
|
||||
);
|
||||
expect(chordBindingIndex).toBeGreaterThan(-1);
|
||||
|
||||
const firstResult = resolveShortcut({
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(firstResult.match).toBeNull();
|
||||
expect(firstResult.preventDefault).toBe(true);
|
||||
expect(firstResult.nextChordState.step).toBe(1);
|
||||
expect(firstResult.nextChordState.candidateIndices).toEqual([chordBindingIndex]);
|
||||
|
||||
const secondResult = resolveShortcut({
|
||||
event: { key: "s", code: "KeyS" },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
chordState: firstResult.nextChordState,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(secondResult.match?.action).toBe("workspace.terminal.new");
|
||||
expect(secondResult.match?.payload).toBeNull();
|
||||
expect(secondResult.match?.preventDefault).toBe(true);
|
||||
expect(secondResult.match?.stopPropagation).toBe(true);
|
||||
expect(secondResult.preventDefault).toBe(false);
|
||||
expect(secondResult.nextChordState).toEqual(initialChordState());
|
||||
});
|
||||
|
||||
it("schedules a chord reset timeout for advancing candidates", () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const bindings = buildEffectiveBindings({
|
||||
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+W S",
|
||||
});
|
||||
const onChordReset = vi.fn();
|
||||
|
||||
const result = resolveShortcut({
|
||||
event: { key: "w", code: "KeyW", ctrlKey: true },
|
||||
context: { isMac: false, isDesktop: true },
|
||||
onChordReset,
|
||||
bindings,
|
||||
});
|
||||
|
||||
expect(result.match).toBeNull();
|
||||
expect(result.preventDefault).toBe(true);
|
||||
expect(result.nextChordState.timeoutId).not.toBeNull();
|
||||
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
expect(onChordReset).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyboard-shortcut help sections", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
KeyboardShortcutPayload,
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
|
||||
import { type KeyCombo, parseShortcutString } from "@/keyboard/shortcut-string";
|
||||
|
||||
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||
|
||||
@@ -33,15 +33,8 @@ export type KeyboardShortcutHelpRow = {
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export type ShortcutSectionId =
|
||||
| "navigation"
|
||||
| "tabs-panes"
|
||||
| "projects"
|
||||
| "panels"
|
||||
| "agent-input";
|
||||
|
||||
export type KeyboardShortcutHelpSection = {
|
||||
id: ShortcutSectionId;
|
||||
id: "global" | "agent-input";
|
||||
title: string;
|
||||
rows: KeyboardShortcutHelpRow[];
|
||||
};
|
||||
@@ -76,7 +69,7 @@ type ShortcutPayloadDef =
|
||||
|
||||
interface ShortcutHelp {
|
||||
id: string;
|
||||
section: ShortcutSectionId;
|
||||
section: "global" | "agent-input";
|
||||
label: string;
|
||||
keys: ShortcutKey[];
|
||||
note?: string;
|
||||
@@ -95,22 +88,13 @@ interface ShortcutBinding {
|
||||
}
|
||||
|
||||
export interface ParsedShortcutBinding extends ShortcutBinding {
|
||||
parsedChord: KeyCombo[];
|
||||
}
|
||||
|
||||
export interface ChordState {
|
||||
candidateIndices: number[];
|
||||
step: number;
|
||||
timeoutId: ReturnType<typeof setTimeout> | null;
|
||||
parsedCombo: KeyCombo;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<ShortcutSectionId, string> = {
|
||||
navigation: "Navigation",
|
||||
"tabs-panes": "Tabs & Panes",
|
||||
projects: "Projects",
|
||||
panels: "Panels",
|
||||
const SHORTCUT_HELP_SECTION_TITLES: Record<KeyboardShortcutHelpSection["id"], string> = {
|
||||
global: "Global",
|
||||
"agent-input": "Agent Input",
|
||||
};
|
||||
|
||||
@@ -125,7 +109,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
section: "global",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
@@ -137,64 +121,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "new-agent",
|
||||
section: "projects",
|
||||
section: "global",
|
||||
label: "Open project",
|
||||
keys: ["mod", "shift", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- New worktree ---
|
||||
{
|
||||
id: "worktree-new-cmd-o-mac",
|
||||
action: "worktree.new",
|
||||
combo: "Cmd+O",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "new-worktree",
|
||||
section: "projects",
|
||||
label: "New worktree",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "worktree-new-ctrl-o-non-mac",
|
||||
action: "worktree.new",
|
||||
combo: "Ctrl+O",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "new-worktree",
|
||||
section: "projects",
|
||||
label: "New worktree",
|
||||
keys: ["mod", "O"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Archive worktree ---
|
||||
{
|
||||
id: "worktree-archive-cmd-shift-backspace-mac",
|
||||
action: "worktree.archive",
|
||||
combo: "Cmd+Shift+Backspace",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "archive-worktree",
|
||||
section: "projects",
|
||||
label: "Archive worktree",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "worktree-archive-ctrl-shift-backspace-non-mac",
|
||||
action: "worktree.archive",
|
||||
combo: "Ctrl+Shift+Backspace",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "archive-worktree",
|
||||
section: "projects",
|
||||
label: "Archive worktree",
|
||||
keys: ["mod", "shift", "Backspace"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Tab management ---
|
||||
{
|
||||
id: "workspace-tab-new-cmd-t-mac",
|
||||
@@ -203,7 +135,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "New agent tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
@@ -215,7 +147,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "New agent tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
@@ -227,7 +159,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, desktop: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["meta", "W"],
|
||||
},
|
||||
@@ -239,7 +171,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, desktop: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["ctrl", "W"],
|
||||
},
|
||||
@@ -251,7 +183,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { desktop: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-tab-close-current",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Close current tab",
|
||||
keys: ["alt", "shift", "W"],
|
||||
},
|
||||
@@ -266,7 +198,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
},
|
||||
@@ -279,7 +211,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["mod", "1-9"],
|
||||
},
|
||||
@@ -292,7 +224,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-jump-index",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Jump to workspace",
|
||||
keys: ["alt", "1-9"],
|
||||
},
|
||||
@@ -307,7 +239,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "1-9"],
|
||||
},
|
||||
@@ -320,7 +252,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "index" },
|
||||
help: {
|
||||
id: "workspace-tab-jump-index",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Jump to tab",
|
||||
keys: ["alt", "shift", "1-9"],
|
||||
},
|
||||
@@ -335,7 +267,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
@@ -348,7 +280,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["mod", "["],
|
||||
},
|
||||
@@ -361,7 +293,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
@@ -374,7 +306,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["mod", "]"],
|
||||
},
|
||||
@@ -387,7 +319,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-prev",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Previous workspace",
|
||||
keys: ["alt", "["],
|
||||
},
|
||||
@@ -400,7 +332,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-next",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Next workspace",
|
||||
keys: ["alt", "]"],
|
||||
},
|
||||
@@ -415,7 +347,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: -1 },
|
||||
help: {
|
||||
id: "workspace-tab-prev",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Previous tab",
|
||||
keys: ["alt", "shift", "["],
|
||||
},
|
||||
@@ -428,7 +360,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
payload: { type: "delta", delta: 1 },
|
||||
help: {
|
||||
id: "workspace-tab-next",
|
||||
section: "navigation",
|
||||
section: "global",
|
||||
label: "Next tab",
|
||||
keys: ["alt", "shift", "]"],
|
||||
},
|
||||
@@ -442,7 +374,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-right",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Split pane right",
|
||||
keys: ["mod", "\\"],
|
||||
},
|
||||
@@ -454,7 +386,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-split-down",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Split pane down",
|
||||
keys: ["mod", "shift", "\\"],
|
||||
},
|
||||
@@ -466,7 +398,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-left",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Focus pane left",
|
||||
keys: ["mod", "shift", "Left"],
|
||||
},
|
||||
@@ -478,7 +410,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-right",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Focus pane right",
|
||||
keys: ["mod", "shift", "Right"],
|
||||
},
|
||||
@@ -490,7 +422,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-up",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Focus pane up",
|
||||
keys: ["mod", "shift", "Up"],
|
||||
},
|
||||
@@ -502,7 +434,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-focus-down",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Focus pane down",
|
||||
keys: ["mod", "shift", "Down"],
|
||||
},
|
||||
@@ -514,7 +446,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-left",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Move tab left",
|
||||
keys: ["mod", "shift", "alt", "Left"],
|
||||
},
|
||||
@@ -526,7 +458,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-right",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Move tab right",
|
||||
keys: ["mod", "shift", "alt", "Right"],
|
||||
},
|
||||
@@ -538,7 +470,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-up",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Move tab up",
|
||||
keys: ["mod", "shift", "alt", "Up"],
|
||||
},
|
||||
@@ -550,7 +482,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-move-tab-down",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Move tab down",
|
||||
keys: ["mod", "shift", "alt", "Down"],
|
||||
},
|
||||
@@ -562,38 +494,12 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, terminal: false, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-pane-close",
|
||||
section: "tabs-panes",
|
||||
section: "global",
|
||||
label: "Close pane",
|
||||
keys: ["mod", "shift", "W"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- New terminal ---
|
||||
{
|
||||
id: "workspace-terminal-new-cmd-shift-t-mac",
|
||||
action: "workspace.terminal.new",
|
||||
combo: "Cmd+Shift+T",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "workspace-terminal-new",
|
||||
section: "panels",
|
||||
label: "New terminal",
|
||||
keys: ["mod", "shift", "T"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-terminal-new-ctrl-shift-t-non-mac",
|
||||
action: "workspace.terminal.new",
|
||||
combo: "Ctrl+Shift+T",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "workspace-terminal-new",
|
||||
section: "panels",
|
||||
label: "New terminal",
|
||||
keys: ["mod", "shift", "T"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Command center ---
|
||||
{
|
||||
id: "command-center-toggle-cmd-k-mac",
|
||||
@@ -602,7 +508,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
@@ -614,7 +520,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-command-center",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle command center",
|
||||
keys: ["mod", "K"],
|
||||
},
|
||||
@@ -629,7 +535,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { focusScope: "other" },
|
||||
help: {
|
||||
id: "show-shortcuts",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Show keyboard shortcuts",
|
||||
keys: ["?"],
|
||||
note: "Available when focus is not in a text field or terminal.",
|
||||
@@ -644,7 +550,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "B"],
|
||||
},
|
||||
@@ -662,7 +568,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-left-sidebar",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle left sidebar",
|
||||
keys: ["mod", "."],
|
||||
},
|
||||
@@ -674,7 +580,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: true, hasSelectedAgent: true, commandCenter: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
@@ -686,7 +592,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
when: { mac: false, hasSelectedAgent: true, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "toggle-right-sidebar",
|
||||
section: "panels",
|
||||
section: "global",
|
||||
label: "Toggle right sidebar",
|
||||
keys: ["mod", "E"],
|
||||
},
|
||||
@@ -768,52 +674,6 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
keys: ["Esc"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-send-enter",
|
||||
action: "message-input.action",
|
||||
combo: "Enter",
|
||||
when: { focusScope: "message-input", commandCenter: false },
|
||||
payload: { type: "message-input", kind: "send" },
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
help: {
|
||||
id: "message-input-send",
|
||||
section: "agent-input",
|
||||
label: "Send message",
|
||||
keys: ["Enter"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-queue-cmd-enter-mac",
|
||||
action: "message-input.action",
|
||||
combo: "Cmd+Enter",
|
||||
when: { mac: true, focusScope: "message-input", commandCenter: false },
|
||||
payload: { type: "message-input", kind: "queue" },
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
help: {
|
||||
id: "message-input-queue",
|
||||
section: "agent-input",
|
||||
label: "Queue message",
|
||||
keys: ["mod", "Enter"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "message-input-queue-ctrl-enter-non-mac",
|
||||
action: "message-input.action",
|
||||
combo: "Ctrl+Enter",
|
||||
when: { mac: false, focusScope: "message-input", commandCenter: false },
|
||||
payload: { type: "message-input", kind: "queue" },
|
||||
preventDefault: false,
|
||||
stopPropagation: false,
|
||||
help: {
|
||||
id: "message-input-queue",
|
||||
section: "agent-input",
|
||||
label: "Queue message",
|
||||
keys: ["mod", "Enter"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "message-input-voice-mute-toggle",
|
||||
action: "message-input.action",
|
||||
@@ -833,12 +693,11 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
// --- Parse bindings at module load ---
|
||||
|
||||
function parseBinding(binding: ShortcutBinding): ParsedShortcutBinding {
|
||||
const parsedChord = parseChordString(binding.combo);
|
||||
const lastCombo = parsedChord.at(-1);
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
const parsedCombo = parseShortcutString(binding.combo);
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, parsedChord };
|
||||
return { ...binding, parsedCombo };
|
||||
}
|
||||
|
||||
export const DEFAULT_BINDINGS: readonly ParsedShortcutBinding[] =
|
||||
@@ -852,17 +711,16 @@ export function buildEffectiveBindings(
|
||||
if (override === undefined) {
|
||||
return binding;
|
||||
}
|
||||
let parsedChord: KeyCombo[];
|
||||
let parsedCombo: KeyCombo;
|
||||
try {
|
||||
parsedChord = parseChordString(override);
|
||||
parsedCombo = parseShortcutString(override);
|
||||
} catch {
|
||||
return binding;
|
||||
}
|
||||
const lastCombo = parsedChord.at(-1);
|
||||
if (binding.repeat === false && lastCombo) {
|
||||
lastCombo.repeat = false;
|
||||
if (binding.repeat === false) {
|
||||
parsedCombo.repeat = false;
|
||||
}
|
||||
return { ...binding, combo: override, parsedChord };
|
||||
return { ...binding, combo: override, parsedCombo };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -939,27 +797,6 @@ function resolvePayload(
|
||||
}
|
||||
}
|
||||
|
||||
const CHORD_TIMEOUT_MS = 1500;
|
||||
|
||||
function clearChordTimeout(timeoutId: ReturnType<typeof setTimeout> | null): void {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function createChordTimeout(onChordReset: () => void): ReturnType<typeof setTimeout> {
|
||||
return setTimeout(onChordReset, CHORD_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
function resetChordState(input: ChordState): ChordState {
|
||||
clearChordTimeout(input.timeoutId);
|
||||
return {
|
||||
candidateIndices: [],
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function helpMatchesPlatform(
|
||||
when: ShortcutWhen | undefined,
|
||||
context: KeyboardShortcutPlatformContext,
|
||||
@@ -974,131 +811,24 @@ function helpMatchesPlatform(
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
chordState: ChordState;
|
||||
onChordReset: () => void;
|
||||
bindings?: readonly ParsedShortcutBinding[];
|
||||
}): {
|
||||
match: KeyboardShortcutMatch | null;
|
||||
nextChordState: ChordState;
|
||||
preventDefault: boolean;
|
||||
};
|
||||
export function resolveKeyboardShortcut(input: {
|
||||
event: KeyboardEvent;
|
||||
context: KeyboardShortcutContext;
|
||||
chordState: ChordState;
|
||||
onChordReset: () => void;
|
||||
bindings?: readonly ParsedShortcutBinding[];
|
||||
}): {
|
||||
match: KeyboardShortcutMatch | null;
|
||||
nextChordState: ChordState;
|
||||
preventDefault: boolean;
|
||||
} {
|
||||
const { event, context, chordState, onChordReset, bindings = DEFAULT_BINDINGS } = input;
|
||||
|
||||
if (chordState.step === 0) {
|
||||
const advancingCandidateIndices: number[] = [];
|
||||
let singleComboMatch: KeyboardShortcutMatch | null = null;
|
||||
|
||||
for (const [index, binding] of bindings.entries()) {
|
||||
const firstCombo = binding.parsedChord[0];
|
||||
if (!firstCombo) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesCombo(firstCombo, event, context.isMac)) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesWhen(binding.when, context)) {
|
||||
continue;
|
||||
}
|
||||
if (binding.parsedChord.length > 1) {
|
||||
advancingCandidateIndices.push(index);
|
||||
continue;
|
||||
}
|
||||
if (!singleComboMatch) {
|
||||
singleComboMatch = {
|
||||
action: binding.action,
|
||||
payload: resolvePayload(binding.payload, event),
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (advancingCandidateIndices.length > 0) {
|
||||
return {
|
||||
match: null,
|
||||
nextChordState: {
|
||||
candidateIndices: advancingCandidateIndices,
|
||||
step: 1,
|
||||
timeoutId: createChordTimeout(onChordReset),
|
||||
},
|
||||
preventDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
match: singleComboMatch,
|
||||
nextChordState: resetChordState(chordState),
|
||||
preventDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
const matchingCandidateIndices: number[] = [];
|
||||
let completedMatch: KeyboardShortcutMatch | null = null;
|
||||
|
||||
for (const index of chordState.candidateIndices) {
|
||||
const binding = bindings[index];
|
||||
if (!binding) {
|
||||
continue;
|
||||
}
|
||||
const combo = binding.parsedChord[chordState.step];
|
||||
if (!combo) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesCombo(combo, event, context.isMac)) {
|
||||
}): KeyboardShortcutMatch | null {
|
||||
const { event, context, bindings = DEFAULT_BINDINGS } = input;
|
||||
for (const binding of bindings) {
|
||||
if (!matchesCombo(binding.parsedCombo, event, context.isMac)) {
|
||||
continue;
|
||||
}
|
||||
if (!matchesWhen(binding.when, context)) {
|
||||
continue;
|
||||
}
|
||||
if (chordState.step + 1 === binding.parsedChord.length) {
|
||||
completedMatch = {
|
||||
action: binding.action,
|
||||
payload: resolvePayload(binding.payload, event),
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
matchingCandidateIndices.push(index);
|
||||
}
|
||||
|
||||
if (completedMatch) {
|
||||
return {
|
||||
match: completedMatch,
|
||||
nextChordState: resetChordState(chordState),
|
||||
preventDefault: false,
|
||||
action: binding.action,
|
||||
payload: resolvePayload(binding.payload, event),
|
||||
preventDefault: binding.preventDefault ?? true,
|
||||
stopPropagation: binding.stopPropagation ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
if (matchingCandidateIndices.length > 0) {
|
||||
clearChordTimeout(chordState.timeoutId);
|
||||
return {
|
||||
match: null,
|
||||
nextChordState: {
|
||||
candidateIndices: matchingCandidateIndices,
|
||||
step: chordState.step + 1,
|
||||
timeoutId: createChordTimeout(onChordReset),
|
||||
},
|
||||
preventDefault: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
match: null,
|
||||
nextChordState: resetChordState(chordState),
|
||||
preventDefault: false,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getBindingIdForAction(
|
||||
@@ -1138,11 +868,8 @@ export function buildKeyboardShortcutHelpSections(
|
||||
bindings: readonly ParsedShortcutBinding[] = DEFAULT_BINDINGS,
|
||||
): KeyboardShortcutHelpSection[] {
|
||||
const seenRows = new Set<string>();
|
||||
const rowsBySection = new Map<ShortcutSectionId, KeyboardShortcutHelpRow[]>([
|
||||
["navigation", []],
|
||||
["tabs-panes", []],
|
||||
["projects", []],
|
||||
["panels", []],
|
||||
const rowsBySection = new Map<KeyboardShortcutHelpSection["id"], KeyboardShortcutHelpRow[]>([
|
||||
["global", []],
|
||||
["agent-input", []],
|
||||
]);
|
||||
|
||||
@@ -1172,7 +899,7 @@ export function buildKeyboardShortcutHelpSections(
|
||||
});
|
||||
}
|
||||
|
||||
const sectionOrder: ShortcutSectionId[] = ["navigation", "tabs-panes", "projects", "panels", "agent-input"];
|
||||
const sectionOrder: KeyboardShortcutHelpSection["id"][] = ["global", "agent-input"];
|
||||
|
||||
return sectionOrder.flatMap((sectionId) => {
|
||||
const rows = rowsBySection.get(sectionId) ?? [];
|
||||
|
||||
@@ -36,8 +36,6 @@ KEY_MAP["`"] = { code: "Backquote", key: "`" };
|
||||
KEY_MAP["/"] = { code: "Slash" };
|
||||
KEY_MAP["?"] = { code: "Slash", key: "?" };
|
||||
KEY_MAP["Space"] = { code: "Space", key: " " };
|
||||
KEY_MAP["Enter"] = { code: "Enter", key: "Enter" };
|
||||
KEY_MAP["Backspace"] = { code: "Backspace" };
|
||||
KEY_MAP["Escape"] = { code: "Escape" };
|
||||
KEY_MAP["ArrowLeft"] = { code: "ArrowLeft" };
|
||||
KEY_MAP["ArrowRight"] = { code: "ArrowRight" };
|
||||
@@ -103,10 +101,6 @@ export function parseShortcutString(s: string): KeyCombo {
|
||||
return combo;
|
||||
}
|
||||
|
||||
export function parseChordString(s: string): KeyCombo[] {
|
||||
return s.split(" ").map(parseShortcutString);
|
||||
}
|
||||
|
||||
export function keyComboToString(combo: KeyCombo): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
@@ -126,10 +120,6 @@ export function keyComboToString(combo: KeyCombo): string {
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function chordToString(chord: KeyCombo[]): string {
|
||||
return chord.map(keyComboToString).join(" ");
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
"MetaLeft",
|
||||
"MetaRight",
|
||||
@@ -166,10 +156,6 @@ export function comboStringToShortcutKeys(comboString: string): ShortcutKey[] {
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function chordStringToShortcutKeys(s: string): ShortcutKey[][] {
|
||||
return s.split(" ").map(comboStringToShortcutKeys);
|
||||
}
|
||||
|
||||
export function keyboardEventToComboString(event: KeyboardEvent): string | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null;
|
||||
|
||||
@@ -49,7 +49,7 @@ function useTerminalPanelDescriptor(
|
||||
|
||||
function TerminalPanel() {
|
||||
const isFocused = useIsFocused();
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
const { serverId, workspaceId, target, openTab } = usePaneContext();
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!isFocused) {
|
||||
@@ -60,7 +60,15 @@ function TerminalPanel() {
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
terminalId={target.terminalId}
|
||||
selectedTerminalId={target.terminalId}
|
||||
onSelectedTerminalIdChange={(terminalId) => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
openTab({ kind: "terminal", terminalId });
|
||||
}}
|
||||
hideHeader
|
||||
manageTerminalDirectorySubscription={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
type SettingsSectionId =
|
||||
| "hosts"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "keyboard-shortcuts"
|
||||
| "diagnostics"
|
||||
| "about"
|
||||
| "permissions"
|
||||
@@ -83,7 +83,7 @@ function getSettingsSections(context: { isDesktop: boolean }): SettingsSectionDe
|
||||
const sections: SettingsSectionDef[] = [
|
||||
{ id: "hosts", label: "Hosts", icon: Server },
|
||||
{ id: "appearance", label: "Appearance", icon: Palette },
|
||||
{ id: "shortcuts", label: "Shortcuts", icon: Keyboard },
|
||||
{ id: "keyboard-shortcuts", label: "Keyboard Shortcuts", icon: Keyboard },
|
||||
{ id: "diagnostics", label: "Diagnostics", icon: Stethoscope },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
];
|
||||
@@ -514,7 +514,7 @@ function SettingsSectionContent({
|
||||
return <HostsSection {...hostsProps} />;
|
||||
case "appearance":
|
||||
return <AppearanceSection {...appearanceProps} />;
|
||||
case "shortcuts":
|
||||
case "keyboard-shortcuts":
|
||||
return <KeyboardShortcutsSection />;
|
||||
case "diagnostics":
|
||||
return <DiagnosticsSection {...diagnosticsProps} />;
|
||||
|
||||
@@ -10,31 +10,16 @@ import {
|
||||
getBindingIdForAction,
|
||||
type KeyboardShortcutHelpRow,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
comboStringToShortcutKeys,
|
||||
keyboardEventToComboString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { comboStringToShortcutKeys, keyboardEventToComboString } from "@/keyboard/shortcut-string";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
|
||||
function ShortcutSequence({ chord }: { chord: string[] | null }) {
|
||||
if (!chord || chord.length === 0) {
|
||||
return <Text style={styles.capturingText}>Press shortcut...</Text>;
|
||||
}
|
||||
|
||||
return <Shortcut chord={chord.map(comboStringToShortcutKeys)} />;
|
||||
}
|
||||
|
||||
function ShortcutRow({
|
||||
row,
|
||||
bindingId,
|
||||
overrideCombo,
|
||||
isCapturing,
|
||||
capturedCombos,
|
||||
onRebind,
|
||||
onDone,
|
||||
onCancel,
|
||||
onReset,
|
||||
}: {
|
||||
@@ -42,38 +27,29 @@ function ShortcutRow({
|
||||
bindingId: string | null;
|
||||
overrideCombo: string | undefined;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
onRebind: () => void;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const displayChord = overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys];
|
||||
const displayKeys = overrideCombo ? comboStringToShortcutKeys(overrideCombo) : row.keys;
|
||||
|
||||
return (
|
||||
<View style={[styles.row, isCapturing && styles.rowCapturing]}>
|
||||
<Text style={styles.rowLabel}>{row.label}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
{isCapturing ? (
|
||||
<ShortcutSequence chord={capturedCombos} />
|
||||
<Text style={styles.capturingText}>Press shortcut...</Text>
|
||||
) : (
|
||||
<Shortcut chord={displayChord} />
|
||||
<Shortcut keys={displayKeys} />
|
||||
)}
|
||||
{bindingId !== null && (
|
||||
<>
|
||||
{isCapturing && capturedCombos.length > 0 ? (
|
||||
<Button variant="ghost" size="sm" onPress={onDone}>
|
||||
Done
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={isCapturing ? onCancel : onRebind}
|
||||
>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={isCapturing ? onCancel : onRebind}
|
||||
>
|
||||
{isCapturing ? "Cancel" : "Rebind"}
|
||||
</Button>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
@@ -87,73 +63,51 @@ function ShortcutRow({
|
||||
|
||||
export function KeyboardShortcutsSection() {
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
|
||||
useKeyboardShortcutOverrides();
|
||||
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
|
||||
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const isDesktop = getIsDesktop();
|
||||
const sections = buildKeyboardShortcutHelpSections({ isMac, isDesktop });
|
||||
|
||||
function cancelCapture() {
|
||||
setCapturedCombos([]);
|
||||
setCapturingBindingId(null);
|
||||
setCapturingShortcut(false);
|
||||
}
|
||||
|
||||
function startCapture(bindingId: string) {
|
||||
setCapturedCombos([]);
|
||||
setCapturingBindingId(bindingId);
|
||||
setCapturingShortcut(true);
|
||||
}
|
||||
|
||||
function saveCapture() {
|
||||
if (capturingBindingId === null || capturedCombos.length === 0) {
|
||||
return;
|
||||
}
|
||||
void setOverride(capturingBindingId, capturedCombos.join(" "));
|
||||
cancelCapture();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") return;
|
||||
if (capturingBindingId === null) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const activeBindingId = capturingBindingId;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setCapturingBindingId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Alt" || key === "Control" || key === "Meta" || key === "Shift") {
|
||||
return;
|
||||
}
|
||||
|
||||
const comboString = keyboardEventToComboString(event);
|
||||
if (comboString === null) {
|
||||
return;
|
||||
}
|
||||
if (comboString === null) return;
|
||||
|
||||
setCapturedCombos((current) => [...current, comboString]);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void setOverride(activeBindingId, comboString);
|
||||
setCapturingBindingId(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
};
|
||||
}, [capturingBindingId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setCapturingShortcut(false);
|
||||
};
|
||||
}, [setCapturingShortcut]);
|
||||
}, [capturingBindingId, setOverride]);
|
||||
|
||||
if (Platform.OS !== "web") {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
<View style={[settingsStyles.card, styles.mobileCard]}>
|
||||
<Text style={styles.mobileText}>
|
||||
Keyboard shortcuts are only available on desktop.
|
||||
@@ -166,7 +120,7 @@ export function KeyboardShortcutsSection() {
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={settingsStyles.sectionTitle}>Shortcuts</Text>
|
||||
<Text style={settingsStyles.sectionTitle}>Keyboard Shortcuts</Text>
|
||||
{hasOverrides && (
|
||||
<Button variant="ghost" size="sm" onPress={() => void resetAll()}>
|
||||
Reset all
|
||||
@@ -190,14 +144,8 @@ export function KeyboardShortcutsSection() {
|
||||
bindingId={bindingId}
|
||||
overrideCombo={overrideCombo}
|
||||
isCapturing={capturingBindingId === bindingId}
|
||||
capturedCombos={capturingBindingId === bindingId ? capturedCombos : []}
|
||||
onRebind={() => {
|
||||
if (bindingId) {
|
||||
startCapture(bindingId);
|
||||
}
|
||||
}}
|
||||
onDone={saveCapture}
|
||||
onCancel={cancelCapture}
|
||||
onRebind={() => setCapturingBindingId(bindingId)}
|
||||
onCancel={() => setCapturingBindingId(null)}
|
||||
onReset={() => {
|
||||
if (bindingId) void removeOverride(bindingId);
|
||||
}}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/components/ui/context-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
@@ -309,10 +308,6 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabDropPreviewIndex = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
|
||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
const [tabsActionsWidth, setTabsActionsWidth] = useState<number>(0);
|
||||
|
||||
@@ -453,12 +448,10 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
{newAgentTabKeys ? (
|
||||
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
<Shortcut keys={["mod", "T"]} style={styles.newTabTooltipShortcut} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -474,13 +467,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
{newTerminalTabKeys ? (
|
||||
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -495,13 +483,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<Columns2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
{splitRightKeys ? (
|
||||
<Shortcut chord={splitRightKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane right</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -516,13 +499,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
>
|
||||
<Rows2 size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
{splitDownKeys ? (
|
||||
<Shortcut chord={splitDownKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.newTabTooltipText}>Split pane down</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
|
||||
@@ -1465,9 +1465,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
case "workspace.tab.new":
|
||||
handleCreateDraftTab();
|
||||
return true;
|
||||
case "workspace.terminal.new":
|
||||
handleCreateTerminal();
|
||||
return true;
|
||||
case "workspace.tab.close-current":
|
||||
if (activeTabId) {
|
||||
void handleCloseTabById(activeTabId);
|
||||
@@ -1496,7 +1493,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, navigateToTabId, tabs],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
@@ -1601,7 +1598,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
"workspace.tab.close-current",
|
||||
"workspace.tab.navigate-index",
|
||||
"workspace.tab.navigate-relative",
|
||||
"workspace.terminal.new",
|
||||
] as const,
|
||||
enabled: Boolean(normalizedServerId && normalizedWorkspaceId),
|
||||
priority: 100,
|
||||
|
||||
@@ -4,9 +4,7 @@ import { useKeyboardShortcutsStore } from "./keyboard-shortcuts-store";
|
||||
beforeEach(() => {
|
||||
useKeyboardShortcutsStore.setState({
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
@@ -20,10 +18,4 @@ describe("keyboard-shortcuts-store", () => {
|
||||
useKeyboardShortcutsStore.getState().setCommandCenterOpen(true);
|
||||
expect(useKeyboardShortcutsStore.getState().commandCenterOpen).toBe(true);
|
||||
});
|
||||
|
||||
it("toggles shortcut capture state", () => {
|
||||
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(false);
|
||||
useKeyboardShortcutsStore.getState().setCapturingShortcut(true);
|
||||
expect(useKeyboardShortcutsStore.getState().capturingShortcut).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ interface KeyboardShortcutsState {
|
||||
commandCenterOpen: boolean;
|
||||
projectPickerOpen: boolean;
|
||||
shortcutsDialogOpen: boolean;
|
||||
capturingShortcut: boolean;
|
||||
altDown: boolean;
|
||||
cmdOrCtrlDown: boolean;
|
||||
/** Sidebar-visible workspace targets (up to 9), in top-to-bottom visual order. */
|
||||
@@ -16,7 +15,6 @@ interface KeyboardShortcutsState {
|
||||
setCommandCenterOpen: (open: boolean) => void;
|
||||
setProjectPickerOpen: (open: boolean) => void;
|
||||
setShortcutsDialogOpen: (open: boolean) => void;
|
||||
setCapturingShortcut: (capturing: boolean) => void;
|
||||
setAltDown: (down: boolean) => void;
|
||||
setCmdOrCtrlDown: (down: boolean) => void;
|
||||
setSidebarShortcutWorkspaceTargets: (targets: SidebarShortcutWorkspaceTarget[]) => void;
|
||||
@@ -28,7 +26,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
|
||||
commandCenterOpen: false,
|
||||
projectPickerOpen: false,
|
||||
shortcutsDialogOpen: false,
|
||||
capturingShortcut: false,
|
||||
altDown: false,
|
||||
cmdOrCtrlDown: false,
|
||||
sidebarShortcutWorkspaceTargets: [],
|
||||
@@ -37,7 +34,6 @@ export const useKeyboardShortcutsStore = create<KeyboardShortcutsState>((set) =>
|
||||
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||
setProjectPickerOpen: (open) => set({ projectPickerOpen: open }),
|
||||
setShortcutsDialogOpen: (open) => set({ shortcutsDialogOpen: open }),
|
||||
setCapturingShortcut: (capturing) => set({ capturingShortcut: capturing }),
|
||||
setAltDown: (down) => set({ altDown: down }),
|
||||
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||
setSidebarShortcutWorkspaceTargets: (targets) =>
|
||||
|
||||
@@ -2,20 +2,8 @@ export type ShortcutKey = "mod" | "shift" | "alt" | "ctrl" | "meta" | string;
|
||||
|
||||
export type ShortcutOs = "mac" | "non-mac";
|
||||
|
||||
const KEY_DISPLAY: Record<string, string> = {
|
||||
Backspace: "⌫",
|
||||
Enter: "↩",
|
||||
Esc: "Esc",
|
||||
Space: "␣",
|
||||
Left: "←",
|
||||
Right: "→",
|
||||
Up: "↑",
|
||||
Down: "↓",
|
||||
};
|
||||
|
||||
function normalizeKey(key: string): string {
|
||||
if (!key) return "";
|
||||
if (KEY_DISPLAY[key]) return KEY_DISPLAY[key];
|
||||
if (key.length === 1) return key.toUpperCase();
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface SidebarProjectWorkspaceLinkRowModel {
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
kind: "project_section";
|
||||
chevron: "expand" | "collapse" | null;
|
||||
chevron: "expand" | "collapse";
|
||||
trailingAction: "new_worktree" | "none";
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export type SidebarProjectRowModel =
|
||||
| SidebarProjectSectionRowModel;
|
||||
|
||||
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
|
||||
return project.workspaces.length === 1 && project.projectKind !== "git";
|
||||
return project.workspaces.length === 1;
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
@@ -50,12 +50,9 @@ export function buildSidebarProjectRowModel(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const collapsible =
|
||||
input.project.projectKind === "git" || input.project.workspaces.length > 1;
|
||||
|
||||
return {
|
||||
kind: "project_section",
|
||||
chevron: collapsible ? (input.collapsed ? "expand" : "collapse") : null,
|
||||
chevron: input.collapsed ? "expand" : "collapse",
|
||||
trailingAction: input.project.projectKind === "git" ? "new_worktree" : "none",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_PATH=$0
|
||||
case "$SCRIPT_PATH" in
|
||||
/*) ;;
|
||||
*) SCRIPT_PATH="$(pwd)/$SCRIPT_PATH" ;;
|
||||
esac
|
||||
|
||||
# Follow symlinks so `/usr/local/bin/paseo` resolves back into the app bundle.
|
||||
while [ -L "$SCRIPT_PATH" ]; do
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
|
||||
LINK_TARGET=$(readlink "$SCRIPT_PATH")
|
||||
case "$LINK_TARGET" in
|
||||
/*) SCRIPT_PATH="$LINK_TARGET" ;;
|
||||
*) SCRIPT_PATH="${SCRIPT_DIR}/${LINK_TARGET}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$SCRIPT_PATH")" && pwd)
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
RESOURCES_DIR=$(CDPATH= cd -- "${SCRIPT_DIR}/.." && pwd)
|
||||
|
||||
# macOS: MacOS/Paseo is a sibling of Resources/
|
||||
@@ -31,4 +15,6 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec env PASEO_DESKTOP_CLI=1 "${APP_EXECUTABLE}" "$@"
|
||||
CLI_ENTRY="${RESOURCES_DIR}/app.asar/node_modules/@getpaseo/cli/dist/index.js"
|
||||
|
||||
exec env ELECTRON_RUN_AS_NODE=1 "${APP_EXECUTABLE}" "${CLI_ENTRY}" "$@"
|
||||
|
||||
@@ -4,10 +4,12 @@ setlocal
|
||||
set "SCRIPT_DIR=%~dp0"
|
||||
set "RESOURCES_DIR=%SCRIPT_DIR%.."
|
||||
set "APP_EXECUTABLE=%RESOURCES_DIR%\..\Paseo.exe"
|
||||
set "CLI_ENTRY=%RESOURCES_DIR%\app.asar\node_modules\@getpaseo\cli\dist\index.js"
|
||||
|
||||
if not exist "%APP_EXECUTABLE%" (
|
||||
echo Bundled Paseo executable not found at %APP_EXECUTABLE% 1>&2
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "PASEO_DESKTOP_CLI=1"
|
||||
"%APP_EXECUTABLE%" %*
|
||||
set "ELECTRON_RUN_AS_NODE=1"
|
||||
"%APP_EXECUTABLE%" "%CLI_ENTRY%" %*
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"dev": "npm run build:main && wait-on tcp:8081 && electron .",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
sendLocalTransportMessage,
|
||||
closeLocalTransportSession,
|
||||
} from "./local-transport.js";
|
||||
import { createNodeEntrypointInvocation, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
import { createElectronNodeEnv, resolveDaemonRunnerEntrypoint } from "./runtime-paths.js";
|
||||
|
||||
const DAEMON_LOG_FILENAME = "daemon.log";
|
||||
const DAEMON_PID_FILENAME = "paseo.pid";
|
||||
@@ -293,23 +293,17 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||
const home = getPaseoHome();
|
||||
const daemonRunner = resolveDaemonRunnerEntrypoint();
|
||||
const corsOrigins = buildDesktopDaemonCorsOriginsEnv();
|
||||
const invocation = createNodeEntrypointInvocation({
|
||||
entrypoint: daemonRunner,
|
||||
argvMode: "node-script",
|
||||
args: [],
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
PASEO_HOME: home,
|
||||
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const child: ChildProcess = spawn(
|
||||
invocation.command,
|
||||
invocation.args,
|
||||
process.execPath,
|
||||
[...daemonRunner.execArgv, daemonRunner.entryPath],
|
||||
{
|
||||
detached: true,
|
||||
env: invocation.env,
|
||||
env: createElectronNodeEnv({
|
||||
...process.env,
|
||||
PASEO_HOME: home,
|
||||
...(corsOrigins ? { PASEO_CORS_ORIGINS: corsOrigins } : {}),
|
||||
}),
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createNodeEntrypointInvocation,
|
||||
parseCliPassthroughArgsFromArgv,
|
||||
type NodeEntrypointSpec,
|
||||
} from "./node-entrypoint-launcher";
|
||||
|
||||
const CLI_ENTRYPOINT: NodeEntrypointSpec = {
|
||||
entryPath: "/tmp/paseo-cli.js",
|
||||
execArgv: ["--import", "tsx"],
|
||||
};
|
||||
|
||||
describe("node-entrypoint-launcher", () => {
|
||||
describe("parseCliPassthroughArgsFromArgv", () => {
|
||||
it("returns null when no CLI args are provided", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores macOS GUI launch arguments", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "-psn_0_12345"],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores --no-sandbox injected by Linux wrapper", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/usr/bin/Paseo", "--no-sandbox", "status"],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toEqual(["status"]);
|
||||
});
|
||||
|
||||
it("returns null when only --no-sandbox is present", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/usr/bin/Paseo", "--no-sandbox"],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves CLI flags for direct app invocations", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo", "--version"],
|
||||
isDefaultApp: false,
|
||||
forceCli: false,
|
||||
}),
|
||||
).toEqual(["--version"]);
|
||||
});
|
||||
|
||||
it("forces CLI mode for shim launches even without args", () => {
|
||||
expect(
|
||||
parseCliPassthroughArgsFromArgv({
|
||||
argv: ["/Applications/Paseo.app/Contents/MacOS/Paseo"],
|
||||
isDefaultApp: false,
|
||||
forceCli: true,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createNodeEntrypointInvocation", () => {
|
||||
it("uses the packaged runner when the desktop app is packaged", () => {
|
||||
expect(
|
||||
createNodeEntrypointInvocation({
|
||||
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
isPackaged: true,
|
||||
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
entrypoint: CLI_ENTRYPOINT,
|
||||
argvMode: "bare",
|
||||
args: ["ls", "--json"],
|
||||
baseEnv: { PATH: "/usr/bin" },
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
args: [
|
||||
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
"bare",
|
||||
"/tmp/paseo-cli.js",
|
||||
"ls",
|
||||
"--json",
|
||||
],
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the entrypoint directly in development", () => {
|
||||
expect(
|
||||
createNodeEntrypointInvocation({
|
||||
execPath: "/opt/homebrew/bin/electron",
|
||||
isPackaged: false,
|
||||
packagedRunnerPath: null,
|
||||
entrypoint: CLI_ENTRYPOINT,
|
||||
argvMode: "bare",
|
||||
args: ["ls"],
|
||||
baseEnv: { PATH: "/usr/bin" },
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/opt/homebrew/bin/electron",
|
||||
args: ["--import", "tsx", "/tmp/paseo-cli.js", "ls"],
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps node-style argv for packaged script entrypoints", () => {
|
||||
expect(
|
||||
createNodeEntrypointInvocation({
|
||||
execPath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
isPackaged: true,
|
||||
packagedRunnerPath: "/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
entrypoint: CLI_ENTRYPOINT,
|
||||
argvMode: "node-script",
|
||||
args: ["--dev"],
|
||||
baseEnv: { PATH: "/usr/bin" },
|
||||
}),
|
||||
).toEqual({
|
||||
command: "/Applications/Paseo.app/Contents/MacOS/Paseo",
|
||||
args: [
|
||||
"/Applications/Paseo.app/Contents/Resources/app.asar/dist/daemon/node-entrypoint-runner.js",
|
||||
"node-script",
|
||||
"/tmp/paseo-cli.js",
|
||||
"--dev",
|
||||
],
|
||||
env: {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
const IGNORED_ARG_PREFIXES = ["-psn_", "--no-sandbox"];
|
||||
|
||||
export const DESKTOP_CLI_ENV = "PASEO_DESKTOP_CLI";
|
||||
|
||||
export type NodeEntrypointSpec = {
|
||||
entryPath: string;
|
||||
execArgv: string[];
|
||||
};
|
||||
|
||||
export type NodeEntrypointInvocation = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
export type NodeEntrypointArgvMode = "bare" | "node-script";
|
||||
|
||||
type CreateNodeEntrypointInvocationInput = {
|
||||
execPath: string;
|
||||
isPackaged: boolean;
|
||||
packagedRunnerPath: string | null;
|
||||
entrypoint: NodeEntrypointSpec;
|
||||
argvMode: NodeEntrypointArgvMode;
|
||||
args: string[];
|
||||
baseEnv: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type ParseCliPassthroughArgsFromArgvInput = {
|
||||
argv: string[];
|
||||
isDefaultApp: boolean;
|
||||
forceCli: boolean;
|
||||
};
|
||||
|
||||
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCliPassthroughArgsFromArgv(
|
||||
input: ParseCliPassthroughArgsFromArgvInput,
|
||||
): string[] | null {
|
||||
const startIndex = input.isDefaultApp ? 2 : 1;
|
||||
const effective = input.argv
|
||||
.slice(startIndex)
|
||||
.filter((arg) => !IGNORED_ARG_PREFIXES.some((p) => arg.startsWith(p)));
|
||||
|
||||
if (input.forceCli) {
|
||||
return effective;
|
||||
}
|
||||
|
||||
return effective.length > 0 ? effective : null;
|
||||
}
|
||||
|
||||
export function createNodeEntrypointInvocation(
|
||||
input: CreateNodeEntrypointInvocationInput,
|
||||
): NodeEntrypointInvocation {
|
||||
const env = createElectronNodeEnv(input.baseEnv);
|
||||
|
||||
if (input.isPackaged) {
|
||||
if (!input.packagedRunnerPath) {
|
||||
throw new Error("Packaged node entrypoint runner is required for desktop launches.");
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.execPath,
|
||||
args: [input.packagedRunnerPath, input.argvMode, input.entrypoint.entryPath, ...input.args],
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.execPath,
|
||||
args: [...input.entrypoint.execArgv, input.entrypoint.entryPath, ...input.args],
|
||||
env,
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const [argvMode, entryPath, ...args] = process.argv.slice(2);
|
||||
if (argvMode !== "bare" && argvMode !== "node-script") {
|
||||
throw new Error(`Unsupported node entrypoint argv mode: ${argvMode ?? "<missing>"}`);
|
||||
}
|
||||
if (!entryPath) {
|
||||
throw new Error("Missing node entrypoint path.");
|
||||
}
|
||||
|
||||
process.argv =
|
||||
argvMode === "bare" ? [process.argv[0] ?? "node", ...args] : [process.argv[0] ?? "node", entryPath, ...args];
|
||||
await import(pathToFileURL(entryPath).href);
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -3,23 +3,21 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
DESKTOP_CLI_ENV,
|
||||
createNodeEntrypointInvocation as createSharedNodeEntrypointInvocation,
|
||||
parseCliPassthroughArgsFromArgv as parseCliPassthroughArgs,
|
||||
type NodeEntrypointArgvMode,
|
||||
type NodeEntrypointInvocation,
|
||||
type NodeEntrypointSpec,
|
||||
} from "./node-entrypoint-launcher.js";
|
||||
|
||||
const CLI_PACKAGE_NAME = "@getpaseo/cli";
|
||||
const SERVER_PACKAGE_NAME = "@getpaseo/server";
|
||||
const CLI_BIN_ENTRY = `${CLI_PACKAGE_NAME}/bin/paseo`;
|
||||
const IGNORED_GUI_LAUNCH_ARG_PREFIX = "-psn_";
|
||||
|
||||
type PackageInfo = {
|
||||
root: string;
|
||||
};
|
||||
|
||||
export type NodeEntrypointSpec = {
|
||||
entryPath: string;
|
||||
execArgv: string[];
|
||||
};
|
||||
|
||||
const esmRequire = createRequire(__filename);
|
||||
|
||||
function findPackageRootFromResolvedPath(input: {
|
||||
@@ -75,10 +73,6 @@ function resolvePackagedAsarPath(): string {
|
||||
return path.join(process.resourcesPath, "app.asar");
|
||||
}
|
||||
|
||||
function resolvePackagedNodeEntrypointRunnerPath(): string {
|
||||
return path.join(resolvePackagedAsarPath(), "dist", "daemon", "node-entrypoint-runner.js");
|
||||
}
|
||||
|
||||
function assertPathExists(input: { label: string; filePath: string }): string {
|
||||
if (!existsSync(input.filePath)) {
|
||||
throw new Error(`${input.label} is missing at ${input.filePath}`);
|
||||
@@ -88,11 +82,16 @@ function assertPathExists(input: { label: string; filePath: string }): string {
|
||||
}
|
||||
|
||||
export function parseCliPassthroughArgsFromArgv(argv: string[]): string[] | null {
|
||||
return parseCliPassthroughArgs({
|
||||
argv,
|
||||
isDefaultApp: process.defaultApp,
|
||||
forceCli: process.env[DESKTOP_CLI_ENV] === "1",
|
||||
});
|
||||
const startIndex = process.defaultApp ? 2 : 1;
|
||||
const effective = argv
|
||||
.slice(startIndex)
|
||||
.filter(
|
||||
(arg) =>
|
||||
!arg.startsWith(IGNORED_GUI_LAUNCH_ARG_PREFIX) &&
|
||||
!arg.startsWith("--"),
|
||||
);
|
||||
|
||||
return effective.length > 0 ? effective : null;
|
||||
}
|
||||
|
||||
export function resolveDaemonRunnerEntrypoint(): NodeEntrypointSpec {
|
||||
@@ -168,39 +167,18 @@ export function resolveCliEntrypoint(): NodeEntrypointSpec {
|
||||
};
|
||||
}
|
||||
|
||||
export function createNodeEntrypointInvocation(input: {
|
||||
entrypoint: NodeEntrypointSpec;
|
||||
argvMode: NodeEntrypointArgvMode;
|
||||
args: string[];
|
||||
baseEnv: NodeJS.ProcessEnv;
|
||||
}): NodeEntrypointInvocation {
|
||||
return createSharedNodeEntrypointInvocation({
|
||||
execPath: process.execPath,
|
||||
isPackaged: app.isPackaged,
|
||||
packagedRunnerPath: app.isPackaged
|
||||
? assertPathExists({
|
||||
label: "Bundled node entrypoint runner",
|
||||
filePath: resolvePackagedNodeEntrypointRunnerPath(),
|
||||
})
|
||||
: null,
|
||||
entrypoint: input.entrypoint,
|
||||
argvMode: input.argvMode,
|
||||
args: input.args,
|
||||
baseEnv: input.baseEnv,
|
||||
});
|
||||
export function createElectronNodeEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...baseEnv,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
};
|
||||
}
|
||||
|
||||
function spawnCliProcess(args: string[]): SpawnSyncReturns<Buffer> {
|
||||
const cli = resolveCliEntrypoint();
|
||||
const invocation = createNodeEntrypointInvocation({
|
||||
entrypoint: cli,
|
||||
argvMode: "bare",
|
||||
args,
|
||||
baseEnv: process.env,
|
||||
});
|
||||
|
||||
return spawnSync(invocation.command, invocation.args, {
|
||||
env: invocation.env,
|
||||
return spawnSync(process.execPath, [...cli.execArgv, cli.entryPath, ...args], {
|
||||
env: createElectronNodeEnv(process.env),
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "expo-module build",
|
||||
"clean": "expo-module clean",
|
||||
"dev": "expo-module build",
|
||||
"lint": "expo-module lint",
|
||||
"test": "expo-module test",
|
||||
"format": "biome check .",
|
||||
|
||||
@@ -10,7 +10,7 @@ dotenv.config({
|
||||
const daemonRunnerEntry = fileURLToPath(new URL("./daemon-runner.ts", import.meta.url));
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--inspect", "--heapsnapshot-near-heap-limit=3", ...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
|
||||
[...process.execArgv, daemonRunnerEntry, "--dev", ...process.argv.slice(2)],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
|
||||
@@ -1588,6 +1588,34 @@ describe("AgentManager", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("notifyAgentState does not mutate updatedAt", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const before = manager.getAgent(snapshot.id);
|
||||
expect(before).toBeDefined();
|
||||
|
||||
manager.notifyAgentState(snapshot.id);
|
||||
|
||||
const after = manager.getAgent(snapshot.id);
|
||||
expect(after).toBeDefined();
|
||||
expect(after!.updatedAt.getTime()).toBe(before!.updatedAt.getTime());
|
||||
});
|
||||
|
||||
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -247,6 +247,7 @@ type LiveEventStreamingSession = AgentSession & {
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const LIVE_BACKLOG_TERMINAL_REPLAY_DELAY_MS = 300;
|
||||
const RECENT_USER_MESSAGE_REPLAY_WINDOW_MS = 15_000;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
|
||||
@@ -790,7 +791,6 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, modeId };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -807,7 +807,6 @@ export class AgentManager {
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -823,7 +822,6 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -842,7 +840,6 @@ export class AgentManager {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
await this.persistSnapshot(agent);
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -851,7 +848,6 @@ export class AgentManager {
|
||||
if (!agent || agent.internal) {
|
||||
return;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
@@ -935,6 +931,66 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
classifyRecordedUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string },
|
||||
): "new" | "duplicate" | "conflict" {
|
||||
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
||||
if (!normalizedMessageId) {
|
||||
return "new";
|
||||
}
|
||||
|
||||
const agent = this.requireAgent(agentId);
|
||||
for (const row of agent.timelineRows) {
|
||||
if (row.item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
const rowMessageId = normalizeMessageId(row.item.messageId);
|
||||
if (rowMessageId !== normalizedMessageId) {
|
||||
continue;
|
||||
}
|
||||
return row.item.text === text ? "duplicate" : "conflict";
|
||||
}
|
||||
|
||||
if (this.isRecentUserMessageReplay(agent, text)) {
|
||||
return "duplicate";
|
||||
}
|
||||
|
||||
return "new";
|
||||
}
|
||||
|
||||
private isRecentUserMessageReplay(agent: ManagedAgent, text: string): boolean {
|
||||
let latestUserMessageRow: AgentTimelineRow | null = null;
|
||||
for (let index = agent.timelineRows.length - 1; index >= 0; index -= 1) {
|
||||
const row = agent.timelineRows[index];
|
||||
if (!row || row.item.type !== "user_message") {
|
||||
continue;
|
||||
}
|
||||
latestUserMessageRow = row;
|
||||
break;
|
||||
}
|
||||
|
||||
const latestUserMessageItem =
|
||||
latestUserMessageRow?.item.type === "user_message" ? latestUserMessageRow.item : null;
|
||||
if (!latestUserMessageRow || !latestUserMessageItem || latestUserMessageItem.text !== text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (agent.lifecycle === "running" || agent.pendingRun || agent.pendingReplacement) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const rowTimestampMs = Date.parse(latestUserMessageRow.timestamp);
|
||||
const latestActivityMs = Math.max(
|
||||
Number.isFinite(rowTimestampMs) ? rowTimestampMs : 0,
|
||||
agent.updatedAt.getTime(),
|
||||
agent.lastUserMessageAt?.getTime() ?? 0,
|
||||
);
|
||||
|
||||
return Date.now() - latestActivityMs <= RECENT_USER_MESSAGE_REPLAY_WINDOW_MS;
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -1058,7 +1114,6 @@ export class AgentManager {
|
||||
"streamAgent.finalize: applying terminal state",
|
||||
);
|
||||
if (!shouldHoldBusyForReplacement) {
|
||||
this.touchUpdatedAt(mutableAgent);
|
||||
this.emitState(mutableAgent);
|
||||
this.flushLiveEventBacklog(mutableAgent);
|
||||
}
|
||||
@@ -1155,7 +1210,6 @@ export class AgentManager {
|
||||
const lifecycle = (latestActive as { lifecycle: AgentLifecycleStatus }).lifecycle;
|
||||
if (!hasForegroundRun && lifecycle === "running") {
|
||||
latestActive.lifecycle = "idle";
|
||||
self.touchUpdatedAt(latestActive);
|
||||
self.emitState(latestActive);
|
||||
self.flushLiveEventBacklog(latestActive);
|
||||
}
|
||||
@@ -1313,7 +1367,6 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
agent.pendingPermissions.clear();
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
@@ -97,4 +98,47 @@ describe("Codex app-server provider", () => {
|
||||
expect(item.detail.newString).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not buffer terminal Codex events before turn_started", () => {
|
||||
const terminalEvents: AgentStreamEvent[] = [
|
||||
{ type: "turn_completed", provider: "codex", usage: undefined },
|
||||
{ type: "turn_failed", provider: "codex", error: "boom" },
|
||||
{ type: "turn_canceled", provider: "codex", reason: "interrupted" },
|
||||
];
|
||||
|
||||
for (const event of terminalEvents) {
|
||||
expect(__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted(event)).toBe(false);
|
||||
}
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldBufferCodexEventUntilTurnStarted({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "stale chunk" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("only synthesizes completion from idle thread status while a run is active", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "idle",
|
||||
hasActiveRun: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "idle",
|
||||
hasActiveRun: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
__codexAppServerInternals.shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: "running",
|
||||
hasActiveRun: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1195,6 +1195,18 @@ const ThreadStartedNotificationSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ThreadStatusChangedNotificationSchema = z
|
||||
.object({
|
||||
threadId: z.string().optional(),
|
||||
status: z
|
||||
.object({
|
||||
type: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const TurnStartedNotificationSchema = z
|
||||
.object({
|
||||
turn: z.object({ id: z.string() }).passthrough(),
|
||||
@@ -1402,6 +1414,16 @@ const ItemFileChangeOutputDeltaNotificationSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ItemCommandExecutionOutputDeltaNotificationSchema = z
|
||||
.object({
|
||||
itemId: z.string().optional(),
|
||||
callId: z.string().optional(),
|
||||
stream: z.string().optional(),
|
||||
delta: z.string().optional(),
|
||||
chunk: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexEventTurnDiffNotificationSchema = z
|
||||
.object({
|
||||
msg: z
|
||||
@@ -1416,6 +1438,7 @@ const CodexEventTurnDiffNotificationSchema = z
|
||||
|
||||
type ParsedCodexNotification =
|
||||
| { kind: "thread_started"; threadId: string }
|
||||
| { kind: "thread_status_changed"; threadId: string | null; statusType: string | null }
|
||||
| { kind: "turn_started"; turnId: string }
|
||||
| { kind: "turn_completed"; status: string; errorMessage: string | null }
|
||||
| { kind: "plan_updated"; plan: Array<{ step: string | null; status: string | null }> }
|
||||
@@ -1499,6 +1522,25 @@ const CodexNotificationSchema = z.union([
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("thread/status/changed"),
|
||||
params: ThreadStatusChangedNotificationSchema,
|
||||
})
|
||||
.transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "thread_status_changed",
|
||||
threadId: params.threadId ?? null,
|
||||
statusType: params.status?.type ?? null,
|
||||
}),
|
||||
),
|
||||
z.object({ method: z.literal("thread/status/changed"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({
|
||||
kind: "invalid_payload",
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({ method: z.literal("turn/started"), params: TurnStartedNotificationSchema })
|
||||
.transform(
|
||||
@@ -1872,6 +1914,28 @@ const CodexNotificationSchema = z.union([
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("item/commandExecution/outputDelta"),
|
||||
params: ItemCommandExecutionOutputDeltaNotificationSchema,
|
||||
})
|
||||
.transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "exec_command_output_delta",
|
||||
callId: params.callId ?? params.itemId ?? null,
|
||||
stream: params.stream ?? null,
|
||||
chunk: params.delta ?? params.chunk ?? null,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({ method: z.literal("item/commandExecution/outputDelta"), params: z.unknown() })
|
||||
.transform(
|
||||
({ method, params }): ParsedCodexNotification => ({
|
||||
kind: "invalid_payload",
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("codex/event/turn_diff"),
|
||||
@@ -2034,8 +2098,37 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
shouldBufferCodexEventUntilTurnStarted,
|
||||
shouldSynthesizeTurnCompletionFromThreadStatus,
|
||||
};
|
||||
|
||||
function isTerminalAgentStreamEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldBufferCodexEventUntilTurnStarted(event: AgentStreamEvent): boolean {
|
||||
if (
|
||||
event.type === "permission_requested" ||
|
||||
event.type === "permission_resolved" ||
|
||||
event.type === "turn_started" ||
|
||||
isTerminalAgentStreamEvent(event)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldSynthesizeTurnCompletionFromThreadStatus(input: {
|
||||
statusType: string | null;
|
||||
hasActiveRun: boolean;
|
||||
}): boolean {
|
||||
return input.hasActiveRun && input.statusType === "idle";
|
||||
}
|
||||
|
||||
class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
@@ -2074,6 +2167,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private runAwaitingTerminal = false;
|
||||
private connected = false;
|
||||
private collaborationModes: Array<{
|
||||
name: string;
|
||||
@@ -2500,33 +2594,29 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
||||
this.runAwaitingTerminal = true;
|
||||
|
||||
let sawTurnStarted = false;
|
||||
for await (const event of queue) {
|
||||
// Drop pre-start timeline noise that can leak from the previous turn.
|
||||
// Keep permission events, which can legitimately arrive before turn_started.
|
||||
// Keep permission and terminal events, which can legitimately arrive
|
||||
// before turn_started on resumed/replayed requests.
|
||||
if (!sawTurnStarted) {
|
||||
if (event.type === "permission_requested" || event.type === "permission_resolved") {
|
||||
yield event;
|
||||
if (shouldBufferCodexEventUntilTurnStarted(event)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "turn_started") {
|
||||
sawTurnStarted = true;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
yield event;
|
||||
if (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
if (isTerminalAgentStreamEvent(event)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.runAwaitingTerminal = false;
|
||||
if (this.eventQueue === queue) {
|
||||
this.eventQueue = null;
|
||||
}
|
||||
@@ -2860,6 +2950,31 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "thread_status_changed") {
|
||||
if (
|
||||
shouldSynthesizeTurnCompletionFromThreadStatus({
|
||||
statusType: parsed.statusType,
|
||||
hasActiveRun: this.runAwaitingTerminal,
|
||||
})
|
||||
) {
|
||||
this.runAwaitingTerminal = false;
|
||||
this.currentTurnId = null;
|
||||
this.emittedItemStartedIds.clear();
|
||||
this.emittedItemCompletedIds.clear();
|
||||
this.emittedExecCommandStartedCallIds.clear();
|
||||
this.emittedExecCommandCompletedCallIds.clear();
|
||||
this.pendingCommandOutputDeltas.clear();
|
||||
this.pendingFileChangeOutputDeltas.clear();
|
||||
this.warnedIncompleteEditToolCallIds.clear();
|
||||
this.emitEvent({
|
||||
type: "turn_completed",
|
||||
provider: CODEX_PROVIDER,
|
||||
usage: this.latestUsage,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "turn_started") {
|
||||
this.currentTurnId = parsed.turnId;
|
||||
this.emittedItemStartedIds.clear();
|
||||
@@ -2874,6 +2989,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
if (parsed.kind === "turn_completed") {
|
||||
this.runAwaitingTerminal = false;
|
||||
this.currentTurnId = null;
|
||||
if (parsed.status === "failed") {
|
||||
this.emitEvent({
|
||||
type: "turn_failed",
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from "vitest";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
|
||||
type AgentUpdatePayload = AgentUpdateMessage["payload"];
|
||||
type AgentUpsertPayload = Extract<AgentUpdatePayload, { kind: "upsert" }>;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function collectAgentUpdates(client: DaemonClient): {
|
||||
updates: AgentUpdatePayload[];
|
||||
unsub: () => void;
|
||||
} {
|
||||
const updates: AgentUpdatePayload[] = [];
|
||||
const unsub = client.on("agent_update", (message) => {
|
||||
if (message.type === "agent_update") {
|
||||
updates.push(message.payload);
|
||||
}
|
||||
});
|
||||
return { updates, unsub };
|
||||
}
|
||||
|
||||
function lastUpsertFor(updates: AgentUpdatePayload[], agentId: string): AgentUpsertPayload | undefined {
|
||||
return updates
|
||||
.filter((u): u is AgentUpsertPayload => u.kind === "upsert" && u.agent.id === agentId)
|
||||
.at(-1);
|
||||
}
|
||||
|
||||
describe("mode-switch update propagation", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.cleanup();
|
||||
}, 30000);
|
||||
|
||||
test("agent_update event arrives with updated currentModeId after setAgentMode", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
expect(agent.currentModeId).toBe("default");
|
||||
|
||||
await sleep(300);
|
||||
const beforeCount = updates.length;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await sleep(500);
|
||||
|
||||
const modeUpdate = updates
|
||||
.slice(beforeCount)
|
||||
.find(
|
||||
(u): u is AgentUpsertPayload =>
|
||||
u.kind === "upsert" &&
|
||||
u.agent.id === agent.id &&
|
||||
u.agent.currentModeId === "bypassPermissions",
|
||||
);
|
||||
|
||||
expect(modeUpdate).toBeDefined();
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("updatedAt is bumped when mode changes", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await sleep(300);
|
||||
|
||||
const updatedAtBefore = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await sleep(500);
|
||||
|
||||
const updatedAtAfter = lastUpsertFor(updates, agent.id)?.agent.updatedAt;
|
||||
|
||||
expect(updatedAtBefore).toBeTruthy();
|
||||
expect(updatedAtAfter).toBeTruthy();
|
||||
expect(updatedAtAfter).not.toBe(updatedAtBefore);
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("mode switch is visible in second client bootstrap snapshot", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
await sleep(200);
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "plan");
|
||||
await sleep(200);
|
||||
|
||||
const client2 = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
});
|
||||
await client2.connect();
|
||||
|
||||
const bootstrapResult = await client2.fetchAgents({
|
||||
subscribe: { subscriptionId: "bootstrap-test" },
|
||||
});
|
||||
|
||||
const found = bootstrapResult.entries.find((e) => e.agent.id === agent.id);
|
||||
expect(found?.agent.currentModeId).toBe("plan");
|
||||
|
||||
const { updates: client2Updates, unsub } = collectAgentUpdates(client2);
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "acceptEdits");
|
||||
await sleep(500);
|
||||
|
||||
const modeUpdate = client2Updates.find(
|
||||
(u): u is AgentUpsertPayload =>
|
||||
u.kind === "upsert" &&
|
||||
u.agent.id === agent.id &&
|
||||
u.agent.currentModeId === "acceptEdits",
|
||||
);
|
||||
expect(modeUpdate).toBeDefined();
|
||||
|
||||
unsub();
|
||||
await client2.close();
|
||||
});
|
||||
|
||||
test("rapid mode switches settle to the final value", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
await ctx.client.setAgentMode(agent.id, "plan");
|
||||
await ctx.client.setAgentMode(agent.id, "acceptEdits");
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("acceptEdits");
|
||||
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("mode switch immediately after creation reflects correct mode", async () => {
|
||||
const { updates, unsub } = collectAgentUpdates(ctx.client);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
modeId: "default",
|
||||
});
|
||||
await ctx.client.setAgentMode(agent.id, "bypassPermissions");
|
||||
|
||||
await sleep(500);
|
||||
|
||||
expect(lastUpsertFor(updates, agent.id)?.agent.currentModeId).toBe("bypassPermissions");
|
||||
|
||||
unsub();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "send-message-id-replay-"));
|
||||
}
|
||||
|
||||
describe("send_agent_message_request replay", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test("does not start a second run when the same client messageId is replayed", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate message replay",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
const messageId = "msg-replay-1";
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterFirst?.agent.status).toBe("idle");
|
||||
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||
expect(firstUpdatedAt).not.toBeNull();
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterReplay?.agent.status).toBe("idle");
|
||||
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not start a second run when identical text is replayed with a new messageId right after finish", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate text replay after finish",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-1" });
|
||||
await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
|
||||
const afterFirst = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterFirst?.agent.status).toBe("idle");
|
||||
const firstUpdatedAt = afterFirst?.agent.updatedAt ?? null;
|
||||
expect(firstUpdatedAt).not.toBeNull();
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Reply with exactly hello", { messageId: "msg-2" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const afterReplay = await ctx.client.fetchAgent(agent.id);
|
||||
expect(afterReplay?.agent.status).toBe("idle");
|
||||
expect(afterReplay?.agent.updatedAt ?? null).toBe(firstUpdatedAt);
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 20,
|
||||
projection: "canonical",
|
||||
});
|
||||
const userMessages = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "Reply with exactly hello",
|
||||
);
|
||||
expect(userMessages).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("does not replace an active run when identical text is resent with a new messageId", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "duplicate text replay while running",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-1" });
|
||||
await ctx.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running", 5_000);
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "Run: sleep 30", { messageId: "msg-running-2" });
|
||||
const afterReplay = await ctx.client.waitForFinish(agent.id, 5_000);
|
||||
expect(afterReplay.status).toBe("idle");
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 20,
|
||||
projection: "canonical",
|
||||
});
|
||||
const userMessages = timeline.entries.filter(
|
||||
(entry) => entry.item.type === "user_message" && entry.item.text === "Run: sleep 30",
|
||||
);
|
||||
expect(userMessages).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2610,6 +2610,20 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, text, {
|
||||
messageId,
|
||||
});
|
||||
if (replayDisposition === "duplicate") {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, messageId },
|
||||
"Suppressing duplicate send replay for already-recorded user message",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (replayDisposition === "conflict") {
|
||||
throw new Error(`Client messageId '${messageId}' was reused with different text`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, text, {
|
||||
messageId,
|
||||
@@ -6626,6 +6640,38 @@ export class Session {
|
||||
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
|
||||
const replayDisposition = this.agentManager.classifyRecordedUserMessage(agentId, msg.text, {
|
||||
messageId: msg.messageId,
|
||||
});
|
||||
if (replayDisposition === "duplicate") {
|
||||
this.sessionLogger.info(
|
||||
{ agentId, messageId: msg.messageId, requestId: msg.requestId },
|
||||
"Suppressing duplicate send_agent_message_request replay",
|
||||
);
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (replayDisposition === "conflict") {
|
||||
this.emit({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId,
|
||||
accepted: false,
|
||||
error: `Client messageId '${msg.messageId}' was reused with different text`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||||
messageId: msg.messageId,
|
||||
|
||||
@@ -1745,6 +1745,61 @@ async function resolveGitHubRepo(cwd: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listPullRequestsForHead(options: {
|
||||
cwd: string;
|
||||
repo: string;
|
||||
owner: string;
|
||||
head: string;
|
||||
state: "open" | "closed";
|
||||
}): Promise<any[]> {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${options.repo}/pulls`,
|
||||
"-X",
|
||||
"GET",
|
||||
"-F",
|
||||
`head=${options.owner}:${options.head}`,
|
||||
"-F",
|
||||
`state=${options.state}`,
|
||||
],
|
||||
{ cwd: options.cwd },
|
||||
);
|
||||
const parsed = JSON.parse(stdout.trim());
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function buildPullRequestStatus(current: any, fallbackHead: string): PullRequestStatus | null {
|
||||
if (!current || typeof current !== "object") {
|
||||
return null;
|
||||
}
|
||||
const url = current.html_url ?? current.url;
|
||||
const title = current.title;
|
||||
if (typeof url !== "string" || typeof title !== "string" || !url || !title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mergedAt =
|
||||
typeof current.merged_at === "string" && current.merged_at.trim().length > 0
|
||||
? current.merged_at
|
||||
: null;
|
||||
const state =
|
||||
mergedAt !== null
|
||||
? "merged"
|
||||
: typeof current.state === "string" && current.state.trim().length > 0
|
||||
? current.state
|
||||
: "";
|
||||
|
||||
return {
|
||||
url,
|
||||
title,
|
||||
state,
|
||||
baseRefName: current.base?.ref ?? "",
|
||||
headRefName: current.head?.ref ?? fallbackHead,
|
||||
isMerged: mergedAt !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createPullRequest(
|
||||
cwd: string,
|
||||
@@ -1789,8 +1844,9 @@ export async function createPullRequest(
|
||||
|
||||
export async function getPullRequestStatus(cwd: string): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
if (!head) {
|
||||
if (!repo || !head) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
@@ -1804,51 +1860,69 @@ export async function getPullRequestStatus(cwd: string): Promise<PullRequestStat
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
const owner = repo.split("/")[0];
|
||||
let openPulls: any[];
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"view",
|
||||
"--json",
|
||||
"url,title,state,baseRefName,headRefName,mergedAt",
|
||||
],
|
||||
{ cwd },
|
||||
);
|
||||
const pr = JSON.parse(stdout.trim());
|
||||
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
|
||||
return { status: null, githubFeaturesEnabled: true };
|
||||
}
|
||||
const mergedAt =
|
||||
typeof pr.mergedAt === "string" && pr.mergedAt.trim().length > 0
|
||||
? pr.mergedAt
|
||||
: null;
|
||||
const state =
|
||||
mergedAt !== null
|
||||
? "merged"
|
||||
: typeof pr.state === "string" && pr.state.trim().length > 0
|
||||
? pr.state.toLowerCase()
|
||||
: "";
|
||||
return {
|
||||
status: {
|
||||
url: pr.url,
|
||||
title: pr.title,
|
||||
state,
|
||||
baseRefName: pr.baseRefName ?? "",
|
||||
headRefName: pr.headRefName ?? head,
|
||||
isMerged: mergedAt !== null,
|
||||
},
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
openPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "open",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return { status: null, githubFeaturesEnabled: false };
|
||||
}
|
||||
// gh pr view exits non-zero when no PR exists for the branch
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("no pull requests found") || message.includes("Could not resolve")) {
|
||||
return { status: null, githubFeaturesEnabled: true };
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const openPull = openPulls[0] ?? null;
|
||||
const openStatus = buildPullRequestStatus(openPull, head);
|
||||
if (openStatus) {
|
||||
return {
|
||||
status: openStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
let closedPulls: any[];
|
||||
try {
|
||||
closedPulls = await listPullRequestsForHead({
|
||||
cwd,
|
||||
repo,
|
||||
owner,
|
||||
head,
|
||||
state: "closed",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isGhAuthError(error)) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: false,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const mergedClosedPull =
|
||||
closedPulls.find(
|
||||
(entry) =>
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.merged_at === "string" &&
|
||||
entry.merged_at.trim().length > 0,
|
||||
) ?? null;
|
||||
const mergedStatus = buildPullRequestStatus(mergedClosedPull, head);
|
||||
if (!mergedStatus) {
|
||||
return {
|
||||
status: null,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: mergedStatus,
|
||||
githubFeaturesEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user