mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f |
@@ -428,6 +428,50 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Patch AppImage for Wayland compatibility
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
appimage_dir="$GITHUB_WORKSPACE/packages/desktop/src-tauri/target/release/bundle/appimage"
|
||||
appimage="$(find "$appimage_dir" -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
|
||||
if [ -z "$appimage" ]; then
|
||||
echo "::error::No AppImage found to patch"
|
||||
exit 1
|
||||
fi
|
||||
echo "Patching $appimage for Wayland compatibility..."
|
||||
|
||||
# Extract the AppImage
|
||||
workdir="$(mktemp -d)"
|
||||
cd "$workdir"
|
||||
"$appimage" --appimage-extract
|
||||
|
||||
# Remove GDK_BACKEND=x11 from the GTK plugin hook.
|
||||
# The bundled libgdk-3.so has Wayland support built in; forcing x11
|
||||
# prevents the app from starting on Wayland-only systems.
|
||||
gtk_hook="squashfs-root/apprun-hooks/linuxdeploy-plugin-gtk.sh"
|
||||
if [ -f "$gtk_hook" ]; then
|
||||
sed -i 's/^export GDK_BACKEND=x11/#export GDK_BACKEND=x11/' "$gtk_hook"
|
||||
echo "Patched $gtk_hook — removed GDK_BACKEND=x11"
|
||||
fi
|
||||
|
||||
# Repackage
|
||||
tools_dir="$(mktemp -d)"
|
||||
curl -fsSL https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage \
|
||||
-o "$tools_dir/appimagetool"
|
||||
chmod +x "$tools_dir/appimagetool"
|
||||
ARCH=x86_64 "$tools_dir/appimagetool" squashfs-root "$appimage"
|
||||
|
||||
# Re-sign
|
||||
rm -f "$appimage.sig"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
npx tauri signer sign "$appimage"
|
||||
echo "Repackaged and re-signed $appimage"
|
||||
|
||||
- name: Upload Linux release assets
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
|
||||
Generated
+91
@@ -7186,6 +7186,17 @@
|
||||
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/cpp": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz",
|
||||
"integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.1.tgz",
|
||||
@@ -7197,6 +7208,17 @@
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/go": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz",
|
||||
"integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||
@@ -7217,6 +7239,17 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/java": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz",
|
||||
"integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
|
||||
@@ -7258,6 +7291,17 @@
|
||||
"@lezer/highlight": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/php": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz",
|
||||
"integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/python": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz",
|
||||
@@ -7269,6 +7313,39 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/rust": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz",
|
||||
"integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/xml": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz",
|
||||
"integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||
@@ -31462,13 +31539,20 @@
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -31684,13 +31768,20 @@
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.30",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -38,13 +38,20 @@
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
|
||||
@@ -620,7 +620,6 @@ export default function RootLayout() {
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/agents" />
|
||||
<Stack.Screen name="h/[serverId]/new-agent" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { DraftAgentScreen } from "@/screens/agent/draft-agent-screen";
|
||||
|
||||
export default function HostNewAgentRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
|
||||
return <DraftAgentScreen forcedServerId={serverId} />;
|
||||
}
|
||||
@@ -1,5 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from './agent-status-bar.utils'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
normalizeModelId,
|
||||
resolveAgentModelSelection,
|
||||
} from './agent-status-bar.utils'
|
||||
|
||||
describe('getStatusSelectorHint', () => {
|
||||
it('explains what each editable status control does', () => {
|
||||
expect(getStatusSelectorHint('thinking')).toBe('Thinking mode')
|
||||
expect(getStatusSelectorHint('model')).toBe('Change model')
|
||||
expect(getStatusSelectorHint('mode')).toBe('Change permission mode')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeModelId', () => {
|
||||
it('treats empty and default values as unset', () => {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
|
||||
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -33,13 +34,18 @@ import {
|
||||
type AgentModeColorTier,
|
||||
type AgentModeIcon,
|
||||
} from '@server/server/agent/provider-manifest'
|
||||
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
|
||||
import {
|
||||
getStatusSelectorHint,
|
||||
resolveAgentModelSelection,
|
||||
} from '@/components/agent-status-bar.utils'
|
||||
|
||||
type StatusOption = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type StatusSelector = 'provider' | 'mode' | 'model' | 'thinking'
|
||||
|
||||
type ControlledAgentStatusBarProps = {
|
||||
provider: string
|
||||
providerOptions?: StatusOption[]
|
||||
@@ -139,7 +145,7 @@ function ControlledStatusBar({
|
||||
const { theme } = useUnistyles()
|
||||
const isWeb = Platform.OS === 'web'
|
||||
const [prefsOpen, setPrefsOpen] = useState(false)
|
||||
const [openSelector, setOpenSelector] = useState<'provider' | 'mode' | 'model' | 'thinking' | null>(null)
|
||||
const [openSelector, setOpenSelector] = useState<StatusSelector | null>(null)
|
||||
|
||||
const providerAnchorRef = useRef<View>(null)
|
||||
const modeAnchorRef = useRef<View>(null)
|
||||
@@ -215,12 +221,19 @@ function ControlledStatusBar({
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
|
||||
(selector: StatusSelector) => (nextOpen: boolean) => {
|
||||
setOpenSelector(nextOpen ? selector : null)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSelectorPress = useCallback(
|
||||
(selector: StatusSelector) => {
|
||||
handleOpenChange(selector)(openSelector !== selector)
|
||||
},
|
||||
[handleOpenChange, openSelector]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
@@ -231,7 +244,7 @@ function ControlledStatusBar({
|
||||
ref={providerAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectProvider}
|
||||
onPress={() => setOpenSelector(openSelector === 'provider' ? null : 'provider')}
|
||||
onPress={() => handleSelectorPress('provider')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
@@ -260,27 +273,39 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
<Tooltip
|
||||
key={`mode-${openSelector === 'mode' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modeAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectMode}
|
||||
onPress={() => handleSelectorPress('mode')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeIconBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectMode) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select agent mode (${displayMode})`}
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
{ModeIconComponent ? (
|
||||
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
|
||||
) : (
|
||||
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('mode')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModeOptions}
|
||||
value={selectedModeId ?? ''}
|
||||
@@ -297,25 +322,37 @@ function ControlledStatusBar({
|
||||
|
||||
{canSelectModel ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => setOpenSelector(openSelector === 'model' ? null : 'model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
<Tooltip
|
||||
key={`model-${openSelector === 'model' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={modelAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={modelDisabled}
|
||||
onPress={() => handleSelectorPress('model')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'model') && styles.modeBadgePressed,
|
||||
modelDisabled && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent model"
|
||||
testID="agent-model-selector"
|
||||
>
|
||||
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayModel}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('model')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxModelOptions}
|
||||
value={selectedModelId ?? ''}
|
||||
@@ -331,25 +368,37 @@ function ControlledStatusBar({
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<>
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => setOpenSelector(openSelector === 'thinking' ? null : 'thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
<Tooltip
|
||||
key={`thinking-${openSelector === 'thinking' ? 'open' : 'closed'}`}
|
||||
delayDuration={0}
|
||||
enabledOnDesktop
|
||||
enabledOnMobile={false}
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<Pressable
|
||||
ref={thinkingAnchorRef}
|
||||
collapsable={false}
|
||||
disabled={disabled || !canSelectThinking}
|
||||
onPress={() => handleSelectorPress('thinking')}
|
||||
style={({ pressed, hovered }) => [
|
||||
styles.modeBadge,
|
||||
hovered && styles.modeBadgeHovered,
|
||||
(pressed || openSelector === 'thinking') && styles.modeBadgePressed,
|
||||
(disabled || !canSelectThinking) && styles.disabledBadge,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Select thinking option (${displayThinking})`}
|
||||
testID="agent-thinking-selector"
|
||||
>
|
||||
<Brain size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.modeBadgeText}>{displayThinking}</Text>
|
||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getStatusSelectorHint('thinking')}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxThinkingOptions}
|
||||
value={selectedThinkingOptionId ?? ''}
|
||||
@@ -386,7 +435,10 @@ function ControlledStatusBar({
|
||||
>
|
||||
{providerOptions && providerOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'provider'}
|
||||
onOpenChange={handleOpenChange('provider')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectProvider}
|
||||
style={({ pressed }) => [
|
||||
@@ -418,7 +470,10 @@ function ControlledStatusBar({
|
||||
|
||||
{modeOptions && modeOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'mode'}
|
||||
onOpenChange={handleOpenChange('mode')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectMode}
|
||||
style={({ pressed }) => [
|
||||
@@ -458,7 +513,10 @@ function ControlledStatusBar({
|
||||
|
||||
{canSelectModel ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'model'}
|
||||
onOpenChange={handleOpenChange('model')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={modelDisabled}
|
||||
style={({ pressed }) => [
|
||||
@@ -490,7 +548,10 @@ function ControlledStatusBar({
|
||||
|
||||
{thinkingOptions && thinkingOptions.length > 0 ? (
|
||||
<View style={styles.sheetSection}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu
|
||||
open={openSelector === 'thinking'}
|
||||
onOpenChange={handleOpenChange('thinking')}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled || !canSelectThinking}
|
||||
style={({ pressed }) => [
|
||||
@@ -763,6 +824,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
prefsButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { AgentModelDefinition } from '@server/server/agent/agent-sdk-types'
|
||||
|
||||
export type ExplainedStatusSelector = 'mode' | 'model' | 'thinking'
|
||||
|
||||
export function getStatusSelectorHint(selector: ExplainedStatusSelector): string {
|
||||
switch (selector) {
|
||||
case 'thinking':
|
||||
return 'Thinking mode'
|
||||
case 'model':
|
||||
return 'Change model'
|
||||
case 'mode':
|
||||
return 'Change permission mode'
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeModelId(modelId: string | null | undefined): string | null {
|
||||
const normalized = typeof modelId === 'string' ? modelId.trim() : ''
|
||||
if (!normalized || normalized.toLowerCase() === 'default') {
|
||||
|
||||
@@ -11,6 +11,8 @@ import Animated, {
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { MessagesSquare, Plus, Settings } from 'lucide-react-native'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Shortcut } from '@/components/ui/shortcut'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { usePanelStore } from '@/stores/panel-store'
|
||||
import { SidebarWorkspaceList } from './sidebar-workspace-list'
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
mapPathnameToServer,
|
||||
parseServerIdFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { useOpenProjectPicker } from '@/hooks/use-open-project-picker'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
|
||||
@@ -117,7 +119,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId: activeServerId,
|
||||
enabled: isOpen,
|
||||
})
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed } =
|
||||
const { collapsedProjectKeys, shortcutIndexByWorkspaceKey, toggleProjectCollapsed, setProjectCollapsed } =
|
||||
useSidebarShortcutModel(projects)
|
||||
const {
|
||||
translateX,
|
||||
@@ -152,16 +154,16 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
closeToAgent()
|
||||
}, [closeToAgent])
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen)
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId)
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
closeToAgent()
|
||||
setProjectPickerOpen(true)
|
||||
}, [closeToAgent, setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [closeToAgent, openProjectPicker])
|
||||
|
||||
const handleOpenProjectDesktop = useCallback(() => {
|
||||
setProjectPickerOpen(true)
|
||||
}, [setProjectPickerOpen])
|
||||
void openProjectPicker()
|
||||
}, [openProjectPicker])
|
||||
|
||||
// Mobile: close sidebar and navigate
|
||||
const handleSettingsMobile = useCallback(() => {
|
||||
@@ -312,12 +314,12 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectMobile}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
@@ -327,7 +329,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
hovered && styles.newAgentButtonTextHovered,
|
||||
]}
|
||||
>
|
||||
Add project
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -343,6 +345,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -373,23 +376,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectMobile}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -441,19 +454,19 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
<View style={styles.sidebarHeaderRow}>
|
||||
<Pressable
|
||||
style={styles.newAgentButton}
|
||||
testID="sidebar-new-agent"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
testID="sidebar-sessions"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<Plus
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.newAgentButtonText, hovered && styles.newAgentButtonTextHovered]}
|
||||
>
|
||||
Add project
|
||||
Sessions
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -469,6 +482,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
serverId={activeServerId}
|
||||
collapsedProjectKeys={collapsedProjectKeys}
|
||||
onToggleProjectCollapsed={toggleProjectCollapsed}
|
||||
onSetProjectCollapsed={setProjectCollapsed}
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
projects={projects}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
@@ -495,23 +509,33 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.footerIconRow}>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-all-agents"
|
||||
nativeID="sidebar-all-agents"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Sessions"
|
||||
accessibilityRole="button"
|
||||
onPress={handleViewMore}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<MessagesSquare
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel="Add project"
|
||||
accessibilityRole="button"
|
||||
onPress={handleOpenProjectDesktop}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Plus
|
||||
size={theme.iconSize.lg}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>Add project</Text>
|
||||
<Shortcut keys={['⌘', '⇧', 'O']} />
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Pressable
|
||||
style={styles.footerIconButton}
|
||||
testID="sidebar-settings"
|
||||
@@ -594,7 +618,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingRight: theme.spacing[1],
|
||||
paddingLeft: theme.spacing[3],
|
||||
flexShrink: 0,
|
||||
},
|
||||
newAgentButtonHovered: {},
|
||||
@@ -690,4 +715,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -11,22 +11,17 @@ import {
|
||||
import { Folder } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHosts, useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
|
||||
export function ProjectPickerModal() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pathname = usePathname();
|
||||
const daemons = useHosts();
|
||||
|
||||
@@ -43,15 +38,12 @@ export function ProjectPickerModal() {
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined
|
||||
);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
@@ -102,31 +94,15 @@ export function ProjectPickerModal() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = await client.openProject(trimmed);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
const didOpenProject = await openProject(trimmed);
|
||||
if (didOpenProject) {
|
||||
setOpen(false);
|
||||
}
|
||||
mergeWorkspaces(serverId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
setOpen(false);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
serverId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[client, mergeWorkspaces, serverId, setHasHydratedWorkspaces, setOpen, toast]
|
||||
[client, openProject, serverId, setOpen]
|
||||
);
|
||||
|
||||
const handleSubmitCustom = useCallback(() => {
|
||||
|
||||
@@ -33,8 +33,7 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runt
|
||||
import { getIsTauri } from '@/constants/layout'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import {
|
||||
buildHostNewAgentRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
buildHostWorkspaceRouteWithOpenIntent,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from '@/utils/host-routes'
|
||||
import {
|
||||
@@ -72,6 +71,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
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'
|
||||
|
||||
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
|
||||
if (!icon) {
|
||||
@@ -90,6 +91,7 @@ interface SidebarWorkspaceListProps {
|
||||
serverId: string | null
|
||||
collapsedProjectKeys: ReadonlySet<string>
|
||||
onToggleProjectCollapsed: (projectKey: string) => void
|
||||
onSetProjectCollapsed: (projectKey: string, collapsed: boolean) => void
|
||||
shortcutIndexByWorkspaceKey: Map<string, number>
|
||||
isRefreshing?: boolean
|
||||
onRefresh?: () => void
|
||||
@@ -105,9 +107,11 @@ interface ProjectHeaderRowProps {
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
selected?: boolean
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
chevron: 'expand' | 'collapse' | null
|
||||
onPress: () => void
|
||||
onCreateWorktree?: () => void
|
||||
shortcutNumber?: number | null
|
||||
showShortcutBadge?: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving?: boolean
|
||||
@@ -124,6 +128,7 @@ interface WorkspaceRowInnerProps {
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
isArchiving: boolean
|
||||
isCreating?: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
menuController: ReturnType<typeof useContextMenu> | null
|
||||
archiveLabel?: string
|
||||
@@ -184,11 +189,15 @@ function ProjectLeadingVisual({
|
||||
displayName,
|
||||
iconDataUri,
|
||||
workspace,
|
||||
chevron = null,
|
||||
showChevron = false,
|
||||
isArchiving = false,
|
||||
}: {
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
workspace: SidebarWorkspaceEntry | null
|
||||
chevron?: 'expand' | 'collapse' | null
|
||||
showChevron?: boolean
|
||||
isArchiving?: boolean
|
||||
}) {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
|
||||
@@ -199,7 +208,9 @@ function ProjectLeadingVisual({
|
||||
|
||||
return (
|
||||
<View style={styles.projectLeadingVisualSlot}>
|
||||
{shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
{showChevron && chevron !== null ? (
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
) : shouldShowWorkspaceStatus && activeWorkspace ? (
|
||||
<WorkspaceStatusIndicator bucket={activeWorkspace.statusBucket} loading={isArchiving} />
|
||||
) : iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
@@ -215,8 +226,11 @@ function ProjectLeadingVisual({
|
||||
function ProjectInlineChevron({
|
||||
chevron,
|
||||
}: {
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
chevron: 'expand' | 'collapse' | null
|
||||
}) {
|
||||
if (chevron === null) {
|
||||
return null
|
||||
}
|
||||
if (chevron === 'collapse') {
|
||||
return <ChevronDown size={14} color="#9ca3af" />
|
||||
}
|
||||
@@ -226,33 +240,47 @@ function ProjectInlineChevron({
|
||||
function NewWorktreeButton({
|
||||
displayName,
|
||||
onPress,
|
||||
visible,
|
||||
testID,
|
||||
}: {
|
||||
displayName: string
|
||||
onPress: () => void
|
||||
visible: boolean
|
||||
testID: string
|
||||
}) {
|
||||
const { theme } = useUnistyles()
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.projectIconActionButton,
|
||||
(hovered || pressed) && styles.projectIconActionButtonHovered,
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
onPress()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
<Plus size={14} color="#9ca3af" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<View style={styles.projectTrailingControlSlot} pointerEvents={visible ? 'auto' : 'none'}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.projectIconActionButton,
|
||||
!visible && styles.projectIconActionButtonHidden,
|
||||
(hovered || pressed) && styles.projectIconActionButtonHovered,
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
onPress()
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Create a new worktree for ${displayName}`}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="end" offset={8}>
|
||||
<Text style={styles.projectActionTooltipText}>New worktree</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -479,12 +507,15 @@ function ProjectHeaderRow({
|
||||
chevron,
|
||||
onPress,
|
||||
onCreateWorktree,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
@@ -510,34 +541,71 @@ function ProjectHeaderRow({
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
/>
|
||||
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
<ProjectInlineChevron chevron={chevron} />
|
||||
<View style={styles.projectTitleGroup}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{onCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={onCreateWorktree}
|
||||
visible={isHovered}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
<View style={styles.shortcutBadge}>
|
||||
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
if (menuController) {
|
||||
return (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed, hovered = false }) => [
|
||||
<View
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
style={({ pressed }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={() => setIsHovered(true)}
|
||||
onPointerLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
isHovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
@@ -547,27 +615,8 @@ function ProjectHeaderRow({
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
selected && styles.sidebarRowSelected,
|
||||
hovered && styles.projectRowHovered,
|
||||
pressed && styles.projectRowPressed,
|
||||
]}
|
||||
onPressIn={interaction.handlePressIn}
|
||||
onTouchMove={interaction.handleTouchMove}
|
||||
onPressOut={interaction.handlePressOut}
|
||||
onPress={handlePress}
|
||||
testID={`sidebar-project-row-${project.projectKey}`}
|
||||
>
|
||||
{rowChildren}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -580,6 +629,7 @@ function WorkspaceRowInner({
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving,
|
||||
isCreating = false,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
@@ -632,15 +682,23 @@ function WorkspaceRowInner({
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.workspaceRowLeft}
|
||||
>
|
||||
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
|
||||
<WorkspaceStatusIndicator
|
||||
bucket={workspace.statusBucket}
|
||||
loading={isArchiving || isCreating}
|
||||
/>
|
||||
<Text
|
||||
style={[styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered]}
|
||||
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
|
||||
@@ -717,6 +775,7 @@ function WorkspaceRowWithMenu({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -727,6 +786,7 @@ function WorkspaceRowWithMenu({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
isCreating?: boolean
|
||||
}) {
|
||||
const toast = useToast()
|
||||
const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree)
|
||||
@@ -827,6 +887,7 @@ function WorkspaceRowWithMenu({
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchiving}
|
||||
isCreating={isCreating}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
archiveLabel={isWorktree ? 'Archive worktree' : 'Hide from sidebar'}
|
||||
@@ -846,6 +907,8 @@ function NonGitProjectRowWithMenuContent({
|
||||
workspace,
|
||||
selected,
|
||||
onPress,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -856,6 +919,8 @@ function NonGitProjectRowWithMenuContent({
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
@@ -909,8 +974,10 @@ function NonGitProjectRowWithMenuContent({
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
chevron="disclosure"
|
||||
chevron={null}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isArchivingWorkspace}
|
||||
@@ -944,6 +1011,8 @@ function NonGitProjectRowWithMenu(props: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
onPress: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
@@ -955,37 +1024,65 @@ function NonGitProjectRowWithMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowPlain({
|
||||
workspace,
|
||||
selected,
|
||||
function FlattenedProjectRow({
|
||||
project,
|
||||
displayName,
|
||||
iconDataUri,
|
||||
rowModel,
|
||||
onPress,
|
||||
onCreateWorktree,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
project: SidebarProjectEntry
|
||||
displayName: string
|
||||
iconDataUri: string | null
|
||||
rowModel: Extract<ReturnType<typeof buildSidebarProjectRowModel>, { kind: 'workspace_link' }>
|
||||
onPress: () => void
|
||||
onCreateWorktree?: () => void
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
if (project.projectKind === 'non_git') {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
onPress={onPress}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<WorkspaceRowInner
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={rowModel.workspace}
|
||||
selected={rowModel.selected}
|
||||
chevron={rowModel.chevron}
|
||||
onPress={onPress}
|
||||
onCreateWorktree={rowModel.trailingAction === 'new_worktree' ? onCreateWorktree : undefined}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={false}
|
||||
dragHandleProps={dragHandleProps}
|
||||
menuController={null}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1000,6 +1097,7 @@ function WorkspaceRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
isCreating = false,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
@@ -1010,6 +1108,7 @@ function WorkspaceRow({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
canCopyBranchName: boolean
|
||||
isCreating?: boolean
|
||||
}) {
|
||||
return (
|
||||
<WorkspaceRowWithMenu
|
||||
@@ -1022,6 +1121,7 @@ function WorkspaceRow({
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1044,6 +1144,7 @@ function ProjectBlock({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
useNestable,
|
||||
creatingWorkspaceIds,
|
||||
}: {
|
||||
project: SidebarProjectEntry
|
||||
collapsed: boolean
|
||||
@@ -1062,6 +1163,7 @@ function ProjectBlock({
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
useNestable: boolean
|
||||
creatingWorkspaceIds: ReadonlySet<string>
|
||||
}) {
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
@@ -1073,11 +1175,9 @@ function ProjectBlock({
|
||||
}),
|
||||
[activeWorkspaceSelection, collapsed, project, serverId]
|
||||
)
|
||||
const flattenedWorkspace = rowModel.flattenedWorkspace
|
||||
|
||||
const renderWorkspaceRow = useCallback(
|
||||
(item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps }) => {
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
|
||||
const isSelected =
|
||||
Boolean(serverId) &&
|
||||
activeWorkspaceSelection?.serverId === serverId &&
|
||||
@@ -1090,6 +1190,7 @@ function ProjectBlock({
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(item.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canCopyBranchName={project.projectKind === 'git'}
|
||||
isCreating={creatingWorkspaceIds.has(item.workspaceId)}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
@@ -1105,6 +1206,8 @@ function ProjectBlock({
|
||||
},
|
||||
[
|
||||
activeWorkspaceSelection,
|
||||
project.projectKind,
|
||||
creatingWorkspaceIds,
|
||||
onWorkspacePress,
|
||||
serverId,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
@@ -1137,20 +1240,26 @@ function ProjectBlock({
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
{flattenedWorkspace ? (
|
||||
<NonGitProjectRowWithMenu
|
||||
{rowModel.kind === 'workspace_link' ? (
|
||||
<FlattenedProjectRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
workspace={flattenedWorkspace}
|
||||
selected={rowModel.selected}
|
||||
rowModel={rowModel}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
navigateToWorkspace(serverId, flattenedWorkspace.workspaceId)
|
||||
navigateToWorkspace(serverId, rowModel.workspace.workspaceId)
|
||||
}}
|
||||
onCreateWorktree={
|
||||
rowModel.trailingAction === 'new_worktree' && onCreateWorktree
|
||||
? () => onCreateWorktree(project)
|
||||
: undefined
|
||||
}
|
||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1201,6 +1310,7 @@ export function SidebarWorkspaceList({
|
||||
serverId,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
onSetProjectCollapsed,
|
||||
shortcutIndexByWorkspaceKey,
|
||||
isRefreshing = false,
|
||||
onRefresh,
|
||||
@@ -1212,6 +1322,13 @@ export function SidebarWorkspaceList({
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const pathname = usePathname()
|
||||
const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection()
|
||||
const toast = useToast()
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces)
|
||||
const [creatingProjectKey, setCreatingProjectKey] = useState<string | null>(null)
|
||||
const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState<Set<string>>(() => new Set())
|
||||
const creatingWorkspaceTimeoutsRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map()
|
||||
)
|
||||
const isTauri = getIsTauri()
|
||||
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
|
||||
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
|
||||
@@ -1291,6 +1408,51 @@ export function SidebarWorkspaceList({
|
||||
return byProject
|
||||
}, [projectIconQueries, projectIconRequests, projects, serverId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (creatingWorkspaceIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const visibleWorkspaceIds = new Set<string>()
|
||||
for (const project of projects) {
|
||||
for (const workspace of project.workspaces) {
|
||||
visibleWorkspaceIds.add(workspace.workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter(
|
||||
(workspaceId) => !visibleWorkspaceIds.has(workspaceId)
|
||||
)
|
||||
if (removedWorkspaceIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
next.delete(workspaceId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [creatingWorkspaceIds, projects])
|
||||
|
||||
const handleProjectDragEnd = useCallback(
|
||||
(reorderedProjects: SidebarProjectEntry[]) => {
|
||||
if (!serverId) {
|
||||
@@ -1349,19 +1511,68 @@ export function SidebarWorkspaceList({
|
||||
)
|
||||
|
||||
const handleCreateWorktree = useCallback(
|
||||
(project: SidebarProjectEntry) => {
|
||||
async (project: SidebarProjectEntry) => {
|
||||
if (!serverId || project.projectKind !== 'git') {
|
||||
return
|
||||
}
|
||||
onWorkspacePress?.()
|
||||
router.push(
|
||||
buildHostNewAgentRoute(serverId, {
|
||||
workingDir: project.iconWorkingDir,
|
||||
worktreeMode: 'create',
|
||||
}) as any
|
||||
)
|
||||
if (creatingProjectKey) {
|
||||
return
|
||||
}
|
||||
onSetProjectCollapsed(project.projectKey, false)
|
||||
setCreatingProjectKey(project.projectKey)
|
||||
try {
|
||||
const client = getHostRuntimeStore().getClient(serverId)
|
||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||
throw new Error('Host is not connected')
|
||||
}
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: project.iconWorkingDir,
|
||||
worktreeSlug: createNameId(),
|
||||
})
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? 'Failed to create worktree')
|
||||
}
|
||||
const workspace = payload.workspace
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(workspace)])
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.add(workspace.id)
|
||||
return next
|
||||
})
|
||||
const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspace.id)
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout)
|
||||
}
|
||||
creatingWorkspaceTimeoutsRef.current.set(
|
||||
workspace.id,
|
||||
setTimeout(() => {
|
||||
creatingWorkspaceTimeoutsRef.current.delete(workspace.id)
|
||||
setCreatingWorkspaceIds((current) => {
|
||||
if (!current.has(workspace.id)) {
|
||||
return current
|
||||
}
|
||||
const next = new Set(current)
|
||||
next.delete(workspace.id)
|
||||
return next
|
||||
})
|
||||
}, 3000)
|
||||
)
|
||||
onWorkspacePress?.()
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(serverId, workspace.id, {
|
||||
kind: 'draft',
|
||||
draftId: 'new',
|
||||
}) as any
|
||||
)
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
} finally {
|
||||
setCreatingProjectKey((current) => (current === project.projectKey ? null : current))
|
||||
}
|
||||
},
|
||||
[serverId, onWorkspacePress]
|
||||
[creatingProjectKey, mergeWorkspaces, onSetProjectCollapsed, onWorkspacePress, serverId, toast]
|
||||
)
|
||||
|
||||
const renderProject = useCallback(
|
||||
@@ -1385,6 +1596,7 @@ export function SidebarWorkspaceList({
|
||||
isDragging={isActive}
|
||||
dragHandleProps={dragHandleProps}
|
||||
useNestable={isNative}
|
||||
creatingWorkspaceIds={creatingWorkspaceIds}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1401,6 +1613,7 @@ export function SidebarWorkspaceList({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
isNative,
|
||||
creatingWorkspaceIds,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1519,6 +1732,13 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectTitleGroup: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[1],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectIcon: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -1547,8 +1767,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
projectActionButton: {
|
||||
flexDirection: 'row',
|
||||
@@ -1577,6 +1797,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
projectIconActionButtonHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
projectIconActionButtonHidden: {
|
||||
opacity: 0,
|
||||
},
|
||||
projectTrailingControlSlot: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
},
|
||||
projectActionTooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
@@ -1665,9 +1895,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
workspaceBranchTextHovered: {
|
||||
opacity: 1,
|
||||
},
|
||||
workspaceCreatingText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
diffStatRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -1698,11 +1936,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
shortcutBadgeText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
lineHeight: 14,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type WorkspaceDesktopTabRowItem,
|
||||
} from "@/screens/workspace/workspace-desktop-tabs-row";
|
||||
import {
|
||||
useWorkspaceTabPresentation,
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
@@ -489,35 +489,41 @@ function DragOverlayTabChipInner({
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const label =
|
||||
presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
]}
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.dragOverlayLabel,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
{(presentation) => {
|
||||
const label =
|
||||
presentation.titleState === "loading" ? "Loading..." : presentation.label;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.dragOverlayChip,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<WorkspaceTabIcon presentation={presentation} active size={14} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.dragOverlayLabel,
|
||||
{ color: theme.colors.foreground },
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react-native";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
|
||||
export type ToastVariant = "default" | "success" | "error";
|
||||
|
||||
export type ToastShowOptions = {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastState = {
|
||||
id: number;
|
||||
content: ReactNode;
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastApi = {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
|
||||
export function useToastHost(): {
|
||||
api: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
} {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback(
|
||||
(content: ReactNode, options?: ToastShowOptions) => {
|
||||
const nativeMessage =
|
||||
typeof content === "string"
|
||||
? content.trim()
|
||||
: null;
|
||||
if (!content || nativeMessage === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration =
|
||||
durationMs <= 2500
|
||||
? ToastAndroid.SHORT
|
||||
: ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(
|
||||
nativeMessage,
|
||||
duration,
|
||||
ToastAndroid.TOP
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
variant,
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} />,
|
||||
}),
|
||||
error: (message: string) =>
|
||||
show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
setToast(null);
|
||||
}, []);
|
||||
|
||||
return { api, toast, dismiss };
|
||||
}
|
||||
|
||||
export function ToastViewport({
|
||||
toast,
|
||||
onDismiss,
|
||||
placement = "app-shell",
|
||||
}: {
|
||||
toast: ToastState | null;
|
||||
onDismiss: () => void;
|
||||
placement?: ToastViewportPlacement;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
clearTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -8,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const topOffset =
|
||||
placement === "app-shell"
|
||||
? insets.top + headerTopPadding + headerHeight + theme.spacing[2]
|
||||
: theme.spacing[3];
|
||||
|
||||
const icon =
|
||||
toast.icon ?? (
|
||||
toast.variant === "success" ? (
|
||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||
) : toast.variant === "error" ? (
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
{
|
||||
marginTop: topOffset,
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[
|
||||
styles.message,
|
||||
toast.variant === "error" ? styles.messageError : null,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (
|
||||
placement === "app-shell" &&
|
||||
Platform.OS === "web" &&
|
||||
typeof document !== "undefined"
|
||||
) {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: 0,
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
toastError: {
|
||||
borderColor: theme.colors.destructive,
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
contentSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -243,9 +243,79 @@ export function ToolCallDetailsContent({
|
||||
);
|
||||
}
|
||||
} else if (detail?.type === "search") {
|
||||
const searchSections: ReactNode[] = [];
|
||||
if (detail.query) {
|
||||
searchSections.push(
|
||||
<View key="search-query" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.content) {
|
||||
searchSections.push(
|
||||
<View key="search-content" style={styles.section}>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>{detail.content}</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.filePaths && detail.filePaths.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-files" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.filePaths.join("\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.webResults && detail.webResults.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-web-results" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.webResults.map((entry) => `${entry.title}\n${entry.url}`).join("\n\n")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (detail.annotations && detail.annotations.length > 0) {
|
||||
searchSections.push(
|
||||
<View key="search-annotations" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.annotations.join("\n\n")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
sections.push(...searchSections);
|
||||
} else if (detail?.type === "fetch") {
|
||||
sections.push(
|
||||
<View key="search" style={styles.section}>
|
||||
<Text selectable style={styles.scrollText}>{detail.query}</Text>
|
||||
<View
|
||||
key="fetch"
|
||||
style={[styles.section, shouldFill && styles.fillHeight]}
|
||||
>
|
||||
<ScrollView
|
||||
style={[
|
||||
styles.scrollArea,
|
||||
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
|
||||
shouldFill && styles.fillHeight,
|
||||
]}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
nestedScrollEnabled
|
||||
showsVerticalScrollIndicator
|
||||
>
|
||||
<ScrollView horizontal nestedScrollEnabled showsHorizontalScrollIndicator>
|
||||
<Text selectable style={styles.scrollText}>
|
||||
{detail.result ? `${detail.url}\n\n${detail.result}` : detail.url}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
} else if (detail?.type === "plain_text") {
|
||||
|
||||
@@ -1,62 +1,9 @@
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "../lib/overlay-root";
|
||||
import {
|
||||
Animated,
|
||||
Easing,
|
||||
Platform,
|
||||
Text,
|
||||
ToastAndroid,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react-native";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
|
||||
type ToastVariant = "default" | "success" | "error";
|
||||
|
||||
export type ToastShowOptions = {
|
||||
icon?: ReactNode;
|
||||
variant?: ToastVariant;
|
||||
durationMs?: number;
|
||||
/**
|
||||
* Set to true to use OS toast on Android.
|
||||
*/
|
||||
nativeAndroid?: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
type ToastState = {
|
||||
id: number;
|
||||
content: ReactNode;
|
||||
nativeMessage: string | null;
|
||||
icon?: ReactNode;
|
||||
variant: ToastVariant;
|
||||
durationMs: number;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
export type ToastApi = {
|
||||
show: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
copied: (label?: string) => void;
|
||||
error: (message: string) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
ToastViewport,
|
||||
useToastHost,
|
||||
type ToastApi,
|
||||
} from "@/components/toast-host";
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
@@ -69,259 +16,12 @@ export function useToast(): ToastApi {
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const show = useCallback(
|
||||
(content: ReactNode, options?: ToastShowOptions) => {
|
||||
const nativeMessage =
|
||||
typeof content === "string"
|
||||
? content.trim()
|
||||
: null;
|
||||
if (!content || nativeMessage === "") return;
|
||||
|
||||
const variant = options?.variant ?? "default";
|
||||
const durationMs = options?.durationMs ?? DEFAULT_DURATION_MS;
|
||||
const nativeAndroid = options?.nativeAndroid ?? false;
|
||||
|
||||
if (Platform.OS === "android" && nativeAndroid && nativeMessage) {
|
||||
const duration =
|
||||
durationMs <= 2500
|
||||
? ToastAndroid.SHORT
|
||||
: ToastAndroid.LONG;
|
||||
ToastAndroid.showWithGravity(
|
||||
nativeMessage,
|
||||
duration,
|
||||
ToastAndroid.TOP
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
idRef.current += 1;
|
||||
setToast({
|
||||
id: idRef.current,
|
||||
content,
|
||||
nativeMessage,
|
||||
icon: options?.icon,
|
||||
variant,
|
||||
durationMs,
|
||||
testID: options?.testID,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<ToastApi>(
|
||||
() => ({
|
||||
show,
|
||||
copied: (label?: string) =>
|
||||
show(label ? `Copied ${label}` : "Copied", {
|
||||
variant: "success",
|
||||
icon: <CheckCircle2 size={18} />,
|
||||
}),
|
||||
error: (message: string) => show(message, { variant: "error", durationMs: 3200 }),
|
||||
}),
|
||||
[show]
|
||||
);
|
||||
const { api, toast, dismiss } = useToastHost();
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<ToastViewport toast={toast} onDismiss={() => setToast(null)} />
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastViewport({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: ToastState | null;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
const translateY = useRef(new Animated.Value(-8)).current;
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const animateOut = useCallback(() => {
|
||||
clearTimer();
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: -8,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(({ finished }) => {
|
||||
if (finished) {
|
||||
onDismiss();
|
||||
}
|
||||
});
|
||||
}, [clearTimer, onDismiss, opacity, translateY]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) {
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimer();
|
||||
opacity.setValue(0);
|
||||
translateY.setValue(-8);
|
||||
|
||||
Animated.parallel([
|
||||
Animated.timing(opacity, {
|
||||
toValue: 1,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(translateY, {
|
||||
toValue: 0,
|
||||
duration: 140,
|
||||
easing: Easing.out(Easing.quad),
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
animateOut();
|
||||
}, toast.durationMs);
|
||||
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [animateOut, clearTimer, opacity, toast, translateY]);
|
||||
|
||||
if (!toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const headerHeight = isMobile ? HEADER_INNER_HEIGHT_MOBILE : HEADER_INNER_HEIGHT;
|
||||
const headerTopPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
|
||||
const icon =
|
||||
toast.icon ?? (
|
||||
toast.variant === "success" ? (
|
||||
<CheckCircle2 size={18} color={theme.colors.primary} />
|
||||
) : toast.variant === "error" ? (
|
||||
<AlertTriangle size={18} color={theme.colors.destructive} />
|
||||
) : null
|
||||
);
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
style={[
|
||||
styles.toast,
|
||||
toast.variant === "success" ? styles.toastSuccess : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
{
|
||||
marginTop:
|
||||
insets.top + headerTopPadding + headerHeight + theme.spacing[2],
|
||||
opacity,
|
||||
transform: [{ translateY }],
|
||||
},
|
||||
]}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text
|
||||
testID="app-toast-message"
|
||||
style={[
|
||||
styles.message,
|
||||
toast.variant === "error" ? styles.messageError : null,
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// On web, portal to overlay root to control stacking order
|
||||
if (Platform.OS === "web" && typeof document !== "undefined") {
|
||||
return createPortal(content, getOverlayRoot());
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
position: "absolute",
|
||||
left: theme.spacing[4],
|
||||
right: theme.spacing[4],
|
||||
top: 0,
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
toastSuccess: {
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
toastError: {
|
||||
borderColor: theme.colors.destructive,
|
||||
},
|
||||
iconSlot: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
contentSlot: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
messageError: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface DesktopPermissionRowProps {
|
||||
isRequesting: boolean;
|
||||
showBorder?: boolean;
|
||||
onRequest: () => void;
|
||||
extraActionLabel?: string;
|
||||
isExtraActionBusy?: boolean;
|
||||
isExtraActionDisabled?: boolean;
|
||||
onExtraAction?: () => void;
|
||||
}
|
||||
|
||||
export function DesktopPermissionRow({
|
||||
@@ -18,6 +22,10 @@ export function DesktopPermissionRow({
|
||||
isRequesting,
|
||||
showBorder,
|
||||
onRequest,
|
||||
extraActionLabel,
|
||||
isExtraActionBusy = false,
|
||||
isExtraActionDisabled = false,
|
||||
onExtraAction,
|
||||
}: DesktopPermissionRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const state = status?.state ?? "unknown";
|
||||
@@ -36,9 +44,21 @@ export function DesktopPermissionRow({
|
||||
</View>
|
||||
<View style={styles.permissionRowActions}>
|
||||
{isGranted ? (
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
<View style={styles.permissionGrantedActions}>
|
||||
<View style={styles.permissionStatusPill}>
|
||||
<Check size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.permissionStatusText}>Granted</Text>
|
||||
</View>
|
||||
{extraActionLabel && onExtraAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={onExtraAction}
|
||||
disabled={isExtraActionDisabled || isExtraActionBusy}
|
||||
>
|
||||
{isExtraActionBusy ? `${extraActionLabel}...` : extraActionLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onPress={onRequest} disabled={isRequesting}>
|
||||
@@ -75,6 +95,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
permissionGrantedActions: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
permissionStatusPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -13,8 +13,10 @@ export function DesktopPermissionsSection() {
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
} = useDesktopPermissions();
|
||||
|
||||
if (!isDesktop) {
|
||||
@@ -22,6 +24,7 @@ export function DesktopPermissionsSection() {
|
||||
}
|
||||
|
||||
const isBusy = isRefreshing || requestingPermission !== null;
|
||||
const notificationsGranted = snapshot?.notifications.state === "granted";
|
||||
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
@@ -48,6 +51,12 @@ export function DesktopPermissionsSection() {
|
||||
onRequest={() => {
|
||||
void requestPermission("notifications");
|
||||
}}
|
||||
extraActionLabel="Test"
|
||||
isExtraActionBusy={isSendingTestNotification}
|
||||
isExtraActionDisabled={!notificationsGranted || isBusy}
|
||||
onExtraAction={() => {
|
||||
void sendTestNotification();
|
||||
}}
|
||||
/>
|
||||
<DesktopPermissionRow
|
||||
title="Microphone"
|
||||
|
||||
@@ -38,9 +38,13 @@ import {
|
||||
|
||||
export interface LocalDaemonSectionProps {
|
||||
appVersion: string | null
|
||||
showLifecycleControls: boolean
|
||||
}
|
||||
|
||||
export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
export function LocalDaemonSection({
|
||||
appVersion,
|
||||
showLifecycleControls,
|
||||
}: LocalDaemonSectionProps) {
|
||||
const { theme } = useUnistyles()
|
||||
const showSection = shouldUseManagedDesktopDaemon()
|
||||
const { settings, updateSettings } = useAppSettings()
|
||||
@@ -347,57 +351,61 @@ export function LocalDaemonSection({ appVersion }: LocalDaemonSectionProps) {
|
||||
<Text style={styles.valueSubtext}>{daemonStatusDetailText}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{isDaemonManagementPaused
|
||||
? 'Paused. The built-in daemon stays stopped until you start it again.'
|
||||
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? 'Resuming...'
|
||||
: 'Pausing...'
|
||||
: isDaemonManagementPaused
|
||||
? 'Resume'
|
||||
: 'Pause'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
{showLifecycleControls ? (
|
||||
<>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Daemon management</Text>
|
||||
<Text style={styles.hintText}>
|
||||
{isDaemonManagementPaused
|
||||
? 'Paused. The built-in daemon stays stopped until you start it again.'
|
||||
: 'Enabled. Paseo can manage the built-in daemon from the desktop app.'}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={
|
||||
isDaemonManagementPaused ? (
|
||||
<Play size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Pause size={theme.iconSize.sm} color={theme.colors.foreground} />
|
||||
)
|
||||
}
|
||||
onPress={handleToggleDaemonManagement}
|
||||
disabled={isUpdatingDaemonManagement}
|
||||
>
|
||||
{isUpdatingDaemonManagement
|
||||
? isDaemonManagementPaused
|
||||
? 'Resuming...'
|
||||
: 'Pausing...'
|
||||
: isDaemonManagementPaused
|
||||
? 'Resume'
|
||||
: 'Pause'}
|
||||
</Button>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>{daemonActionLabel}</Text>
|
||||
<Text style={styles.hintText}>{daemonActionMessage}</Text>
|
||||
{statusMessage ? <Text style={styles.statusText}>{statusMessage}</Text> : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<RotateCw size={theme.iconSize.sm} color={theme.colors.foreground} />}
|
||||
onPress={handleUpdateLocalDaemon}
|
||||
disabled={isRestartingDaemon}
|
||||
>
|
||||
{isRestartingDaemon
|
||||
? managedStatus?.status === 'running'
|
||||
? 'Restarting...'
|
||||
: 'Starting...'
|
||||
: daemonActionLabel}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowTitle}>Command line (CLI)</Text>
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
type DesktopPermissionKind,
|
||||
type DesktopPermissionSnapshot,
|
||||
} from "@/desktop/permissions/desktop-permissions";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
|
||||
export interface UseDesktopPermissionsReturn {
|
||||
isDesktop: boolean;
|
||||
snapshot: DesktopPermissionSnapshot | null;
|
||||
isRefreshing: boolean;
|
||||
requestingPermission: DesktopPermissionKind | null;
|
||||
isSendingTestNotification: boolean;
|
||||
refreshPermissions: () => Promise<void>;
|
||||
requestPermission: (kind: DesktopPermissionKind) => Promise<void>;
|
||||
sendTestNotification: () => Promise<void>;
|
||||
}
|
||||
|
||||
const EMPTY_NOTIFICATION_STATUS = {
|
||||
@@ -34,6 +37,7 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
const [requestingPermission, setRequestingPermission] = useState<DesktopPermissionKind | null>(
|
||||
null
|
||||
);
|
||||
const [isSendingTestNotification, setIsSendingTestNotification] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -109,6 +113,29 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
[isDesktop, refreshPermissions]
|
||||
);
|
||||
|
||||
const sendTestNotification = useCallback(async () => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingTestNotification(true);
|
||||
try {
|
||||
const sent = await sendOsNotification({
|
||||
title: "Paseo notification test",
|
||||
body: "If you can see this, delivery works. Click it to verify the open flow.",
|
||||
});
|
||||
if (!sent) {
|
||||
console.warn("[Settings] Desktop test notification was not delivered");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Settings] Failed to send desktop test notification", error);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsSendingTestNotification(false);
|
||||
}
|
||||
}
|
||||
}, [isDesktop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) {
|
||||
return;
|
||||
@@ -122,7 +149,9 @@ export function useDesktopPermissions(): UseDesktopPermissionsReturn {
|
||||
snapshot,
|
||||
isRefreshing,
|
||||
requestingPermission,
|
||||
isSendingTestNotification,
|
||||
refreshPermissions,
|
||||
requestPermission,
|
||||
sendTestNotification,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getTauri } from '@/utils/tauri'
|
||||
|
||||
export async function pickDirectory(): Promise<string | null> {
|
||||
const open = getTauri()?.dialog?.open
|
||||
if (typeof open !== 'function') {
|
||||
throw new Error('Tauri dialog open() is unavailable in this environment.')
|
||||
}
|
||||
|
||||
const selection = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
if (selection === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof selection === 'string') {
|
||||
return selection
|
||||
}
|
||||
|
||||
throw new Error('Unexpected directory picker response.')
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseHostAgentRouteFromPathname,
|
||||
@@ -54,7 +54,7 @@ type CommandCenterActionDefinition = {
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: Href; settingsRoute: Href }) => Href;
|
||||
routeKind: "settings" | "none";
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
@@ -64,14 +64,14 @@ const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "shift", "O"],
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
routeKind: "none",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: ({ settingsRoute }) => settingsRoute,
|
||||
routeKind: "settings",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -92,7 +92,7 @@ export type CommandCenterActionItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
route: Href;
|
||||
route?: Href;
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
};
|
||||
|
||||
@@ -148,11 +148,6 @@ export function useCommandCenter() {
|
||||
return filtered;
|
||||
}, [agents, open, query]);
|
||||
|
||||
const newAgentRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostOpenProjectRoute(serverIdFromPath) as Href) : "/";
|
||||
}, [activeServerId]);
|
||||
|
||||
const settingsRoute = useMemo<Href>(() => {
|
||||
const serverIdFromPath = activeServerId;
|
||||
return serverIdFromPath ? (buildHostSettingsRoute(serverIdFromPath) as Href) : "/";
|
||||
@@ -169,10 +164,10 @@ export function useCommandCenter() {
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute, settingsRoute }),
|
||||
route: action.routeKind === "settings" ? settingsRoute : undefined,
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, open, query, settingsRoute]);
|
||||
}, [open, query, settingsRoute]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!open) {
|
||||
@@ -217,18 +212,21 @@ export function useCommandCenter() {
|
||||
[pathname, setOpen]
|
||||
);
|
||||
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
if (action.id === "new-agent") {
|
||||
setProjectPickerOpen(true);
|
||||
void openProjectPicker();
|
||||
return;
|
||||
}
|
||||
if (!action.route) {
|
||||
return;
|
||||
}
|
||||
didNavigateRef.current = true;
|
||||
router.push(action.route);
|
||||
}, [setOpen, setProjectPickerOpen]);
|
||||
}, [openProjectPicker, setOpen]);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { ActivityIndicator } from "react-native";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { ToastShowOptions } from "@/components/toast-host";
|
||||
|
||||
const HISTORY_REFRESH_TOAST_DELAY_MS = 1000;
|
||||
const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||
@@ -8,22 +8,23 @@ const HISTORY_REFRESH_TOAST_DURATION_MS = 2200;
|
||||
interface UseDelayedHistoryRefreshToastParams {
|
||||
isCatchingUp: boolean;
|
||||
indicatorColor: string;
|
||||
showToast: (content: ReactNode, options?: ToastShowOptions) => void;
|
||||
}
|
||||
|
||||
export function useDelayedHistoryRefreshToast({
|
||||
isCatchingUp,
|
||||
indicatorColor,
|
||||
showToast,
|
||||
}: UseDelayedHistoryRefreshToastParams): void {
|
||||
const toast = useToast();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const wasCatchingUpRef = useRef(false);
|
||||
const isCatchingUpRef = useRef(false);
|
||||
const toastRef = useRef(toast);
|
||||
const showToastRef = useRef(showToast);
|
||||
const indicatorColorRef = useRef(indicatorColor);
|
||||
|
||||
useEffect(() => {
|
||||
toastRef.current = toast;
|
||||
}, [toast]);
|
||||
showToastRef.current = showToast;
|
||||
}, [showToast]);
|
||||
|
||||
useEffect(() => {
|
||||
indicatorColorRef.current = indicatorColor;
|
||||
@@ -44,7 +45,7 @@ export function useDelayedHistoryRefreshToast({
|
||||
if (!isCatchingUpRef.current) {
|
||||
return;
|
||||
}
|
||||
toastRef.current.show("Refreshing", {
|
||||
showToastRef.current("Refreshing", {
|
||||
icon: (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getManagedDaemonStatus } from '@/desktop/managed-runtime/managed-runtime'
|
||||
import { isTauriEnvironment } from '@/utils/tauri'
|
||||
|
||||
const MANAGED_DAEMON_SERVER_ID_QUERY_KEY = ['managed-daemon-server-id'] as const
|
||||
|
||||
interface ManagedDaemonServerIdResult {
|
||||
serverId: string | null
|
||||
}
|
||||
|
||||
async function loadManagedDaemonServerId(): Promise<ManagedDaemonServerIdResult> {
|
||||
const status = await getManagedDaemonStatus()
|
||||
const serverId = status.serverId.trim()
|
||||
return {
|
||||
serverId: serverId.length > 0 ? serverId : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function useIsLocalDaemon(serverId: string): boolean {
|
||||
const normalizedServerId = serverId.trim()
|
||||
const isDesktop = isTauriEnvironment()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: MANAGED_DAEMON_SERVER_ID_QUERY_KEY,
|
||||
queryFn: loadManagedDaemonServerId,
|
||||
enabled: isDesktop,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (!isDesktop || normalizedServerId.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return query.data?.serverId === normalizedServerId
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseServerIdFromPathname,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import { navigateToWorkspace } from "@/hooks/use-workspace-navigation";
|
||||
@@ -18,6 +20,7 @@ import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"
|
||||
import { resolveKeyboardShortcut } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
enabled,
|
||||
@@ -33,7 +36,14 @@ export function useKeyboardShortcuts({
|
||||
toggleFileExplorer?: () => void;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const activeServerIdFromPath = parseServerIdFromPathname(pathname);
|
||||
const activeServerId =
|
||||
hosts.find((host) => host.serverId === activeServerIdFromPath)?.serverId ??
|
||||
hosts[0]?.serverId ??
|
||||
null;
|
||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -92,7 +102,7 @@ export function useKeyboardShortcuts({
|
||||
};
|
||||
|
||||
const openProjectPicker = (): boolean => {
|
||||
useKeyboardShortcutsStore.getState().setProjectPickerOpen(true);
|
||||
void openProjectPickerAction();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -326,6 +336,7 @@ export function useKeyboardShortcuts({
|
||||
}, [
|
||||
enabled,
|
||||
isMobile,
|
||||
openProjectPickerAction,
|
||||
pathname,
|
||||
resetModifiers,
|
||||
selectedAgentId,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
||||
import { useOpenProject } from "./use-open-project";
|
||||
|
||||
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore(
|
||||
(state) => state.setProjectPickerOpen
|
||||
);
|
||||
const openProject = useOpenProject(serverId);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!normalizedServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = await pickDirectory();
|
||||
if (path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { router } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { buildHostWorkspaceRouteWithOpenIntent } from "@/utils/host-routes";
|
||||
|
||||
export function useOpenProject(
|
||||
serverId: string | null
|
||||
): (path: string) => Promise<boolean> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const toast = useToast();
|
||||
const { client } = useHostRuntimeSession(normalizedServerId);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.setHasHydratedWorkspaces
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
async (path: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath || !client || !normalizedServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
|
||||
mergeWorkspaces(normalizedServerId, [
|
||||
normalizeWorkspaceDescriptor(payload.workspace),
|
||||
]);
|
||||
setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
router.replace(
|
||||
buildHostWorkspaceRouteWithOpenIntent(
|
||||
normalizedServerId,
|
||||
payload.workspace.id,
|
||||
{ kind: "draft", draftId: "new" }
|
||||
) as any
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to open project"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
mergeWorkspaces,
|
||||
normalizedServerId,
|
||||
setHasHydratedWorkspaces,
|
||||
toast,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { SidebarProjectEntry } from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
|
||||
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
|
||||
import { isSidebarProjectFlattened } from '@/utils/sidebar-project-row-model'
|
||||
|
||||
export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||
@@ -23,10 +24,14 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const validProjectKeys = new Set(projects.map((project) => project.projectKey))
|
||||
const collapsibleProjectKeys = new Set(
|
||||
projects
|
||||
.filter((project) => !isSidebarProjectFlattened(project))
|
||||
.map((project) => project.projectKey)
|
||||
)
|
||||
const next = new Set<string>()
|
||||
for (const key of prev) {
|
||||
if (validProjectKeys.has(key)) {
|
||||
if (collapsibleProjectKeys.has(key)) {
|
||||
next.add(key)
|
||||
}
|
||||
}
|
||||
@@ -63,9 +68,22 @@ export function useSidebarShortcutModel(projects: SidebarProjectEntry[]) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const setProjectCollapsed = useCallback((projectKey: string, collapsed: boolean) => {
|
||||
setCollapsedProjectKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (collapsed) {
|
||||
next.add(projectKey)
|
||||
} else {
|
||||
next.delete(projectKey)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
collapsedProjectKeys,
|
||||
shortcutIndexByWorkspaceKey: shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
setProjectCollapsed,
|
||||
toggleProjectCollapsed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "@/utils/managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "@/utils/tauri-daemon-transport";
|
||||
import { applyFetchedAgentDirectory } from "@/utils/agent-directory-sync";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
|
||||
@@ -433,7 +432,6 @@ function probeIntervalForConnection(
|
||||
function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
return {
|
||||
createClient: ({ host, connection, clientId, runtimeGeneration }) => {
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
suppressSendErrors: true,
|
||||
@@ -454,17 +452,11 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
if (connection.type === "directTcp") {
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildDaemonWebSocketUrl(connection.endpoint),
|
||||
});
|
||||
}
|
||||
return new DaemonClient({
|
||||
...base,
|
||||
...(tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: connection.relayEndpoint,
|
||||
serverId: host.serverId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
useAgentScreenStateMachine,
|
||||
type AgentScreenMissingState,
|
||||
} from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useDelayedHistoryRefreshToast } from "@/hooks/use-delayed-history-refresh-toast";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
@@ -164,9 +164,9 @@ function AgentScreenContent({
|
||||
onOpenWorkspaceFile,
|
||||
}: AgentScreenContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const insets = useSafeAreaInsets();
|
||||
const queryClient = useQueryClient();
|
||||
const panelToast = useToastHost();
|
||||
const resolvedAgentId = agentId;
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
|
||||
@@ -471,12 +471,12 @@ function AgentScreenContent({
|
||||
}
|
||||
if (!reconnectToastArmedRef.current) {
|
||||
reconnectToastArmedRef.current = true;
|
||||
toast.show("Reconnecting...", {
|
||||
panelToast.api.show("Reconnecting...", {
|
||||
durationMs: 2200,
|
||||
testID: "agent-reconnecting-toast",
|
||||
});
|
||||
}
|
||||
}, [connectionStatus, toast]);
|
||||
}, [connectionStatus, panelToast.api]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -833,14 +833,15 @@ function AgentScreenContent({
|
||||
useDelayedHistoryRefreshToast({
|
||||
isCatchingUp: isHistoryRefreshCatchingUp,
|
||||
indicatorColor: theme.colors.primary,
|
||||
showToast: panelToast.api.show,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldEmitSyncErrorToast) {
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [shouldEmitSyncErrorToast, toast]);
|
||||
panelToast.api.error("Failed to refresh agent. Retrying in background.");
|
||||
}, [panelToast.api, shouldEmitSyncErrorToast]);
|
||||
|
||||
if (viewState.tag === "not_found") {
|
||||
return (
|
||||
@@ -935,6 +936,12 @@ function AgentScreenContent({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
|
||||
@@ -5,18 +5,18 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FolderOpen } from "lucide-react-native";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { getIsTauriMac } from "@/constants/layout";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
|
||||
export function OpenProjectScreen({ serverId: _serverId }: { serverId: string }) {
|
||||
export function OpenProjectScreen({ serverId }: { serverId: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
const desktopAgentListOpen = usePanelStore((s) => s.desktop.agentListOpen);
|
||||
const openAgentList = usePanelStore((s) => s.openAgentList);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((s) => s.setProjectPickerOpen);
|
||||
const openProjectPicker = useOpenProjectPicker(serverId);
|
||||
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const needsTrafficLightInset = !isMobile && !desktopAgentListOpen && getIsTauriMac();
|
||||
@@ -42,7 +42,9 @@ export function OpenProjectScreen({ serverId: _serverId }: { serverId: string })
|
||||
styles.openButton,
|
||||
hovered && styles.openButtonHovered,
|
||||
]}
|
||||
onPress={() => setProjectPickerOpen(true)}
|
||||
onPress={() => {
|
||||
void openProjectPicker();
|
||||
}}
|
||||
testID="open-project-submit"
|
||||
>
|
||||
<FolderOpen size={16} color={theme.colors.foregroundMuted} />
|
||||
|
||||
@@ -47,6 +47,7 @@ import { resolveAppVersion } from "@/utils/app-version";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { THINKING_TONE_NATIVE_PCM_BASE64 } from "@/utils/thinking-tone.native-pcm";
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
|
||||
|
||||
const delay = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
@@ -513,6 +514,7 @@ export default function SettingsScreen() {
|
||||
const isMountedRef = useRef(true);
|
||||
const lastHandledEditHostRef = useRef<string | null>(null);
|
||||
const isDesktop = Platform.OS === "web";
|
||||
const isLocalDaemon = useIsLocalDaemon(routeServerId);
|
||||
const appVersion = resolveAppVersion();
|
||||
const appVersionText = formatVersionWithPrefix(appVersion);
|
||||
const editingServerId = editingDaemon?.serverId ?? null;
|
||||
@@ -906,7 +908,12 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
{isDesktop ? <DesktopPermissionsSection /> : null}
|
||||
{isDesktop ? <LocalDaemonSection appVersion={appVersion} /> : null}
|
||||
{isDesktop ? (
|
||||
<LocalDaemonSection
|
||||
appVersion={appVersion}
|
||||
showLifecycleControls={isLocalDaemon}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<View style={settingsStyles.section}>
|
||||
<Text style={settingsStyles.sectionTitle}>Diagnostics</Text>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useWorkspaceTabLayout } from "@/screens/workspace/use-workspace-tab-layout";
|
||||
import {
|
||||
useWorkspaceTabPresentation,
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
type WorkspaceTabPresentation,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
@@ -546,13 +546,6 @@ function ResolvedDesktopTabChip({
|
||||
showDropIndicatorBefore: boolean;
|
||||
showDropIndicatorAfter: boolean;
|
||||
}) {
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab: item.tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||
const resolvedTab = useMemo(
|
||||
() =>
|
||||
buildWorkspaceDesktopTabActions({
|
||||
@@ -580,33 +573,46 @@ function ResolvedDesktopTabChip({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showDropIndicatorBefore ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
isFocused={isFocused}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
isCloseHovered={item.isCloseHovered}
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{showDropIndicatorAfter ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
|
||||
) : null}
|
||||
</View>
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={item.tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => {
|
||||
const tooltipLabel =
|
||||
presentation.titleState === "loading" ? "Loading agent title" : presentation.label;
|
||||
|
||||
return (
|
||||
<View style={styles.tabSlot}>
|
||||
{showDropIndicatorBefore ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorBefore]} />
|
||||
) : null}
|
||||
<TabChip
|
||||
tab={item.tab}
|
||||
isActive={item.isActive}
|
||||
isDragging={isDragging}
|
||||
isFocused={isFocused}
|
||||
resolvedTabWidth={resolvedTabWidth}
|
||||
showLabel={showLabel}
|
||||
showCloseButton={showCloseButton}
|
||||
isCloseHovered={item.isCloseHovered}
|
||||
isClosingTab={item.isClosingTab}
|
||||
presentation={presentation}
|
||||
tooltipLabel={tooltipLabel}
|
||||
resolvedTab={resolvedTab}
|
||||
setHoveredTabKey={setHoveredTabKey}
|
||||
setHoveredCloseTabKey={setHoveredCloseTabKey}
|
||||
onNavigateTab={onNavigateTab}
|
||||
onCloseTab={onCloseTab}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{showDropIndicatorAfter ? (
|
||||
<View style={[styles.tabDropIndicator, styles.tabDropIndicatorAfter]} />
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
useWorkspaceTabPresentation,
|
||||
WorkspaceTabPresentationResolver,
|
||||
WorkspaceTabIcon,
|
||||
WorkspaceTabOptionRow,
|
||||
} from "@/screens/workspace/workspace-tab-presentation";
|
||||
@@ -226,22 +226,24 @@ function ResolvedMobileActiveTabTrigger({
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
}) {
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab: activeTab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||
<WorkspaceTabIcon presentation={presentation} active />
|
||||
</View>
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={activeTab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => (
|
||||
<>
|
||||
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||
<WorkspaceTabIcon presentation={presentation} active />
|
||||
</View>
|
||||
|
||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</>
|
||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||
{presentation.titleState === "loading" ? "Loading..." : presentation.label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,11 +279,6 @@ function MobileWorkspaceTabOption({
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const presentation = useWorkspaceTabPresentation({
|
||||
tab,
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
});
|
||||
const menuTestIDBase = `workspace-tab-menu-${tab.key}`;
|
||||
const menuEntries = buildWorkspaceTabMenuEntries({
|
||||
surface: "mobile",
|
||||
@@ -298,53 +295,61 @@ function MobileWorkspaceTabOption({
|
||||
});
|
||||
|
||||
return (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
trailingAccessory={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open menu for ${presentation.label}`}
|
||||
hitSlop={8}
|
||||
style={({ open, pressed }) => [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(open || pressed) && styles.mobileTabMenuTriggerActive,
|
||||
]}
|
||||
>
|
||||
<Ellipsis
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
<WorkspaceTabPresentationResolver
|
||||
tab={tab}
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
>
|
||||
{(presentation) => (
|
||||
<WorkspaceTabOptionRow
|
||||
presentation={presentation}
|
||||
selected={selected}
|
||||
active={active}
|
||||
onPress={onPress}
|
||||
trailingAccessory={
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID={`${menuTestIDBase}-trigger`}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Open menu for ${presentation.label}`}
|
||||
hitSlop={8}
|
||||
style={({ open, pressed }) => [
|
||||
styles.mobileTabMenuTrigger,
|
||||
(open || pressed) && styles.mobileTabMenuTriggerActive,
|
||||
]}
|
||||
>
|
||||
<Ellipsis
|
||||
size={theme.iconSize.sm}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
width={220}
|
||||
testID={menuTestIDBase}
|
||||
>
|
||||
{menuEntries.map((entry) =>
|
||||
entry.kind === "separator" ? (
|
||||
<DropdownMenuSeparator key={entry.key} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={entry.key}
|
||||
testID={entry.testID}
|
||||
disabled={entry.disabled}
|
||||
destructive={entry.destructive}
|
||||
onSelect={entry.onSelect}
|
||||
>
|
||||
{entry.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</WorkspaceTabPresentationResolver>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,23 +21,56 @@ export interface WorkspaceTabPresentation {
|
||||
statusBucket: SidebarStateBucket | null;
|
||||
}
|
||||
|
||||
export function useWorkspaceTabPresentation(input: {
|
||||
type WorkspaceTabPresentationResolverProps = {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceTabPresentation {
|
||||
children: (presentation: WorkspaceTabPresentation) => ReactNode;
|
||||
};
|
||||
|
||||
type WorkspaceTabPresentationResolverInnerProps = WorkspaceTabPresentationResolverProps & {
|
||||
registration: NonNullable<ReturnType<typeof getPanelRegistration>>;
|
||||
};
|
||||
|
||||
export function WorkspaceTabPresentationResolver({
|
||||
tab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverProps): ReactElement {
|
||||
ensurePanelsRegistered();
|
||||
const registration = getPanelRegistration(input.tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${input.tab.kind}`);
|
||||
const descriptor = registration.useDescriptor(input.tab.target, {
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolverInner
|
||||
key={tab.kind}
|
||||
registration={registration}
|
||||
tab={tab}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
>
|
||||
{children}
|
||||
</WorkspaceTabPresentationResolverInner>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceTabPresentationResolverInner({
|
||||
registration,
|
||||
tab,
|
||||
serverId,
|
||||
workspaceId,
|
||||
children,
|
||||
}: WorkspaceTabPresentationResolverInnerProps): ReactElement {
|
||||
const descriptor = registration.useDescriptor(tab.target as never, {
|
||||
serverId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return useMemo(
|
||||
const presentation = useMemo(
|
||||
() => ({
|
||||
key: input.tab.key,
|
||||
kind: input.tab.kind,
|
||||
key: tab.key,
|
||||
kind: tab.kind,
|
||||
label: descriptor.label,
|
||||
subtitle: descriptor.subtitle,
|
||||
titleState: descriptor.titleState,
|
||||
@@ -50,10 +83,12 @@ export function useWorkspaceTabPresentation(input: {
|
||||
descriptor.statusBucket,
|
||||
descriptor.subtitle,
|
||||
descriptor.titleState,
|
||||
input.tab.key,
|
||||
input.tab.kind,
|
||||
tab.key,
|
||||
tab.kind,
|
||||
]
|
||||
);
|
||||
|
||||
return <>{children(presentation)}</>;
|
||||
}
|
||||
|
||||
type WorkspaceTabIconProps = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeHostPort } from '@server/shared/daemon-endpoints'
|
||||
import { normalizeHostPort, normalizeLoopbackToLocalhost } from '@server/shared/daemon-endpoints'
|
||||
|
||||
export type DirectTcpHostConnection = {
|
||||
id: string
|
||||
@@ -188,7 +188,7 @@ export function connectionFromListen(listen: string): HostConnection | null {
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = normalizeHostPort(normalizedListen)
|
||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(normalizedListen))
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: 'directTcp',
|
||||
@@ -207,7 +207,7 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
|
||||
const type = typeof record.type === 'string' ? record.type : null
|
||||
if (type === 'directTcp') {
|
||||
try {
|
||||
const endpoint = normalizeHostPort(String(record.endpoint ?? ''))
|
||||
const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(String(record.endpoint ?? '')))
|
||||
return { id: `direct:${endpoint}`, type: 'directTcp', endpoint }
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -59,6 +59,105 @@ index 1234567..0000000
|
||||
-export { legacy };
|
||||
`;
|
||||
|
||||
const RUST_DIFF = `diff --git a/lib.rs b/lib.rs
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/lib.rs
|
||||
+++ b/lib.rs
|
||||
@@ -1,4 +1,4 @@
|
||||
fn main() {
|
||||
- let version = 1;
|
||||
+ let version = 2;
|
||||
println!("Hello, world!");
|
||||
}
|
||||
`;
|
||||
|
||||
const C_DIFF = `diff --git a/example.c b/example.c
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/example.c
|
||||
+++ b/example.c
|
||||
@@ -1,4 +1,4 @@
|
||||
int main(void) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
return version;
|
||||
}
|
||||
`;
|
||||
|
||||
const JAVA_DIFF = `diff --git a/Main.java b/Main.java
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/Main.java
|
||||
+++ b/Main.java
|
||||
@@ -1,5 +1,5 @@
|
||||
public class Main {
|
||||
public static void main(String[] args) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OBJECTIVE_C_DIFF = `diff --git a/AppDelegate.m b/AppDelegate.m
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/AppDelegate.m
|
||||
+++ b/AppDelegate.m
|
||||
@@ -1,4 +1,4 @@
|
||||
int main(void) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
return version;
|
||||
}
|
||||
`;
|
||||
|
||||
const GO_DIFF = `diff --git a/main.go b/main.go
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
- version := 1
|
||||
+ version := 2
|
||||
fmt.Println(version)
|
||||
}
|
||||
`;
|
||||
|
||||
const PHP_DIFF = `diff --git a/index.php b/index.php
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/index.php
|
||||
+++ b/index.php
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
-$version = 1;
|
||||
+$version = 2;
|
||||
echo $version;
|
||||
?>
|
||||
`;
|
||||
|
||||
const YAML_DIFF = `diff --git a/config.yaml b/config.yaml
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/config.yaml
|
||||
+++ b/config.yaml
|
||||
@@ -1,3 +1,3 @@
|
||||
app: paseo
|
||||
-count: 1
|
||||
+count: 2
|
||||
enabled: true
|
||||
`;
|
||||
|
||||
const XML_DIFF = `diff --git a/config.xml b/config.xml
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/config.xml
|
||||
+++ b/config.xml
|
||||
@@ -1,3 +1,3 @@
|
||||
<config>
|
||||
- <count>1</count>
|
||||
+ <count>2</count>
|
||||
</config>
|
||||
`;
|
||||
|
||||
describe("parseDiff", () => {
|
||||
it("parses a simple diff with one hunk", () => {
|
||||
const files = parseDiff(SIMPLE_DIFF);
|
||||
@@ -251,6 +350,124 @@ describe("highlightDiffFile", () => {
|
||||
expect(highlighted.hunks[0].lines[1].tokens).toBeUndefined();
|
||||
expect(highlighted.hunks[0].lines[2].tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Rust code", () => {
|
||||
const files = parseDiff(RUST_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const fnLine = hunk.lines[1];
|
||||
expect(fnLine.tokens).toBeDefined();
|
||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to C code", () => {
|
||||
const files = parseDiff(C_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const mainLine = hunk.lines[1];
|
||||
expect(mainLine.tokens).toBeDefined();
|
||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Java code", () => {
|
||||
const files = parseDiff(JAVA_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const classLine = hunk.lines[1];
|
||||
expect(classLine.tokens).toBeDefined();
|
||||
expect(
|
||||
classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")
|
||||
).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||
const files = parseDiff(OBJECTIVE_C_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Go code", () => {
|
||||
const files = parseDiff(GO_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to PHP code", () => {
|
||||
const files = parseDiff(PHP_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("$version")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to YAML code", () => {
|
||||
const files = parseDiff(YAML_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("count")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to XML code", () => {
|
||||
const files = parseDiff(XML_DIFF);
|
||||
const highlighted = highlightDiffFile(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("<count>")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseAndHighlightDiff", () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostNewAgentRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceAgentRoute,
|
||||
buildHostWorkspaceFileRoute,
|
||||
@@ -66,16 +65,6 @@ describe("workspace route parsing", () => {
|
||||
expect(buildHostRootRoute("local")).toBe("/h/local");
|
||||
});
|
||||
|
||||
it("builds host new-agent routes", () => {
|
||||
expect(buildHostNewAgentRoute("local")).toBe("/h/local/new-agent");
|
||||
expect(
|
||||
buildHostNewAgentRoute("local", {
|
||||
workingDir: "/tmp/repo",
|
||||
worktreeMode: "create",
|
||||
})
|
||||
).toBe("/h/local/new-agent?workingDir=%2Ftmp%2Frepo&worktreeMode=create");
|
||||
});
|
||||
|
||||
it("builds workspace routes with open intent query", () => {
|
||||
expect(buildHostWorkspaceAgentRoute("local", "/tmp/repo", "agent-1")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1"
|
||||
|
||||
@@ -399,40 +399,6 @@ export function buildHostAgentsRoute(serverId: string): string {
|
||||
return `${base}/agents`;
|
||||
}
|
||||
|
||||
export function buildHostNewAgentRoute(
|
||||
serverId: string,
|
||||
options?: {
|
||||
workingDir?: string;
|
||||
provider?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
worktreeMode?: "create" | "attach";
|
||||
}
|
||||
): string {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
const searchParams = new URLSearchParams();
|
||||
const entries: Array<[string, string | undefined]> = [
|
||||
["workingDir", options?.workingDir],
|
||||
["provider", options?.provider],
|
||||
["modeId", options?.modeId],
|
||||
["model", options?.model],
|
||||
["thinkingOptionId", options?.thinkingOptionId],
|
||||
["worktreeMode", options?.worktreeMode],
|
||||
];
|
||||
for (const [key, value] of entries) {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (normalized) {
|
||||
searchParams.set(key, normalized);
|
||||
}
|
||||
}
|
||||
const search = searchParams.toString();
|
||||
return search ? `${base}/new-agent?${search}` : `${base}/new-agent`;
|
||||
}
|
||||
|
||||
export function buildHostOpenProjectRoute(serverId: string): string {
|
||||
const base = buildHostRootRoute(serverId);
|
||||
if (base === "/") {
|
||||
@@ -466,9 +432,6 @@ export function mapPathnameToServer(
|
||||
if (suffix.startsWith("agents")) {
|
||||
return `${base}/agents`;
|
||||
}
|
||||
if (suffix.startsWith("new-agent")) {
|
||||
return `${base}/new-agent`;
|
||||
}
|
||||
if (suffix.startsWith("open-project")) {
|
||||
return `${base}/open-project`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSidebarProjectRowModel } from './sidebar-project-row-model'
|
||||
import {
|
||||
buildSidebarProjectRowModel,
|
||||
isSidebarProjectFlattened,
|
||||
} from './sidebar-project-row-model'
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
@@ -51,11 +54,11 @@ describe('buildSidebarProjectRowModel', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
interaction: 'navigate',
|
||||
chevron: 'disclosure',
|
||||
trailingAction: 'none',
|
||||
flattenedWorkspace,
|
||||
kind: 'workspace_link',
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
trailingAction: 'none',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,10 +81,36 @@ describe('buildSidebarProjectRowModel', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.selected).toBe(true)
|
||||
expect(result).toMatchObject({
|
||||
kind: 'workspace_link',
|
||||
selected: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps git projects as expandable sections with a new worktree action', () => {
|
||||
it('flattens git projects with a single workspace and keeps the new worktree action', () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: '/repo/main',
|
||||
workspaceKind: 'local_checkout',
|
||||
})
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'git',
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'workspace_link',
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
trailingAction: 'new_worktree',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps multi-workspace git projects as expandable sections with a new worktree action', () => {
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: 'git',
|
||||
@@ -94,11 +123,28 @@ describe('buildSidebarProjectRowModel', () => {
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
interaction: 'toggle',
|
||||
kind: 'project_section',
|
||||
chevron: 'expand',
|
||||
trailingAction: 'new_worktree',
|
||||
flattenedWorkspace: null,
|
||||
selected: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSidebarProjectFlattened', () => {
|
||||
it('returns true for single-workspace projects regardless of kind', () => {
|
||||
expect(isSidebarProjectFlattened(project({ projectKind: 'git', workspaces: [workspace()] }))).toBe(true)
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: 'non_git', workspaces: [workspace()] }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for multi-workspace projects', () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(
|
||||
project({
|
||||
workspaces: [workspace({ workspaceId: '/repo/main' }), workspace({ workspaceId: '/repo/feat' })],
|
||||
})
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,12 +3,26 @@ import type {
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
|
||||
export interface SidebarProjectRowModel {
|
||||
interaction: 'toggle' | 'navigate'
|
||||
chevron: 'expand' | 'collapse' | 'disclosure'
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
flattenedWorkspace: SidebarWorkspaceEntry | null
|
||||
export interface SidebarProjectWorkspaceLinkRowModel {
|
||||
kind: 'workspace_link'
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
chevron: null
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
}
|
||||
|
||||
export interface SidebarProjectSectionRowModel {
|
||||
kind: 'project_section'
|
||||
chevron: 'expand' | 'collapse'
|
||||
trailingAction: 'new_worktree' | 'none'
|
||||
}
|
||||
|
||||
export type SidebarProjectRowModel =
|
||||
| SidebarProjectWorkspaceLinkRowModel
|
||||
| SidebarProjectSectionRowModel
|
||||
|
||||
export function isSidebarProjectFlattened(project: SidebarProjectEntry): boolean {
|
||||
return project.workspaces.length === 1
|
||||
}
|
||||
|
||||
export function buildSidebarProjectRowModel(input: {
|
||||
@@ -17,10 +31,9 @@ export function buildSidebarProjectRowModel(input: {
|
||||
serverId?: string | null
|
||||
activeWorkspaceSelection?: { serverId: string; workspaceId: string } | null
|
||||
}): SidebarProjectRowModel {
|
||||
const flattenedWorkspace =
|
||||
input.project.projectKind === 'non_git' && input.project.workspaces.length === 1
|
||||
? input.project.workspaces[0] ?? null
|
||||
: null
|
||||
const flattenedWorkspace = isSidebarProjectFlattened(input.project)
|
||||
? (input.project.workspaces[0] ?? null)
|
||||
: null
|
||||
const selected =
|
||||
flattenedWorkspace !== null &&
|
||||
Boolean(input.serverId) &&
|
||||
@@ -29,19 +42,17 @@ export function buildSidebarProjectRowModel(input: {
|
||||
|
||||
if (flattenedWorkspace) {
|
||||
return {
|
||||
interaction: 'navigate',
|
||||
chevron: 'disclosure',
|
||||
trailingAction: 'none',
|
||||
flattenedWorkspace,
|
||||
kind: 'workspace_link',
|
||||
workspace: flattenedWorkspace,
|
||||
selected,
|
||||
chevron: null,
|
||||
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
interaction: 'toggle',
|
||||
kind: 'project_section',
|
||||
chevron: input.collapsed ? 'expand' : 'collapse',
|
||||
trailingAction: input.project.projectKind === 'git' ? 'new_worktree' : 'none',
|
||||
flattenedWorkspace: null,
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
|
||||
const projects = [
|
||||
project("p1", [workspace("s1", "/repo/main"), workspace("s1", "/repo/feat-a")]),
|
||||
project("p2", [workspace("s1", "/repo2/main")]),
|
||||
project("p2", [workspace("s1", "/repo2/main"), workspace("s1", "/repo2/feat-a")]),
|
||||
];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
@@ -70,4 +70,16 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "/repo/w1" });
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("ignores collapsed state for flattened single-workspace projects", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
projects,
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
});
|
||||
|
||||
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-workspaces-list'
|
||||
import { isSidebarProjectFlattened } from './sidebar-project-row-model'
|
||||
|
||||
export interface SidebarShortcutWorkspaceTarget {
|
||||
serverId: string
|
||||
@@ -34,7 +35,7 @@ export function buildSidebarShortcutModel(input: {
|
||||
const shortcutIndexByWorkspaceKey = new Map<string, number>()
|
||||
|
||||
for (const project of input.projects) {
|
||||
if (input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
if (!isSidebarProjectFlattened(project) && input.collapsedProjectKeys.has(project.projectKey)) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,16 @@ import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { parser as jsonParser } from "@lezer/json";
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as cppParser } from "@lezer/cpp";
|
||||
import { parser as goParser } from "@lezer/go";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as javaParser } from "@lezer/java";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as phpParser } from "@lezer/php";
|
||||
import { parser as rustParser } from "@lezer/rust";
|
||||
import { parser as xmlParser } from "@lezer/xml";
|
||||
import { parser as yamlParser } from "@lezer/yaml";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
@@ -17,6 +24,16 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
tsx: jsParser.configure({ dialect: "tsx jsx" }),
|
||||
mjs: jsParser,
|
||||
cjs: jsParser,
|
||||
// C / C++ / Objective-C
|
||||
c: cppParser,
|
||||
h: cppParser,
|
||||
cc: cppParser,
|
||||
cpp: cppParser,
|
||||
cxx: cppParser,
|
||||
hpp: cppParser,
|
||||
hxx: cppParser,
|
||||
m: cppParser,
|
||||
mm: cppParser,
|
||||
// JSON
|
||||
json: jsonParser,
|
||||
// CSS
|
||||
@@ -25,8 +42,21 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
// HTML
|
||||
html: htmlParser,
|
||||
htm: htmlParser,
|
||||
// XML
|
||||
xml: xmlParser,
|
||||
// Java
|
||||
java: javaParser,
|
||||
// Python
|
||||
py: pythonParser,
|
||||
// Go
|
||||
go: goParser,
|
||||
// PHP
|
||||
php: phpParser,
|
||||
// YAML
|
||||
yaml: yamlParser,
|
||||
yml: yamlParser,
|
||||
// Rust
|
||||
rs: rustParser,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const tauriMock = vi.hoisted(() => {
|
||||
let listenerCleanup: (() => void) | null = null;
|
||||
|
||||
const connect = vi.fn(async () => {
|
||||
let listenerActive = false;
|
||||
|
||||
return {
|
||||
addListener: vi.fn(() => {
|
||||
listenerActive = true;
|
||||
listenerCleanup = vi.fn(() => {
|
||||
if (!listenerActive) {
|
||||
throw new TypeError(
|
||||
"undefined is not an object (evaluating 'listeners[eventId].handlerId')"
|
||||
);
|
||||
}
|
||||
listenerActive = false;
|
||||
});
|
||||
return listenerCleanup;
|
||||
}),
|
||||
send: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn(async () => {
|
||||
listenerCleanup?.();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
connect,
|
||||
getListenerCleanup: () => listenerCleanup,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./tauri", () => ({
|
||||
getTauri: () => ({
|
||||
websocket: {
|
||||
connect: tauriMock.connect,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("tauri-daemon-transport", () => {
|
||||
beforeEach(() => {
|
||||
tauriMock.connect.mockClear();
|
||||
});
|
||||
|
||||
it("does not unregister the websocket listener twice during close", async () => {
|
||||
const mod = await import("./tauri-daemon-transport");
|
||||
const transportFactory = mod.createTauriWebSocketTransportFactory();
|
||||
expect(transportFactory).not.toBeNull();
|
||||
|
||||
const transport = transportFactory!({ url: "ws://localhost:6767/ws" });
|
||||
await Promise.resolve();
|
||||
|
||||
expect(() => transport.close()).not.toThrow();
|
||||
expect(tauriMock.getListenerCleanup()).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,239 +0,0 @@
|
||||
import type { DaemonTransport, DaemonTransportFactory } from "@server/client/daemon-client";
|
||||
import { getTauri } from "@/utils/tauri";
|
||||
|
||||
type TauriWebSocketMessage =
|
||||
| { type: "Text"; data: string }
|
||||
| { type: "Binary"; data: number[] }
|
||||
| { type: "Ping"; data: number[] }
|
||||
| { type: "Pong"; data: number[] }
|
||||
| { type: "Close"; data: { code: number; reason: string } | null };
|
||||
|
||||
type TauriWebSocketConnection = {
|
||||
addListener(cb: (msg: TauriWebSocketMessage) => void): () => void;
|
||||
send(message: string | number[] | TauriWebSocketMessage): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
};
|
||||
|
||||
type TauriWebSocketModule = {
|
||||
connect(url: string, config?: unknown): Promise<TauriWebSocketConnection>;
|
||||
};
|
||||
|
||||
function toTauriOutgoingMessage(
|
||||
data: string | Uint8Array | ArrayBuffer
|
||||
): string | number[] {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Array.from(new Uint8Array(data));
|
||||
}
|
||||
return Array.from(data);
|
||||
}
|
||||
|
||||
function getTauriWebSocketModule(): TauriWebSocketModule | null {
|
||||
const ws = getTauri()?.websocket;
|
||||
if (ws && typeof ws.connect === "function") {
|
||||
return ws as TauriWebSocketModule;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createTauriWebSocketTransportFactory(): DaemonTransportFactory | null {
|
||||
if (getTauri() === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ({ url }) => {
|
||||
let ws: TauriWebSocketConnection | null = null;
|
||||
let wsListenerCleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
let opened = false;
|
||||
let closeEmitted = false;
|
||||
|
||||
const openHandlers = new Set<() => void>();
|
||||
const closeHandlers = new Set<(event?: unknown) => void>();
|
||||
const errorHandlers = new Set<(event?: unknown) => void>();
|
||||
const messageHandlers = new Set<(data: unknown) => void>();
|
||||
|
||||
const pendingSends: Array<string | number[]> = [];
|
||||
let closeRequested: { code?: number; reason?: string } | null = null;
|
||||
|
||||
const emitOpen = () => {
|
||||
if (disposed || opened) return;
|
||||
opened = true;
|
||||
for (const handler of openHandlers) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitClose = (event?: unknown) => {
|
||||
if (disposed || closeEmitted) return;
|
||||
closeEmitted = true;
|
||||
for (const handler of closeHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitError = (event?: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of errorHandlers) {
|
||||
try {
|
||||
handler(event);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const emitMessage = (data: unknown) => {
|
||||
if (disposed) return;
|
||||
for (const handler of messageHandlers) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
let module: TauriWebSocketModule | null = getTauriWebSocketModule();
|
||||
if (!module) {
|
||||
const start = Date.now();
|
||||
while (!module && Date.now() - start < 2000) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
module = getTauriWebSocketModule();
|
||||
}
|
||||
}
|
||||
if (!module) {
|
||||
throw new Error(
|
||||
"Tauri WebSocket plugin is not available (expected getTauri().websocket). Did you enable tauri-plugin-websocket and websocket:default capability?"
|
||||
);
|
||||
}
|
||||
|
||||
const socket = await module.connect(url);
|
||||
if (disposed) {
|
||||
try {
|
||||
await socket.disconnect();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ws = socket;
|
||||
|
||||
wsListenerCleanup = ws.addListener((msg) => {
|
||||
if (msg.type === "Text") {
|
||||
emitMessage({ data: msg.data });
|
||||
return;
|
||||
}
|
||||
if (msg.type === "Binary") {
|
||||
emitMessage({ data: new Uint8Array(msg.data) });
|
||||
return;
|
||||
}
|
||||
if (msg.type === "Close") {
|
||||
emitClose(msg.data ?? undefined);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
emitOpen();
|
||||
|
||||
while (pendingSends.length > 0) {
|
||||
const next = pendingSends.shift();
|
||||
if (!next) break;
|
||||
try {
|
||||
await ws.send(next);
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeRequested) {
|
||||
try {
|
||||
await ws.disconnect();
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
} finally {
|
||||
emitClose(closeRequested);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
emitError(error);
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
const transport: DaemonTransport = {
|
||||
send: (data) => {
|
||||
if (disposed) return;
|
||||
const outgoing = toTauriOutgoingMessage(data);
|
||||
if (!ws) {
|
||||
pendingSends.push(outgoing);
|
||||
return;
|
||||
}
|
||||
void ws.send(outgoing).catch((error) => emitError(error));
|
||||
},
|
||||
close: (code?: number, reason?: string) => {
|
||||
if (disposed) return;
|
||||
closeRequested = { code, reason };
|
||||
if (!ws) return;
|
||||
void ws
|
||||
.disconnect()
|
||||
.catch((error) => emitError(error))
|
||||
.finally(() => emitClose(closeRequested));
|
||||
},
|
||||
onMessage: (handler) => {
|
||||
messageHandlers.add(handler);
|
||||
return () => messageHandlers.delete(handler);
|
||||
},
|
||||
onOpen: (handler) => {
|
||||
openHandlers.add(handler);
|
||||
if (opened) {
|
||||
try {
|
||||
handler();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => openHandlers.delete(handler);
|
||||
},
|
||||
onClose: (handler) => {
|
||||
closeHandlers.add(handler);
|
||||
if (closeEmitted) {
|
||||
try {
|
||||
handler(closeRequested ?? undefined);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
return () => closeHandlers.delete(handler);
|
||||
},
|
||||
onError: (handler) => {
|
||||
errorHandlers.add(handler);
|
||||
return () => errorHandlers.delete(handler);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...transport,
|
||||
close: (code?: number, reason?: string) => {
|
||||
wsListenerCleanup = null;
|
||||
transport.close(code, reason);
|
||||
disposed = true;
|
||||
ws = null;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
buildLocalDaemonTransportUrl,
|
||||
createTauriLocalDaemonTransportFactory,
|
||||
} from "./managed-tauri-daemon-transport";
|
||||
import { createTauriWebSocketTransportFactory } from "./tauri-daemon-transport";
|
||||
|
||||
function normalizeNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
@@ -50,7 +49,6 @@ export async function buildClientConfig(
|
||||
serverId?: string
|
||||
): Promise<DaemonClientConfig> {
|
||||
const clientId = await getOrCreateClientId();
|
||||
const tauriTransportFactory = createTauriWebSocketTransportFactory();
|
||||
const localTransportFactory = createTauriLocalDaemonTransportFactory();
|
||||
const base = {
|
||||
clientId,
|
||||
@@ -61,9 +59,7 @@ export async function buildClientConfig(
|
||||
? localTransportFactory
|
||||
? { transportFactory: localTransportFactory }
|
||||
: {}
|
||||
: tauriTransportFactory
|
||||
? { transportFactory: tauriTransportFactory }
|
||||
: {}),
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (connection.type === "directSocket" || connection.type === "directPipe") {
|
||||
|
||||
@@ -57,4 +57,26 @@ describe("tool-call detail state", () => {
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("treats enriched search detail as meaningful", () => {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "search",
|
||||
query: "needle",
|
||||
toolName: "grep",
|
||||
content: "12:needle",
|
||||
filePaths: ["src/index.ts"],
|
||||
};
|
||||
|
||||
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
|
||||
});
|
||||
|
||||
it("treats fetch detail as meaningful", () => {
|
||||
const detail: ToolCallDetail = {
|
||||
type: "fetch",
|
||||
url: "https://example.com",
|
||||
result: "Fetched summary",
|
||||
};
|
||||
|
||||
assert.strictEqual(hasMeaningfulToolCallDetail(detail), true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,15 @@ export function hasMeaningfulToolCallDetail(
|
||||
case "write":
|
||||
return Boolean(detail.filePath || detail.content);
|
||||
case "search":
|
||||
return detail.query.trim().length > 0;
|
||||
return Boolean(
|
||||
detail.query.trim().length > 0 ||
|
||||
detail.content ||
|
||||
(detail.filePaths && detail.filePaths.length > 0) ||
|
||||
(detail.webResults && detail.webResults.length > 0) ||
|
||||
(detail.annotations && detail.annotations.length > 0)
|
||||
);
|
||||
case "fetch":
|
||||
return Boolean(detail.url || detail.result || detail.codeText);
|
||||
case "worktree_setup":
|
||||
return Boolean(detail.branchName || detail.worktreePath || detail.log);
|
||||
case "sub_agent":
|
||||
|
||||
@@ -20,6 +20,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
edit: Pencil,
|
||||
write: Pencil,
|
||||
search: Search,
|
||||
fetch: Search,
|
||||
worktree_setup: SquareTerminal,
|
||||
sub_agent: Bot,
|
||||
plain_text: Wrench,
|
||||
|
||||
Generated
+2
-2
@@ -2629,15 +2629,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "paseo"
|
||||
version = "0.1.29"
|
||||
version = "0.1.30"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"cc",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -19,6 +19,7 @@ name = "Paseo"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
tauri-build = { version = "2.5.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
@@ -41,9 +42,6 @@ tauri-plugin-websocket = "2"
|
||||
tokio = { version = "1", features = ["net", "sync"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
mac-notification-sys = "0.6"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ApplePressAndHoldEnabled</key>
|
||||
<false/>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>dev.paseo.desktop</string>
|
||||
<key>CFBundleName</key>
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
fn main() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
println!("cargo:rerun-if-changed=macos/paseo_notifications.h");
|
||||
println!("cargo:rerun-if-changed=macos/paseo_notifications.m");
|
||||
|
||||
cc::Build::new()
|
||||
.file("macos/paseo_notifications.m")
|
||||
.flag("-fobjc-arc")
|
||||
.compile("paseo_notifications");
|
||||
|
||||
println!("cargo:rustc-link-lib=framework=Foundation");
|
||||
println!("cargo:rustc-link-lib=framework=UserNotifications");
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#import <stdbool.h>
|
||||
|
||||
typedef void (*PaseoNotificationClickCallback)(const char* payload_json);
|
||||
|
||||
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out);
|
||||
bool paseo_notifications_send(
|
||||
const char* title,
|
||||
const char* body,
|
||||
const char* payload_json,
|
||||
char** error_out
|
||||
);
|
||||
void paseo_notifications_free_string(char* value);
|
||||
@@ -0,0 +1,176 @@
|
||||
#import "paseo_notifications.h"
|
||||
|
||||
#import <dispatch/dispatch.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
|
||||
@interface PaseoNotificationDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@property(nonatomic, assign) PaseoNotificationClickCallback clickCallback;
|
||||
@end
|
||||
|
||||
@implementation PaseoNotificationDelegate
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse*)response
|
||||
withCompletionHandler:(void (^)(void))completionHandler {
|
||||
if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier] &&
|
||||
self.clickCallback != NULL) {
|
||||
id payload = response.notification.request.content.userInfo[@"paseoPayloadJson"];
|
||||
if ([payload isKindOfClass:[NSString class]]) {
|
||||
self.clickCallback([(NSString*)payload UTF8String]);
|
||||
} else {
|
||||
self.clickCallback(NULL);
|
||||
}
|
||||
}
|
||||
|
||||
[center removeDeliveredNotificationsWithIdentifiers:@[
|
||||
response.notification.request.identifier,
|
||||
]];
|
||||
completionHandler();
|
||||
}
|
||||
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||||
willPresentNotification:(UNNotification*)notification
|
||||
withCompletionHandler:
|
||||
(void (^)(UNNotificationPresentationOptions options))completionHandler {
|
||||
if (@available(macOS 11.0, *)) {
|
||||
completionHandler(
|
||||
UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
completionHandler((UNNotificationPresentationOptions)0);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static PaseoNotificationDelegate* paseoNotificationDelegate = nil;
|
||||
static BOOL paseoNotificationsAvailable = NO;
|
||||
|
||||
static void paseo_run_on_main_sync(dispatch_block_t block) {
|
||||
if ([NSThread isMainThread]) {
|
||||
block();
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_sync(dispatch_get_main_queue(), block);
|
||||
}
|
||||
|
||||
static char* paseo_strdup(NSString* value) {
|
||||
if (value == nil) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
const char* utf8 = [value UTF8String];
|
||||
if (utf8 == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
size_t length = strlen(utf8);
|
||||
char* buffer = malloc(length + 1);
|
||||
if (buffer == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
memcpy(buffer, utf8, length + 1);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
static BOOL paseo_is_bundled_app_host(void) {
|
||||
NSURL* bundleURL = [[NSBundle mainBundle] bundleURL];
|
||||
NSString* pathExtension = [[bundleURL pathExtension] lowercaseString];
|
||||
return [pathExtension isEqualToString:@"app"];
|
||||
}
|
||||
|
||||
bool paseo_notifications_initialize(PaseoNotificationClickCallback callback, char** error_out) {
|
||||
@autoreleasepool {
|
||||
paseo_run_on_main_sync(^{
|
||||
if (paseoNotificationDelegate == nil) {
|
||||
paseoNotificationDelegate = [[PaseoNotificationDelegate alloc] init];
|
||||
paseoNotificationsAvailable = paseo_is_bundled_app_host();
|
||||
if (paseoNotificationsAvailable) {
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
setDelegate:paseoNotificationDelegate];
|
||||
}
|
||||
}
|
||||
|
||||
paseoNotificationDelegate.clickCallback = callback;
|
||||
});
|
||||
|
||||
if (error_out != NULL) {
|
||||
*error_out = NULL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool paseo_notifications_send(
|
||||
const char* title,
|
||||
const char* body,
|
||||
const char* payload_json,
|
||||
char** error_out
|
||||
) {
|
||||
@autoreleasepool {
|
||||
if (paseoNotificationDelegate == nil) {
|
||||
if (error_out != NULL) {
|
||||
*error_out = paseo_strdup(@"Notification bridge is not initialized.");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!paseoNotificationsAvailable) {
|
||||
if (error_out != NULL) {
|
||||
*error_out = NULL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
NSString* notificationTitle =
|
||||
[NSString stringWithUTF8String:(title != NULL ? title : "")];
|
||||
NSString* notificationBody =
|
||||
(body != NULL && body[0] != '\0') ? [NSString stringWithUTF8String:body] : nil;
|
||||
NSString* payloadString =
|
||||
(payload_json != NULL && payload_json[0] != '\0')
|
||||
? [NSString stringWithUTF8String:payload_json]
|
||||
: nil;
|
||||
NSString* identifier = [[NSUUID UUID] UUIDString];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
|
||||
content.title = notificationTitle;
|
||||
if (notificationBody != nil) {
|
||||
content.body = notificationBody;
|
||||
}
|
||||
if (payloadString != nil) {
|
||||
content.userInfo = @{
|
||||
@"paseoPayloadJson" : payloadString,
|
||||
};
|
||||
}
|
||||
|
||||
UNNotificationRequest* request =
|
||||
[UNNotificationRequest requestWithIdentifier:identifier
|
||||
content:content
|
||||
trigger:nil];
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
addNotificationRequest:request
|
||||
withCompletionHandler:^(NSError* _Nullable error) {
|
||||
if (error != nil) {
|
||||
NSLog(@"[PaseoNotifications] Failed to schedule notification: %@",
|
||||
error.localizedDescription);
|
||||
}
|
||||
}];
|
||||
});
|
||||
|
||||
if (error_out != NULL) {
|
||||
*error_out = NULL;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void paseo_notifications_free_string(char* value) {
|
||||
if (value != NULL) {
|
||||
free(value);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri::{AppHandle, Wry};
|
||||
|
||||
pub const DESKTOP_NOTIFICATION_CLICK_EVENT: &str = "desktop-notification-click";
|
||||
|
||||
@@ -22,70 +22,240 @@ struct DesktopNotificationClickPayload {
|
||||
mod platform {
|
||||
use super::{
|
||||
AppHandle, DesktopNotificationClickPayload, DesktopNotificationInput,
|
||||
DESKTOP_NOTIFICATION_CLICK_EVENT,
|
||||
DESKTOP_NOTIFICATION_CLICK_EVENT, Value, Wry,
|
||||
};
|
||||
use std::sync::OnceLock;
|
||||
use tauri::{async_runtime, Emitter, Runtime};
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use tauri::Emitter;
|
||||
|
||||
const MACOS_NOTIFICATION_SENDER_BUNDLE_ID: &str = "dev.paseo.desktop";
|
||||
static MACOS_NOTIFICATION_SENDER_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
#[link(name = "paseo_notifications", kind = "static")]
|
||||
unsafe extern "C" {
|
||||
fn paseo_notifications_initialize(
|
||||
callback: extern "C" fn(*const c_char),
|
||||
error_out: *mut *mut c_char,
|
||||
) -> bool;
|
||||
fn paseo_notifications_send(
|
||||
title: *const c_char,
|
||||
body: *const c_char,
|
||||
payload_json: *const c_char,
|
||||
error_out: *mut *mut c_char,
|
||||
) -> bool;
|
||||
fn paseo_notifications_free_string(value: *mut c_char);
|
||||
}
|
||||
|
||||
fn ensure_notification_sender() -> Result<(), String> {
|
||||
MACOS_NOTIFICATION_SENDER_INIT
|
||||
static MACOS_NOTIFICATION_APP: OnceLock<Mutex<Option<AppHandle<Wry>>>> = OnceLock::new();
|
||||
static MACOS_NOTIFICATION_INIT: OnceLock<Result<(), String>> = OnceLock::new();
|
||||
|
||||
fn app_slot() -> &'static Mutex<Option<AppHandle<Wry>>> {
|
||||
MACOS_NOTIFICATION_APP.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
fn read_owned_error_message(error_ptr: *mut c_char) -> String {
|
||||
if error_ptr.is_null() {
|
||||
return "Unknown macOS notification error".to_string();
|
||||
}
|
||||
|
||||
let message = unsafe { CStr::from_ptr(error_ptr) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
unsafe {
|
||||
paseo_notifications_free_string(error_ptr);
|
||||
}
|
||||
message
|
||||
}
|
||||
|
||||
extern "C" fn handle_notification_click(payload_json: *const c_char) {
|
||||
let data = if payload_json.is_null() {
|
||||
None
|
||||
} else {
|
||||
let raw = unsafe { CStr::from_ptr(payload_json) }.to_string_lossy();
|
||||
serde_json::from_str::<Value>(&raw).ok()
|
||||
};
|
||||
|
||||
let payload = DesktopNotificationClickPayload { data };
|
||||
let Ok(guard) = app_slot().lock() else {
|
||||
return;
|
||||
};
|
||||
let Some(app) = guard.clone() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
|
||||
log::warn!("failed to emit desktop notification click event: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_notification_bridge() -> Result<(), String> {
|
||||
MACOS_NOTIFICATION_INIT
|
||||
.get_or_init(|| {
|
||||
mac_notification_sys::set_application(MACOS_NOTIFICATION_SENDER_BUNDLE_ID)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Failed to initialize macOS notification sender for {}: {error}",
|
||||
MACOS_NOTIFICATION_SENDER_BUNDLE_ID
|
||||
)
|
||||
})
|
||||
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||
let ok = unsafe {
|
||||
paseo_notifications_initialize(handle_notification_click, &mut error_ptr)
|
||||
};
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(read_owned_error_message(error_ptr))
|
||||
}
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn send_notification<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
pub fn send_notification(
|
||||
app: AppHandle<Wry>,
|
||||
input: DesktopNotificationInput,
|
||||
) -> Result<(), String> {
|
||||
let _task = async_runtime::spawn_blocking(move || {
|
||||
if let Err(error) = ensure_notification_sender() {
|
||||
log::warn!("{error}");
|
||||
return;
|
||||
{
|
||||
let mut guard = app_slot()
|
||||
.lock()
|
||||
.map_err(|_| "Failed to lock macOS notification app handle".to_string())?;
|
||||
*guard = Some(app);
|
||||
}
|
||||
|
||||
ensure_notification_bridge()?;
|
||||
|
||||
let title = CString::new(input.title)
|
||||
.map_err(|_| "Desktop notification title contains interior NUL byte".to_string())?;
|
||||
let body = CString::new(input.body.unwrap_or_default())
|
||||
.map_err(|_| "Desktop notification body contains interior NUL byte".to_string())?;
|
||||
let payload_json = CString::new(
|
||||
input
|
||||
.data
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.map_err(|_| "Desktop notification payload contains interior NUL byte".to_string())?;
|
||||
|
||||
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||
let ok = unsafe {
|
||||
paseo_notifications_send(
|
||||
title.as_ptr(),
|
||||
body.as_ptr(),
|
||||
payload_json.as_ptr(),
|
||||
&mut error_ptr,
|
||||
)
|
||||
};
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(read_owned_error_message(error_ptr))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
extern "C" fn noop_notification_click(_payload_json: *const c_char) {}
|
||||
|
||||
static CLICK_TEST_SENDER: std::sync::OnceLock<mpsc::Sender<Option<String>>> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
extern "C" fn capture_notification_click(payload_json: *const c_char) {
|
||||
let payload = if payload_json.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
unsafe { CStr::from_ptr(payload_json) }
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
)
|
||||
};
|
||||
|
||||
if let Some(sender) = CLICK_TEST_SENDER.get() {
|
||||
let _ = sender.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
let mut notification = mac_notification_sys::Notification::default();
|
||||
notification
|
||||
.title(&input.title)
|
||||
.message(input.body.as_deref().unwrap_or(""))
|
||||
.wait_for_click(true);
|
||||
#[test]
|
||||
#[ignore = "requires a running Cocoa app main queue; verify through the desktop app"]
|
||||
fn native_notification_send_returns_without_waiting_for_click() {
|
||||
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||
let initialized = unsafe {
|
||||
paseo_notifications_initialize(noop_notification_click, &mut error_ptr)
|
||||
};
|
||||
assert!(
|
||||
initialized,
|
||||
"bridge initialization failed: {}",
|
||||
read_owned_error_message(error_ptr)
|
||||
);
|
||||
|
||||
match notification.send() {
|
||||
Ok(mac_notification_sys::NotificationResponse::Click) => {
|
||||
let payload = DesktopNotificationClickPayload { data: input.data };
|
||||
if let Err(error) = app.emit(DESKTOP_NOTIFICATION_CLICK_EVENT, payload) {
|
||||
log::warn!("failed to emit desktop notification click event: {error}");
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
log::warn!("failed to send macOS notification: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
let title = CString::new("Paseo notification smoke test").expect("valid title");
|
||||
let started_at = Instant::now();
|
||||
let sent = unsafe {
|
||||
paseo_notifications_send(
|
||||
title.as_ptr(),
|
||||
std::ptr::null(),
|
||||
std::ptr::null(),
|
||||
&mut error_ptr,
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
sent,
|
||||
"native send failed: {}",
|
||||
read_owned_error_message(error_ptr)
|
||||
);
|
||||
assert!(
|
||||
started_at.elapsed() < Duration::from_secs(2),
|
||||
"native send unexpectedly blocked for {:?}",
|
||||
started_at.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
#[test]
|
||||
#[ignore = "manual smoke test; click the macOS notification within 60 seconds"]
|
||||
fn native_notification_click_callback_roundtrip() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _ = CLICK_TEST_SENDER.set(tx);
|
||||
|
||||
let mut error_ptr: *mut c_char = std::ptr::null_mut();
|
||||
let initialized = unsafe {
|
||||
paseo_notifications_initialize(capture_notification_click, &mut error_ptr)
|
||||
};
|
||||
assert!(
|
||||
initialized,
|
||||
"bridge initialization failed: {}",
|
||||
read_owned_error_message(error_ptr)
|
||||
);
|
||||
|
||||
let title = CString::new("Paseo manual click test").expect("valid title");
|
||||
let body = CString::new("Click this notification to verify the callback path.")
|
||||
.expect("valid body");
|
||||
let payload_json =
|
||||
CString::new(r#"{"kind":"manual-smoke","source":"codex"}"#).expect("valid payload");
|
||||
|
||||
let sent = unsafe {
|
||||
paseo_notifications_send(
|
||||
title.as_ptr(),
|
||||
body.as_ptr(),
|
||||
payload_json.as_ptr(),
|
||||
&mut error_ptr,
|
||||
)
|
||||
};
|
||||
assert!(
|
||||
sent,
|
||||
"native send failed: {}",
|
||||
read_owned_error_message(error_ptr)
|
||||
);
|
||||
|
||||
eprintln!("Notification sent. Click it within 60 seconds.");
|
||||
let payload = rx
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.expect("timed out waiting for notification click");
|
||||
assert_eq!(payload.as_deref(), Some(r#"{"kind":"manual-smoke","source":"codex"}"#));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod platform {
|
||||
use super::{AppHandle, DesktopNotificationInput};
|
||||
use tauri::Runtime;
|
||||
use super::{AppHandle, DesktopNotificationInput, Wry};
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
pub fn send_notification<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
pub fn send_notification(
|
||||
app: AppHandle<Wry>,
|
||||
input: DesktopNotificationInput,
|
||||
) -> Result<(), String> {
|
||||
let mut notification = app.notification().builder().title(&input.title);
|
||||
@@ -102,8 +272,8 @@ mod platform {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_desktop_notification<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
pub async fn send_desktop_notification(
|
||||
app: AppHandle<Wry>,
|
||||
input: DesktopNotificationInput,
|
||||
) -> Result<(), String> {
|
||||
platform::send_notification(app, input)
|
||||
|
||||
@@ -66,13 +66,20 @@
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/relay": "0.1.30",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/go": "^1.0.1",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/html": "^1.3.13",
|
||||
"@lezer/java": "^1.1.3",
|
||||
"@lezer/javascript": "^1.5.4",
|
||||
"@lezer/json": "^1.0.3",
|
||||
"@lezer/markdown": "^1.6.2",
|
||||
"@lezer/php": "^1.0.5",
|
||||
"@lezer/python": "^1.1.18",
|
||||
"@lezer/rust": "^1.0.2",
|
||||
"@lezer/xml": "^1.0.6",
|
||||
"@lezer/yaml": "^1.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.2.6",
|
||||
"@sctg/sentencepiece-js": "^1.1.0",
|
||||
|
||||
@@ -213,6 +213,10 @@ type BranchSuggestionsPayload = BranchSuggestionsResponse['payload']
|
||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload']
|
||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload']
|
||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload']
|
||||
type CreatePaseoWorktreePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: 'create_paseo_worktree_response' }
|
||||
>['payload']
|
||||
type FileExplorerPayload = FileExplorerResponse['payload']
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse['payload']
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload']
|
||||
@@ -2148,6 +2152,22 @@ export class DaemonClient {
|
||||
})
|
||||
}
|
||||
|
||||
async createPaseoWorktree(
|
||||
input: { cwd: string; worktreeSlug?: string },
|
||||
requestId?: string
|
||||
): Promise<CreatePaseoWorktreePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: 'create_paseo_worktree_request',
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
},
|
||||
responseType: 'create_paseo_worktree_response',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
async validateBranch(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
|
||||
@@ -147,6 +147,30 @@ export type ToolCallDetail =
|
||||
| {
|
||||
type: "search";
|
||||
query: string;
|
||||
toolName?: "search" | "grep" | "glob" | "web_search";
|
||||
content?: string;
|
||||
filePaths?: string[];
|
||||
webResults?: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
}>;
|
||||
annotations?: string[];
|
||||
numFiles?: number;
|
||||
numMatches?: number;
|
||||
durationMs?: number;
|
||||
durationSeconds?: number;
|
||||
truncated?: boolean;
|
||||
mode?: "content" | "files_with_matches" | "count";
|
||||
}
|
||||
| {
|
||||
type: "fetch";
|
||||
url: string;
|
||||
prompt?: string;
|
||||
result?: string;
|
||||
code?: number;
|
||||
codeText?: string;
|
||||
bytes?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
| {
|
||||
type: "worktree_setup";
|
||||
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
ToolReadInputSchema,
|
||||
ToolReadOutputSchema,
|
||||
ToolSearchInputSchema,
|
||||
ToolGrepOutputSchema,
|
||||
ToolGlobOutputSchema,
|
||||
ToolShellInputSchema,
|
||||
ToolShellOutputSchema,
|
||||
ToolWebFetchInputSchema,
|
||||
ToolWebFetchOutputSchema,
|
||||
ToolWebSearchOutputSchema,
|
||||
ToolWriteInputSchema,
|
||||
ToolWriteOutputSchema,
|
||||
toEditToolDetail,
|
||||
toFetchToolDetail,
|
||||
toReadToolDetail,
|
||||
toSearchToolDetail,
|
||||
toShellToolDetail,
|
||||
@@ -87,27 +93,32 @@ const ClaudeToolDetailPass2Schema = z.union([
|
||||
ToolEditOutputSchema,
|
||||
toEditToolDetail
|
||||
),
|
||||
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("WebSearch", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "web_search" })
|
||||
),
|
||||
toolDetailBranchByName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("web_search", ToolSearchInputSchema, ToolWebSearchOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "web_search" })
|
||||
),
|
||||
toolDetailBranchByName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
),
|
||||
toolDetailBranchByName("Grep", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("Grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "grep" })
|
||||
),
|
||||
toolDetailBranchByName("grep", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("grep", ToolSearchInputSchema, ToolGrepOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "grep" })
|
||||
),
|
||||
toolDetailBranchByName("Glob", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("Glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "glob" })
|
||||
),
|
||||
toolDetailBranchByName("glob", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toolDetailBranchByName("glob", ToolSearchInputSchema, ToolGlobOutputSchema.nullable(), (input, output) =>
|
||||
toSearchToolDetail({ input, output, toolName: "glob" })
|
||||
),
|
||||
toolDetailBranchByName("WebFetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("web_fetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("WebFetchTool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("web_fetch_tool", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName("webfetch", ToolWebFetchInputSchema, ToolWebFetchOutputSchema, toFetchToolDetail),
|
||||
toolDetailBranchByName(
|
||||
"Skill",
|
||||
z.object({ skill: z.string() }).passthrough(),
|
||||
|
||||
@@ -103,6 +103,7 @@ describe("claude tool-call mapper", () => {
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,6 +221,7 @@ describe("claude tool-call mapper", () => {
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "tool call mapping",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,7 +250,12 @@ describe("claude tool-call mapper", () => {
|
||||
callId: "claude-glob-1",
|
||||
name: "Glob",
|
||||
input: { pattern: "**/.claude/commands/paseo*" },
|
||||
output: { output: "No files found" },
|
||||
output: {
|
||||
durationMs: 7,
|
||||
numFiles: 2,
|
||||
filenames: ["a.txt", "b.txt"],
|
||||
truncated: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -258,6 +265,11 @@ describe("claude tool-call mapper", () => {
|
||||
expect(item.detail).toEqual({
|
||||
type: "search",
|
||||
query: "**/.claude/commands/paseo*",
|
||||
toolName: "glob",
|
||||
filePaths: ["a.txt", "b.txt"],
|
||||
numFiles: 2,
|
||||
durationMs: 7,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,7 +284,14 @@ describe("claude tool-call mapper", () => {
|
||||
output_mode: "content",
|
||||
"-n": true,
|
||||
},
|
||||
output: { output: "No matches found" },
|
||||
output: {
|
||||
mode: "content",
|
||||
numFiles: 1,
|
||||
filenames: ["src/main.rs"],
|
||||
content: "12:const cli = true;",
|
||||
numLines: 1,
|
||||
numMatches: 1,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -282,6 +301,80 @@ describe("claude tool-call mapper", () => {
|
||||
expect(item.detail).toEqual({
|
||||
type: "search",
|
||||
query: '\\\\\\"cli\\\\\\""',
|
||||
toolName: "grep",
|
||||
content: "12:const cli = true;",
|
||||
filePaths: ["src/main.rs"],
|
||||
numFiles: 1,
|
||||
numMatches: 1,
|
||||
mode: "content",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps WebSearch calls with structured results", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-web-search-1",
|
||||
name: "WebSearch",
|
||||
input: { query: "OpenAI latest news" },
|
||||
output: {
|
||||
query: "OpenAI latest news",
|
||||
results: [
|
||||
"Top results:",
|
||||
{
|
||||
tool_use_id: "toolu_123",
|
||||
content: [
|
||||
{ title: "OpenAI launches thing", url: "https://example.com/1" },
|
||||
{ title: "Another result", url: "https://example.com/2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
durationSeconds: 1.5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "search",
|
||||
query: "OpenAI latest news",
|
||||
toolName: "web_search",
|
||||
webResults: [
|
||||
{ title: "OpenAI launches thing", url: "https://example.com/1" },
|
||||
{ title: "Another result", url: "https://example.com/2" },
|
||||
],
|
||||
annotations: ["Top results:"],
|
||||
durationSeconds: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps WebFetch calls with fetched content", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeCompletedToolCall({
|
||||
callId: "claude-web-fetch-1",
|
||||
name: "WebFetch",
|
||||
input: {
|
||||
url: "https://example.com/article",
|
||||
prompt: "Summarize this page",
|
||||
},
|
||||
output: {
|
||||
bytes: 5120,
|
||||
code: 200,
|
||||
codeText: "OK",
|
||||
result: "Summary text",
|
||||
durationMs: 250,
|
||||
url: "https://example.com/article",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "fetch",
|
||||
url: "https://example.com/article",
|
||||
prompt: "Summarize this page",
|
||||
result: "Summary text",
|
||||
code: 200,
|
||||
codeText: "OK",
|
||||
bytes: 5120,
|
||||
durationMs: 250,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -80,6 +80,13 @@ const ClaudeSearchToolNameSchema = z.union([
|
||||
z.literal("Glob"),
|
||||
z.literal("glob"),
|
||||
]);
|
||||
const ClaudeFetchToolNameSchema = z.union([
|
||||
z.literal("WebFetch"),
|
||||
z.literal("web_fetch"),
|
||||
z.literal("WebFetchTool"),
|
||||
z.literal("web_fetch_tool"),
|
||||
z.literal("webfetch"),
|
||||
]);
|
||||
const ClaudeSpeakToolNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -91,6 +98,7 @@ const ClaudeToolKindSchema = z.enum([
|
||||
"write",
|
||||
"edit",
|
||||
"search",
|
||||
"fetch",
|
||||
"speak",
|
||||
"unknown",
|
||||
]);
|
||||
@@ -146,6 +154,13 @@ const ClaudeToolCallPass2EnvelopeSchema = z.union([
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "search" as const,
|
||||
})),
|
||||
ClaudeToolCallPass2InputSchema.extend({
|
||||
name: ClaudeFetchToolNameSchema,
|
||||
}).transform((normalized) => ({
|
||||
...normalized,
|
||||
name: normalized.name.trim(),
|
||||
toolKind: "fetch" as const,
|
||||
})),
|
||||
ClaudeToolCallPass2InputSchema.extend({
|
||||
name: ClaudeSpeakToolNameSchema,
|
||||
}).transform((normalized) => ({
|
||||
@@ -181,6 +196,10 @@ const ClaudeToolCallPass2Schema = z.discriminatedUnion("toolKind", [
|
||||
toolKind: z.literal("search"),
|
||||
name: ClaudeSearchToolNameSchema,
|
||||
}),
|
||||
ClaudeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("fetch"),
|
||||
name: ClaudeFetchToolNameSchema,
|
||||
}),
|
||||
ClaudeToolCallPass2BaseSchema.extend({
|
||||
toolKind: z.literal("speak"),
|
||||
name: z.literal("speak"),
|
||||
@@ -203,8 +222,8 @@ function toToolCallTimelineItem(normalized: ClaudeToolCallPass2): ToolCallTimeli
|
||||
? "write_file"
|
||||
: normalized.toolKind === "edit"
|
||||
? "apply_patch"
|
||||
: normalized.toolKind === "search"
|
||||
? "search"
|
||||
: normalized.toolKind === "search" || normalized.toolKind === "fetch"
|
||||
? normalized.name
|
||||
: normalized.toolKind === "speak"
|
||||
? "speak"
|
||||
: normalized.name;
|
||||
|
||||
@@ -135,6 +135,10 @@ function normalizeCodexModelId(modelId: string | null | undefined): string | und
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCodexModelLabel(displayName: string): string {
|
||||
return displayName.replace(/\bgpt\b/gi, 'GPT');
|
||||
}
|
||||
|
||||
type CodexConfiguredDefaults = {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -3389,7 +3393,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
id: model.id,
|
||||
label: model.displayName,
|
||||
label: normalizeCodexModelLabel(model.displayName),
|
||||
description: model.description,
|
||||
isDefault: isDefaultModel,
|
||||
thinkingOptions: thinkingOptions.length > 0 ? thinkingOptions : undefined,
|
||||
|
||||
@@ -150,10 +150,10 @@ const CodexToolDetailPass2Schema = z.union([
|
||||
(input, output, cwd) => toEditToolDetail(input, output, { normalizePath: normalizePathForCwd(cwd) })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
),
|
||||
toolDetailBranchByNameWithCwd("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toSearchToolDetail({ input, toolName: "web_search" })
|
||||
),
|
||||
CodexSpeakToolDetailSchema,
|
||||
]);
|
||||
|
||||
@@ -151,6 +151,7 @@ describe("codex tool-call mapper", () => {
|
||||
expect(searchItem?.detail).toEqual({
|
||||
type: "search",
|
||||
query: "codex timeline",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -338,6 +339,7 @@ describe("codex tool-call mapper", () => {
|
||||
expect(searchItem?.detail).toEqual({
|
||||
type: "search",
|
||||
query: "codex timeline",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,10 +38,10 @@ const OpencodeKnownToolDetailSchema = z.union([
|
||||
toolDetailBranchByToolName("apply_patch", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByToolName("apply_diff", ToolEditInputSchema, ToolEditOutputSchema, toEditToolDetail),
|
||||
toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toSearchToolDetail({ input, toolName: "search" })
|
||||
),
|
||||
toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) =>
|
||||
toSearchToolDetail(input)
|
||||
toSearchToolDetail({ input, toolName: "web_search" })
|
||||
),
|
||||
]);
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("opencode tool-call mapper", () => {
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,6 +211,7 @@ describe("opencode tool-call mapper", () => {
|
||||
expect(searchItem.detail).toEqual({
|
||||
type: "search",
|
||||
query: "opencode mapper",
|
||||
toolName: "web_search",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -512,6 +512,71 @@ export const ToolSearchInputSchema = z.union([
|
||||
z.object({ pattern: z.string() }).passthrough().transform((value) => ({ query: value.pattern })),
|
||||
]);
|
||||
|
||||
export const ToolGlobOutputSchema = z
|
||||
.object({
|
||||
durationMs: z.number().finite(),
|
||||
numFiles: z.number().int().nonnegative(),
|
||||
filenames: z.array(z.string()),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ToolGrepOutputSchema = z
|
||||
.object({
|
||||
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
|
||||
numFiles: z.number().int().nonnegative(),
|
||||
filenames: z.array(z.string()),
|
||||
content: z.string().optional(),
|
||||
numLines: z.number().int().nonnegative().optional(),
|
||||
numMatches: z.number().int().nonnegative().optional(),
|
||||
appliedLimit: z.number().int().nonnegative().optional(),
|
||||
appliedOffset: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ToolWebFetchInputSchema = z
|
||||
.object({
|
||||
url: z.string(),
|
||||
prompt: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ToolWebFetchOutputSchema = z
|
||||
.object({
|
||||
bytes: z.number().int().nonnegative(),
|
||||
code: z.number().int(),
|
||||
codeText: z.string(),
|
||||
result: z.string(),
|
||||
durationMs: z.number().finite(),
|
||||
url: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ToolWebSearchResultHitSchema = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ToolWebSearchResultEntrySchema = z.union([
|
||||
z
|
||||
.object({
|
||||
tool_use_id: z.string(),
|
||||
content: z.array(ToolWebSearchResultHitSchema),
|
||||
})
|
||||
.passthrough(),
|
||||
z.string(),
|
||||
]);
|
||||
|
||||
export const ToolWebSearchOutputSchema = z
|
||||
.object({
|
||||
query: z.string(),
|
||||
results: z.array(ToolWebSearchResultEntrySchema),
|
||||
durationSeconds: z.number().finite(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ParsedToolShellInput = z.infer<typeof ToolShellInputSchema>;
|
||||
export type ParsedToolShellOutput = z.infer<typeof ToolShellOutputSchema>;
|
||||
export type ParsedToolReadInput = z.infer<typeof ToolReadInputSchema>;
|
||||
@@ -522,6 +587,11 @@ export type ParsedToolWriteOutput = z.infer<typeof ToolWriteOutputSchema>;
|
||||
export type ParsedToolEditInput = z.infer<typeof ToolEditInputSchema>;
|
||||
export type ParsedToolEditOutput = z.infer<typeof ToolEditOutputSchema>;
|
||||
export type ParsedToolSearchInput = z.infer<typeof ToolSearchInputSchema>;
|
||||
export type ParsedToolGlobOutput = z.infer<typeof ToolGlobOutputSchema>;
|
||||
export type ParsedToolGrepOutput = z.infer<typeof ToolGrepOutputSchema>;
|
||||
export type ParsedToolWebFetchInput = z.infer<typeof ToolWebFetchInputSchema>;
|
||||
export type ParsedToolWebFetchOutput = z.infer<typeof ToolWebFetchOutputSchema>;
|
||||
export type ParsedToolWebSearchOutput = z.infer<typeof ToolWebSearchOutputSchema>;
|
||||
|
||||
type NormalizePathFn = (filePath: string) => string | undefined;
|
||||
|
||||
@@ -539,6 +609,24 @@ function normalizeDetailPath(
|
||||
return normalizePath ? normalizePath(trimmed) : trimmed;
|
||||
}
|
||||
|
||||
function isParsedToolGlobOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
): output is ParsedToolGlobOutput {
|
||||
return Boolean(output && "truncated" in output);
|
||||
}
|
||||
|
||||
function isParsedToolGrepOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
): output is ParsedToolGrepOutput {
|
||||
return Boolean(output && "filenames" in output && !("truncated" in output));
|
||||
}
|
||||
|
||||
function isParsedToolWebSearchOutput(
|
||||
output: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null | undefined
|
||||
): output is ParsedToolWebSearchOutput {
|
||||
return Boolean(output && "results" in output);
|
||||
}
|
||||
|
||||
export function toShellToolDetail(
|
||||
input: ParsedToolShellInput | null,
|
||||
output: ParsedToolShellOutput | null
|
||||
@@ -625,15 +713,70 @@ export function toEditToolDetail(
|
||||
};
|
||||
}
|
||||
|
||||
export function toSearchToolDetail(
|
||||
input: ParsedToolSearchInput | null
|
||||
): ToolCallDetail | undefined {
|
||||
export function toSearchToolDetail(params: {
|
||||
input: ParsedToolSearchInput | null;
|
||||
output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null;
|
||||
toolName?: "search" | "grep" | "glob" | "web_search";
|
||||
}): ToolCallDetail | undefined {
|
||||
const { input, output, toolName } = params;
|
||||
if (!input?.query) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePaths =
|
||||
isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
|
||||
? output.filenames.length > 0
|
||||
? output.filenames
|
||||
: undefined
|
||||
: undefined;
|
||||
const webResults = isParsedToolWebSearchOutput(output)
|
||||
? output.results.flatMap((entry) => (typeof entry === "string" ? [] : entry.content))
|
||||
: undefined;
|
||||
const annotations = isParsedToolWebSearchOutput(output)
|
||||
? output.results.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: "search",
|
||||
query: input.query,
|
||||
...(toolName ? { toolName } : {}),
|
||||
...(isParsedToolGrepOutput(output) && output.content ? { content: output.content } : {}),
|
||||
...(filePaths ? { filePaths } : {}),
|
||||
...(webResults && webResults.length > 0 ? { webResults } : {}),
|
||||
...(annotations && annotations.length > 0 ? { annotations } : {}),
|
||||
...(isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)
|
||||
? { numFiles: output.numFiles }
|
||||
: {}),
|
||||
...(isParsedToolGrepOutput(output) && output.numMatches !== undefined
|
||||
? { numMatches: output.numMatches }
|
||||
: {}),
|
||||
...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}),
|
||||
...(isParsedToolWebSearchOutput(output)
|
||||
? { durationSeconds: output.durationSeconds }
|
||||
: {}),
|
||||
...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}),
|
||||
...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toFetchToolDetail(
|
||||
input: ParsedToolWebFetchInput | null,
|
||||
output: ParsedToolWebFetchOutput | null
|
||||
): ToolCallDetail | undefined {
|
||||
const url = input?.url ?? output?.url;
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "fetch",
|
||||
url,
|
||||
...(input?.prompt ? { prompt: input.prompt } : {}),
|
||||
...(output?.result ? { result: output.result } : {}),
|
||||
...(output?.code !== undefined ? { code: output.code } : {}),
|
||||
...(output?.codeText ? { codeText: output.codeText } : {}),
|
||||
...(output?.bytes !== undefined ? { bytes: output.bytes } : {}),
|
||||
...(output?.durationMs !== undefined ? { durationMs: output.durationMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("starts and serves health endpoint", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
openai: { apiKey: "test-openai-api-key" },
|
||||
@@ -77,6 +81,47 @@ describe("paseo daemon bootstrap", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("does not block daemon start on local speech model downloads", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let releaseFetch: ((value: Response) => void) | null = null;
|
||||
const fetchGate = new Promise<Response>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
});
|
||||
vi.stubGlobal("fetch", vi.fn(() => fetchGate));
|
||||
|
||||
const daemonHandle = await createTestPaseoDaemon({
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "local", explicit: true, enabled: true },
|
||||
voiceTurnDetection: { provider: "local", explicit: true, enabled: false },
|
||||
voiceStt: { provider: "local", explicit: true, enabled: false },
|
||||
voiceTts: { provider: "local", explicit: true, enabled: false },
|
||||
},
|
||||
local: {
|
||||
modelsDir: path.join(os.tmpdir(), `paseo-missing-models-${Date.now()}`),
|
||||
models: {
|
||||
dictationStt: "parakeet-tdt-0.6b-v3-int8",
|
||||
voiceStt: "parakeet-tdt-0.6b-v3-int8",
|
||||
voiceTts: "kokoro-en-v0_19",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await originalFetch(`http://127.0.0.1:${daemonHandle.port}/api/health`);
|
||||
expect(response.ok).toBe(true);
|
||||
} finally {
|
||||
releaseFetch?.(
|
||||
new Response(null, {
|
||||
status: 500,
|
||||
statusText: "test cleanup",
|
||||
})
|
||||
);
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses Windows named pipes as managed IPC listen targets", () => {
|
||||
expect(parseListenString(String.raw`\\.\pipe\paseo-managed-test`)).toEqual({
|
||||
type: "pipe",
|
||||
|
||||
@@ -30,7 +30,6 @@ export function buildOfferEndpoints({
|
||||
}
|
||||
|
||||
endpoints.push(`localhost:${port}`);
|
||||
endpoints.push(`127.0.0.1:${port}`);
|
||||
|
||||
return dedupePreserveOrder(endpoints);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ import { DownloadTokenStore } from './file-download/token-store.js'
|
||||
import { PushTokenStore } from './push/token-store.js'
|
||||
import {
|
||||
type WorktreeConfig,
|
||||
computeWorktreePath,
|
||||
getWorktreeSetupCommands,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
@@ -144,6 +147,7 @@ import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
NotGitRepoError,
|
||||
MergeConflictError,
|
||||
@@ -154,6 +158,7 @@ import {
|
||||
pushCurrentBranch,
|
||||
createPullRequest,
|
||||
getPullRequestStatus,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from '../utils/checkout-git.js'
|
||||
import { getProjectIcon } from '../utils/project-icon.js'
|
||||
import { expandTilde } from '../utils/path.js'
|
||||
@@ -1596,6 +1601,10 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg)
|
||||
break
|
||||
|
||||
case 'create_paseo_worktree_request':
|
||||
await this.handleCreatePaseoWorktreeRequest(msg)
|
||||
break
|
||||
|
||||
case 'open_project_request':
|
||||
await this.handleOpenProjectRequest(msg)
|
||||
break
|
||||
@@ -2870,19 +2879,21 @@ export class Session {
|
||||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
|
||||
)
|
||||
|
||||
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch: normalized.baseBranch!,
|
||||
baseBranch,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
cwd = createdWorktree.worktreePath
|
||||
worktreeConfig = createdWorktree
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||
await this.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch: normalized.baseBranch!,
|
||||
baseBranch,
|
||||
newBranchName: normalized.newBranchName!,
|
||||
})
|
||||
} else if (normalized.baseBranch) {
|
||||
@@ -3094,13 +3105,6 @@ export class Session {
|
||||
this.assertSafeGitRef(baseBranch, 'base branch')
|
||||
}
|
||||
|
||||
if (createWorktree && !baseBranch) {
|
||||
throw new Error('Base branch is required when creating a worktree')
|
||||
}
|
||||
if (createNewBranch && !baseBranch) {
|
||||
throw new Error('Base branch is required when creating a new branch')
|
||||
}
|
||||
|
||||
if (createNewBranch) {
|
||||
if (!normalizedBranchName) {
|
||||
throw new Error('New branch name is required')
|
||||
@@ -3133,6 +3137,20 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGitCreateBaseBranch(cwd: string): Promise<string> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||
if (!checkout.isGit) {
|
||||
throw new Error('Cannot create a worktree outside a git repository')
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
|
||||
if (!baseBranch) {
|
||||
throw new Error('Unable to resolve repository default branch')
|
||||
}
|
||||
return baseBranch
|
||||
}
|
||||
|
||||
private toCheckoutError(error: unknown): CheckoutErrorPayload {
|
||||
if (error instanceof NotGitRepoError) {
|
||||
return { code: 'NOT_GIT_REPO', message: error.message }
|
||||
@@ -5677,6 +5695,54 @@ export class Session {
|
||||
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
|
||||
}
|
||||
|
||||
private async registerPendingWorktreeWorkspace(options: {
|
||||
repoRoot: string
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath)
|
||||
const basePlacement = await this.buildProjectPlacement(options.repoRoot)
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId)
|
||||
const existingProject = await this.projectRegistry.get(placement.projectKey)
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
})
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord)
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now)
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||
if (!existing || existing.archivedAt) {
|
||||
@@ -5910,6 +5976,129 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'create_paseo_worktree_request' }>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome })
|
||||
if (!checkout.isGit) {
|
||||
throw new Error('Create worktree requires a git repository')
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
|
||||
if (!baseBranch) {
|
||||
throw new Error('Unable to resolve repository default branch')
|
||||
}
|
||||
|
||||
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4()
|
||||
const validation = validateBranchSlug(normalizedSlug)
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`)
|
||||
}
|
||||
|
||||
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome)
|
||||
const workspace = await this.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName: normalizedSlug,
|
||||
})
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace)
|
||||
this.emit({
|
||||
type: 'create_paseo_worktree_response',
|
||||
payload: {
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
})
|
||||
|
||||
void this.createPaseoWorktreeInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot,
|
||||
baseBranch,
|
||||
slug: normalizedSlug,
|
||||
worktreePath,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree'
|
||||
this.sessionLogger.error(
|
||||
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
|
||||
'Failed to create worktree'
|
||||
)
|
||||
this.emit({
|
||||
type: 'create_paseo_worktree_response',
|
||||
payload: {
|
||||
workspace: null,
|
||||
error: message,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async createPaseoWorktreeInBackground(options: {
|
||||
requestCwd: string
|
||||
repoRoot: string
|
||||
baseBranch: string
|
||||
slug: string
|
||||
worktreePath: string
|
||||
}): Promise<void> {
|
||||
let setupTerminalId: string | null = null
|
||||
|
||||
try {
|
||||
await createAgentWorktree({
|
||||
cwd: options.repoRoot,
|
||||
branchName: options.slug,
|
||||
baseBranch: options.baseBranch,
|
||||
worktreeSlug: options.slug,
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath)
|
||||
if (setupCommands.length > 0 && this.terminalManager) {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktreePath,
|
||||
branchName: options.slug,
|
||||
repoRootPath: options.repoRoot,
|
||||
})
|
||||
this.terminalManager.registerCwdEnv({
|
||||
cwd: options.worktreePath,
|
||||
env: runtimeEnv,
|
||||
})
|
||||
const terminal = await this.terminalManager.createTerminal({
|
||||
cwd: options.worktreePath,
|
||||
name: `setup-${options.slug}`,
|
||||
env: runtimeEnv,
|
||||
})
|
||||
setupTerminalId = terminal.id
|
||||
|
||||
for (const command of setupCommands) {
|
||||
terminal.send({
|
||||
type: 'input',
|
||||
data: `${command}\r`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: options.slug,
|
||||
worktreePath: options.worktreePath,
|
||||
setupTerminalId,
|
||||
},
|
||||
'Background worktree creation failed'
|
||||
)
|
||||
} finally {
|
||||
await this.emitWorkspaceUpdateForCwd(options.worktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleArchiveWorkspaceRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { Session } from './session.js'
|
||||
import type { AgentSnapshotPayload } from '../shared/messages.js'
|
||||
@@ -335,55 +339,110 @@ describe('workspace aggregation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('create paseo worktree request returns a registered workspace descriptor', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-worktree-test-')))
|
||||
const repoDir = path.join(tempDir, 'repo')
|
||||
const paseoHome = path.join(tempDir, 'paseo-home')
|
||||
execSync(`mkdir -p ${repoDir}`)
|
||||
execSync('git init -b main', { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: 'pipe' })
|
||||
writeFileSync(path.join(repoDir, 'file.txt'), 'hello\n')
|
||||
execSync('git add .', { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: 'pipe' })
|
||||
|
||||
const workspaces = new Map()
|
||||
const projects = new Map()
|
||||
session.paseoHome = paseoHome
|
||||
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
|
||||
session.workspaceRegistry.upsert = async (record: any) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
}
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
|
||||
session.projectRegistry.list = async () => Array.from(projects.values())
|
||||
session.projectRegistry.upsert = async (record: any) => {
|
||||
projects.set(record.projectId, record)
|
||||
}
|
||||
session.emit = (message: { type: string; payload: unknown }) => {
|
||||
emitted.push(message)
|
||||
}
|
||||
try {
|
||||
await session.handleCreatePaseoWorktreeRequest({
|
||||
type: 'create_paseo_worktree_request',
|
||||
cwd: repoDir,
|
||||
worktreeSlug: 'worktree-123',
|
||||
requestId: 'req-worktree',
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const response = emitted.find((message) => message.type === 'create_paseo_worktree_response') as
|
||||
| { type: 'create_paseo_worktree_response'; payload: any }
|
||||
| undefined
|
||||
|
||||
expect(response?.payload.error).toBeNull()
|
||||
expect(response?.payload.workspace).toMatchObject({
|
||||
projectDisplayName: 'repo',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'worktree',
|
||||
name: 'worktree-123',
|
||||
status: 'done',
|
||||
})
|
||||
expect(response?.payload.workspace?.id).toContain(path.join('worktree-123'))
|
||||
expect(workspaces.has(response?.payload.workspace?.id)).toBe(true)
|
||||
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true)
|
||||
})
|
||||
|
||||
test('workspace update fanout for multiple cwd values is deduplicated', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: 'sub-dedupe',
|
||||
subscriptionId: 'sub-dedup',
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set()
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set(['/tmp/repo', '/tmp/repo/worktree'])
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'local_checkout',
|
||||
name: 'main',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
{
|
||||
id: '/tmp/repo/sub',
|
||||
id: '/tmp/repo/worktree',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'sub',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'worktree',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: '2026-03-01T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
session.onMessage = (message: { type: string; payload: unknown }) => {
|
||||
emitted.push(message)
|
||||
}
|
||||
|
||||
await session.emitWorkspaceUpdatesForCwds([
|
||||
'/tmp/repo',
|
||||
'/tmp/repo/',
|
||||
' /tmp/repo ',
|
||||
'/tmp/repo/sub',
|
||||
'/tmp/repo/sub/',
|
||||
])
|
||||
await session.emitWorkspaceUpdateForCwd('/tmp/repo/worktree')
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||
expect(workspaceUpdates).toHaveLength(2)
|
||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(['upsert', 'upsert'])
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
|
||||
'/tmp/repo',
|
||||
'/tmp/repo/sub',
|
||||
'/tmp/repo/worktree',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { RequestedSpeechProviders } from "../../speech-types.js";
|
||||
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
|
||||
import { PocketTtsOnnxTTS } from "./pocket/pocket-tts-onnx.js";
|
||||
import {
|
||||
ensureLocalSpeechModels,
|
||||
getLocalSpeechModelDir,
|
||||
DEFAULT_LOCAL_STT_MODEL,
|
||||
DEFAULT_LOCAL_TTS_MODEL,
|
||||
@@ -203,35 +202,6 @@ export async function initializeLocalSpeechServices(params: {
|
||||
models: localModels,
|
||||
});
|
||||
|
||||
if (requiredLocalModelIds.length > 0 && localConfig) {
|
||||
try {
|
||||
logger.info(
|
||||
{
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
},
|
||||
"Ensuring local speech models"
|
||||
);
|
||||
await ensureLocalSpeechModels({
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
logger,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
modelsDir: localConfig.modelsDir,
|
||||
modelIds: requiredLocalModelIds,
|
||||
hint:
|
||||
"Use `paseo speech models` to inspect status and " +
|
||||
"`paseo speech download --model <MODEL_ID>` to fetch missing models.",
|
||||
},
|
||||
"Local speech model bootstrap failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const localSttEngines = new Map<LocalSttModelId, LocalSttEngine>();
|
||||
|
||||
const getLocalSttEngine = async (
|
||||
|
||||
@@ -57,6 +57,105 @@ index 1234567..0000000
|
||||
-export { legacy };
|
||||
`;
|
||||
|
||||
const RUST_DIFF = `diff --git a/lib.rs b/lib.rs
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/lib.rs
|
||||
+++ b/lib.rs
|
||||
@@ -1,4 +1,4 @@
|
||||
fn main() {
|
||||
- let version = 1;
|
||||
+ let version = 2;
|
||||
println!("Hello, world!");
|
||||
}
|
||||
`;
|
||||
|
||||
const C_DIFF = `diff --git a/example.c b/example.c
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/example.c
|
||||
+++ b/example.c
|
||||
@@ -1,4 +1,4 @@
|
||||
int main(void) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
return version;
|
||||
}
|
||||
`;
|
||||
|
||||
const JAVA_DIFF = `diff --git a/Main.java b/Main.java
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/Main.java
|
||||
+++ b/Main.java
|
||||
@@ -1,5 +1,5 @@
|
||||
public class Main {
|
||||
public static void main(String[] args) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const OBJECTIVE_C_DIFF = `diff --git a/AppDelegate.m b/AppDelegate.m
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/AppDelegate.m
|
||||
+++ b/AppDelegate.m
|
||||
@@ -1,4 +1,4 @@
|
||||
int main(void) {
|
||||
- int version = 1;
|
||||
+ int version = 2;
|
||||
return version;
|
||||
}
|
||||
`;
|
||||
|
||||
const GO_DIFF = `diff --git a/main.go b/main.go
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/main.go
|
||||
+++ b/main.go
|
||||
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
- version := 1
|
||||
+ version := 2
|
||||
fmt.Println(version)
|
||||
}
|
||||
`;
|
||||
|
||||
const PHP_DIFF = `diff --git a/index.php b/index.php
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/index.php
|
||||
+++ b/index.php
|
||||
@@ -1,4 +1,4 @@
|
||||
<?php
|
||||
-$version = 1;
|
||||
+$version = 2;
|
||||
echo $version;
|
||||
?>
|
||||
`;
|
||||
|
||||
const YAML_DIFF = `diff --git a/config.yaml b/config.yaml
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/config.yaml
|
||||
+++ b/config.yaml
|
||||
@@ -1,3 +1,3 @@
|
||||
app: paseo
|
||||
-count: 1
|
||||
+count: 2
|
||||
enabled: true
|
||||
`;
|
||||
|
||||
const XML_DIFF = `diff --git a/config.xml b/config.xml
|
||||
index 1234567..abcdefg 100644
|
||||
--- a/config.xml
|
||||
+++ b/config.xml
|
||||
@@ -1,3 +1,3 @@
|
||||
<config>
|
||||
- <count>1</count>
|
||||
+ <count>2</count>
|
||||
</config>
|
||||
`;
|
||||
|
||||
describe("parseDiff", () => {
|
||||
it("parses a simple diff with one hunk", () => {
|
||||
const files = parseDiff(SIMPLE_DIFF);
|
||||
@@ -249,4 +348,122 @@ describe("highlightDiffFromHunks", () => {
|
||||
expect(highlighted.hunks[0].lines[1].tokens).toBeUndefined();
|
||||
expect(highlighted.hunks[0].lines[2].tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Rust code", () => {
|
||||
const files = parseDiff(RUST_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const fnLine = hunk.lines[1];
|
||||
expect(fnLine.tokens).toBeDefined();
|
||||
expect(fnLine.tokens!.some((t) => t.text === "fn" && t.style === "keyword")).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to C code", () => {
|
||||
const files = parseDiff(C_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const mainLine = hunk.lines[1];
|
||||
expect(mainLine.tokens).toBeDefined();
|
||||
expect(mainLine.tokens!.length).toBeGreaterThan(0);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Java code", () => {
|
||||
const files = parseDiff(JAVA_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const classLine = hunk.lines[1];
|
||||
expect(classLine.tokens).toBeDefined();
|
||||
expect(
|
||||
classLine.tokens!.some((t) => t.text === "public" && t.style === "keyword")
|
||||
).toBe(true);
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Objective-C file extensions", () => {
|
||||
const files = parseDiff(OBJECTIVE_C_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const hunk = highlighted.hunks[0];
|
||||
|
||||
const addedLine = hunk.lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to Go code", () => {
|
||||
const files = parseDiff(GO_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("version")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to PHP code", () => {
|
||||
const files = parseDiff(PHP_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("$version")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(
|
||||
addedLine!.tokens!.some((t) => t.text === "2" && t.style === "number")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to YAML code", () => {
|
||||
const files = parseDiff(YAML_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("count")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds syntax highlighting tokens to XML code", () => {
|
||||
const files = parseDiff(XML_DIFF);
|
||||
const highlighted = highlightDiffFromHunks(files[0]);
|
||||
const addedLine = highlighted.hunks[0].lines.find(
|
||||
(line) => line.type === "add" && line.content.includes("<count>")
|
||||
);
|
||||
|
||||
expect(addedLine?.tokens).toBeDefined();
|
||||
expect(addedLine!.tokens!.some((t) => t.style === "tag")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,16 @@ import { highlightTree, tagHighlighter, tags } from "@lezer/highlight";
|
||||
import { parser as jsParser } from "@lezer/javascript";
|
||||
import { parser as jsonParser } from "@lezer/json";
|
||||
import { parser as cssParser } from "@lezer/css";
|
||||
import { parser as cppParser } from "@lezer/cpp";
|
||||
import { parser as goParser } from "@lezer/go";
|
||||
import { parser as htmlParser } from "@lezer/html";
|
||||
import { parser as javaParser } from "@lezer/java";
|
||||
import { parser as pythonParser } from "@lezer/python";
|
||||
import { parser as markdownParser } from "@lezer/markdown";
|
||||
import { parser as phpParser } from "@lezer/php";
|
||||
import { parser as rustParser } from "@lezer/rust";
|
||||
import { parser as xmlParser } from "@lezer/xml";
|
||||
import { parser as yamlParser } from "@lezer/yaml";
|
||||
import { parser as elixirParser } from "lezer-elixir";
|
||||
import type { Parser } from "@lezer/common";
|
||||
|
||||
@@ -17,6 +24,16 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
tsx: jsParser.configure({ dialect: "ts jsx" }),
|
||||
mjs: jsParser,
|
||||
cjs: jsParser,
|
||||
// C / C++ / Objective-C
|
||||
c: cppParser,
|
||||
h: cppParser,
|
||||
cc: cppParser,
|
||||
cpp: cppParser,
|
||||
cxx: cppParser,
|
||||
hpp: cppParser,
|
||||
hxx: cppParser,
|
||||
m: cppParser,
|
||||
mm: cppParser,
|
||||
// JSON
|
||||
json: jsonParser,
|
||||
// CSS
|
||||
@@ -25,8 +42,21 @@ const parsersByExtension: Record<string, Parser> = {
|
||||
// HTML
|
||||
html: htmlParser,
|
||||
htm: htmlParser,
|
||||
// XML
|
||||
xml: xmlParser,
|
||||
// Java
|
||||
java: javaParser,
|
||||
// Python
|
||||
py: pythonParser,
|
||||
// Go
|
||||
go: goParser,
|
||||
// PHP
|
||||
php: phpParser,
|
||||
// YAML
|
||||
yaml: yamlParser,
|
||||
yml: yamlParser,
|
||||
// Rust
|
||||
rs: rustParser,
|
||||
// Elixir
|
||||
ex: elixirParser,
|
||||
exs: elixirParser,
|
||||
|
||||
@@ -69,6 +69,17 @@ export function normalizeHostPort(input: string): string {
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function normalizeLoopbackToLocalhost(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
if (isIpv6 && (host === "::1" || host === "::")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
|
||||
@@ -181,6 +181,32 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
|
||||
z.object({
|
||||
type: z.literal('search'),
|
||||
query: z.string(),
|
||||
toolName: z.enum(['search', 'grep', 'glob', 'web_search']).optional(),
|
||||
content: z.string().optional(),
|
||||
filePaths: z.array(z.string()).optional(),
|
||||
webResults: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
})
|
||||
).optional(),
|
||||
annotations: z.array(z.string()).optional(),
|
||||
numFiles: z.number().optional(),
|
||||
numMatches: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
durationSeconds: z.number().optional(),
|
||||
truncated: z.boolean().optional(),
|
||||
mode: z.enum(['content', 'files_with_matches', 'count']).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('fetch'),
|
||||
url: z.string(),
|
||||
prompt: z.string().optional(),
|
||||
result: z.string().optional(),
|
||||
code: z.number().optional(),
|
||||
codeText: z.string().optional(),
|
||||
bytes: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('worktree_setup'),
|
||||
@@ -910,6 +936,13 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
type: z.literal('create_paseo_worktree_request'),
|
||||
cwd: z.string(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal('open_project_request'),
|
||||
cwd: z.string(),
|
||||
@@ -1160,6 +1193,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
||||
DirectorySuggestionsRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1935,6 +1969,16 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const CreatePaseoWorktreeResponseSchema = z.object({
|
||||
type: z.literal('create_paseo_worktree_response'),
|
||||
payload: z.object({
|
||||
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
setupTerminalId: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const FileExplorerResponseSchema = z.object({
|
||||
type: z.literal('file_explorer_response'),
|
||||
payload: z.object({
|
||||
@@ -2211,6 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
||||
DirectorySuggestionsResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
CreatePaseoWorktreeResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
|
||||
@@ -90,6 +90,11 @@ function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay
|
||||
displayName: "Search",
|
||||
summary: input.detail.query,
|
||||
};
|
||||
case "fetch":
|
||||
return {
|
||||
displayName: "Fetch",
|
||||
summary: input.detail.url,
|
||||
};
|
||||
case "worktree_setup":
|
||||
return {
|
||||
displayName: "Worktree Setup",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MergeFromBaseConflictError,
|
||||
NotGitRepoError,
|
||||
pushCurrentBranch,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -707,6 +708,17 @@ describe("checkout git utilities", () => {
|
||||
expect(baseDiff.diff).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("resolves the repository default branch from origin HEAD", async () => {
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir });
|
||||
execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir });
|
||||
execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir });
|
||||
execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { cwd: repoDir });
|
||||
|
||||
await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main");
|
||||
});
|
||||
|
||||
it("merges to stored baseRefName when baseRef is not provided", async () => {
|
||||
// Create a non-default base branch with a unique commit.
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
|
||||
@@ -708,7 +708,7 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
|
||||
return url !== null;
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||
cwd: repoRoot,
|
||||
@@ -755,6 +755,10 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return resolveRepositoryDefaultBranch(repoRoot);
|
||||
}
|
||||
|
||||
function normalizeLocalBranchRefName(input: string): string {
|
||||
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||
}
|
||||
|
||||
@@ -668,12 +668,21 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
||||
export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
||||
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
|
||||
const projectHash = await deriveWorktreeProjectHash(cwd);
|
||||
return join(home, "worktrees", projectHash);
|
||||
}
|
||||
|
||||
export async function computeWorktreePath(
|
||||
cwd: string,
|
||||
slug: string,
|
||||
paseoHome?: string
|
||||
): Promise<string> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||
return join(worktreesRoot, slug);
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
|
||||
Reference in New Issue
Block a user