Compare commits

..

12 Commits

Author SHA1 Message Date
Mohamed Boudra
efa6a6aed3 chore(release): cut 0.1.18 2026-03-06 23:48:34 +07:00
Mohamed Boudra
ba054b4dfb docs(changelog): add 0.1.18 notes 2026-03-06 23:48:07 +07:00
Mohamed Boudra
604b41db9b refactor(app): extract project icon placeholder label logic 2026-03-06 23:43:02 +07:00
Zi Makki
8dd94757d7 fix(server): restore auto metadata generation 2026-03-06 17:36:29 +01:00
Mohamed Boudra
a99edcc0b2 feat(app): auto-focus terminal on create/switch, stabilize sidebar ordering for new items 2026-03-06 23:19:12 +07:00
Mohamed Boudra
1985bd6669 feat(server): add Phoenix priv/static to project icon search directories 2026-03-06 23:09:40 +07:00
Mohamed Boudra
674573938a Merge remote-tracking branch 'origin/tool-version-changes' 2026-03-06 22:53:46 +07:00
Mohamed Boudra
7821a8a8af feat(app): add Mod+W close-tab shortcut for desktop, simplify Android build scripts, and use universal DMG download 2026-03-06 22:53:03 +07:00
Zi Makki
226ece2bdd add expo stuff 2026-03-06 16:49:40 +01:00
Zi Makki
cec0b96adb updates to tool versions 2026-03-06 16:49:40 +01:00
Zi Makki
9d64c3e01e adding mise toml file 2026-03-06 16:49:40 +01:00
Mohamed Boudra
bb300fa2f8 fix(app): unblock deploy app timer typings [skip ci] 2026-03-06 22:33:20 +07:00
33 changed files with 609 additions and 75 deletions

9
.mise.toml Normal file
View File

@@ -0,0 +1,9 @@
[env]
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
_.path = [
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
]
[tools]
java = "17"

View File

@@ -1,2 +1,4 @@
rust 1.85.1
nodejs 22.20.0
rust 1.85.1
nodejs 22.20.0
java 21
android-sdk latest

View File

@@ -1,5 +1,18 @@
# Changelog
## 0.1.18 - 2026-03-06
### Added
- Added a desktop `Mod+W` shortcut to close the current tab.
### Improved
- New and newly selected terminals now take focus automatically so you can type immediately.
- Kept newly created workspaces and projects in a more stable order in the sidebar.
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
- Updated the website desktop download link to use the universal macOS DMG.
### Fixed
- Restored automatic agent metadata generation for Claude runs.
## 0.1.17 - 2026-03-06
### Added
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.

View File

@@ -141,12 +141,15 @@ From `packages/app`:
```bash
# development (debug)
APP_VARIANT=development npx expo prebuild --platform android --clean --non-interactive
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
APP_VARIANT=development npx expo run:android --variant=debug
# production (release)
APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
APP_VARIANT=production npx expo run:android --variant=release
# clean native project (when needed)
npx expo prebuild --platform android --clean --non-interactive
```
From repo root:
@@ -154,9 +157,10 @@ From repo root:
```bash
npm run android:development
npm run android:production
npm run android:clean
```
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
`npm run android:release` is an alias for `npm run android:production`.
### Cloud build + submit (EAS Workflows)

24
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.17",
"version": "0.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.17",
"version": "0.1.18",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21571,7 +21571,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"@boudra/expo-two-way-audio": "^0.1.3",
"@dnd-kit/core": "^6.3.1",
@@ -21579,7 +21579,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.17",
"@getpaseo/server": "0.1.18",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",
@@ -21685,11 +21685,11 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.17",
"@getpaseo/server": "0.1.17",
"@getpaseo/relay": "0.1.18",
"@getpaseo/server": "0.1.18",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -21739,14 +21739,14 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.17",
"version": "0.1.18",
"devDependencies": {
"@tauri-apps/cli": "^2.9.6"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -21762,12 +21762,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.17",
"@getpaseo/relay": "0.1.18",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",
@@ -22112,7 +22112,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.17",
"version": "0.1.18",
"dependencies": {
"@cloudflare/vite-plugin": "^1.20.3",
"@cloudflare/workers-types": "^4.20260114.0",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"workspaces": [
"packages/server",
@@ -26,9 +26,9 @@
"start": "npm run start --workspace=@getpaseo/server",
"android": "npm run android --workspace=@getpaseo/app",
"android:development": "npm run android:development --workspace=@getpaseo/app",
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
"android:production": "npm run android:production --workspace=@getpaseo/app",
"android:release": "npm run android:prod --workspace=@getpaseo/app",
"android:release": "npm run android:production --workspace=@getpaseo/app",
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
"ios": "npm run ios --workspace=@getpaseo/app",
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",

View File

@@ -1,4 +1,5 @@
import { test, expect, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import {
createAgent,
createAgentInRepo,
@@ -7,6 +8,7 @@ import {
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace";
import { getWorkspaceTabTestIds } from "./helpers/workspace-tabs";
import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
function visibleTestId(page: Page, testId: string) {
@@ -264,6 +266,23 @@ async function readCurrentTerminalBuffer(page: Page): Promise<string> {
}
}
async function expectTerminalFocused(page: Page): Promise<void> {
await expect
.poll(async () => {
return await page.evaluate(() => {
const surface = document.querySelector<HTMLElement>(
'[data-testid="terminal-surface"]'
);
if (!surface) {
return false;
}
const active = document.activeElement;
return active instanceof HTMLElement && surface.contains(active);
});
})
.toBe(true);
}
async function expectCurrentTerminalBufferToContain(page: Page, marker: string): Promise<void> {
await expect
.poll(async () => await readCurrentTerminalBuffer(page), { timeout: 30000 })
@@ -376,11 +395,7 @@ test("new terminal does not inherit output from the previously selected terminal
directory: repo.path,
prompt: "hello",
});
await switchWorkspaceViaSidebar({
page,
serverId,
targetWorkspacePath: repo.path,
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
@@ -403,6 +418,74 @@ test("new terminal does not inherit output from the previously selected terminal
}
});
test("workspace terminal tabs auto-focus on create and switch on desktop web", async ({
page,
}) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-focus-");
try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await createAgentInRepo(page, {
directory: repo.path,
prompt: "hello",
});
await page.goto(buildHostWorkspaceRoute(serverId, repo.path));
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000,
});
await page.getByTestId("workspace-new-terminal-tab").first().click();
await waitForTerminalAttachToSettle(page);
await expectTerminalFocused(page);
const firstMarker = `terminal-focus-one-${Date.now()}`;
await page.keyboard.type(`echo ${firstMarker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expectCurrentTerminalBufferToContain(page, firstMarker);
const terminalTabIdsBeforeSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
id.startsWith("workspace-tab-terminal_")
);
await page.getByTestId("workspace-new-terminal-tab").first().click();
await waitForTerminalAttachToSettle(page);
await expectTerminalFocused(page);
const terminalTabIdsAfterSecondCreate = (await getWorkspaceTabTestIds(page)).filter((id) =>
id.startsWith("workspace-tab-terminal_")
);
const secondTerminalTabId = terminalTabIdsAfterSecondCreate.find(
(id) => !terminalTabIdsBeforeSecondCreate.includes(id)
);
const firstTerminalTabId = terminalTabIdsBeforeSecondCreate[0];
if (!firstTerminalTabId || !secondTerminalTabId) {
throw new Error("Expected two distinct terminal tabs to exist.");
}
const secondMarker = `terminal-focus-two-${Date.now()}`;
await page.keyboard.type(`echo ${secondMarker}`, { delay: 1 });
await page.keyboard.press("Enter");
await expectCurrentTerminalBufferToContain(page, secondMarker);
await page.getByTestId(firstTerminalTabId).first().click();
await waitForTerminalAttachToSettle(page);
await expectTerminalFocused(page);
await expectCurrentTerminalBufferToContain(page, firstMarker);
await page.getByTestId(secondTerminalTabId).first().click();
await waitForTerminalAttachToSettle(page);
await expectTerminalFocused(page);
await expectCurrentTerminalBufferToContain(page, secondMarker);
} finally {
await repo.cleanup();
}
});
test("terminal reattaches cleanly after heavy output and tab switches", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-terminal-reattach-");

View File

@@ -1,16 +1,16 @@
{
"name": "@getpaseo/app",
"main": "index.ts",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "npm run android:development",
"android:development": "APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:prod": "npm run android:production",
"android:development": "APP_VARIANT=development expo prebuild --platform android --non-interactive && APP_VARIANT=development expo run:android --variant=debug",
"android:production": "APP_VARIANT=production expo prebuild --platform android --non-interactive && APP_VARIANT=production expo run:android --variant=release",
"android:release": "npm run android:production",
"android:clean": "expo prebuild --platform android --clean --non-interactive",
"ios": "expo run:ios",
"ios:release": "expo run:ios --configuration Release",
"web": "expo start --web",
@@ -32,7 +32,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@expo/vector-icons": "^15.0.2",
"@floating-ui/react-native": "^0.10.7",
"@getpaseo/server": "0.1.17",
"@getpaseo/server": "0.1.18",
"@gorhom/bottom-sheet": "^5.2.6",
"@gorhom/portal": "^1.0.14",
"@lezer/common": "^1.5.0",

View File

@@ -55,6 +55,7 @@ import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
import { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
import { confirmDialog } from '@/utils/confirm-dialog'
import { projectIconPlaceholderLabelFromDisplayName } from '@/utils/project-display-name'
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
@@ -416,6 +417,8 @@ function ProjectHeaderRow({
menuController: null,
debugId: `project:${project.projectKey}`,
})
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName)
const placeholderInitial = placeholderLabel.charAt(0).toUpperCase()
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
@@ -449,7 +452,7 @@ function ProjectHeaderRow({
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
) : (
<View style={styles.projectIconFallback}>
<Text style={styles.projectIconFallbackText}>{displayName.charAt(0).toUpperCase()}</Text>
<Text style={styles.projectIconFallbackText}>{placeholderInitial}</Text>
</View>
)}

View File

@@ -6,6 +6,7 @@ import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { TerminalEmulatorRuntime } from "../terminal/runtime/terminal-emulator-runtime";
import { focusWithRetries } from "../utils/web-focus";
import {
summarizeTerminalText,
terminalDebugLog,
@@ -343,7 +344,19 @@ export default function TerminalEmulator({
if (focusRequestToken <= 0) {
return;
}
runtimeRef.current?.focus();
return focusWithRetries({
focus: () => {
runtimeRef.current?.focus();
},
isFocused: () => {
const root = rootRef.current;
if (!root) {
return false;
}
const active = typeof document !== "undefined" ? document.activeElement : null;
return active instanceof HTMLElement && root.contains(active);
},
});
}, [focusRequestToken]);
useEffect(() => {

View File

@@ -214,6 +214,7 @@ export function TerminalPane({
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
const updateSelectedTerminalId = useCallback(
(next: string | null) => {
@@ -279,6 +280,21 @@ export function TerminalPane({
setResizeRequestToken((current) => current + 1);
}, []);
useEffect(() => {
if (isMobile || !isScreenFocused || !selectedTerminalId) {
lastAutoFocusKeyRef.current = null;
return;
}
const nextFocusKey = `${scopeKey}:${selectedTerminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
requestTerminalFocus();
}, [isMobile, isScreenFocused, requestTerminalFocus, scopeKey, selectedTerminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
return;

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
appendMissingOrderKeys,
applyStoredOrdering,
buildSidebarProjectsFromWorkspaces,
} from './use-sidebar-workspaces-list'
@@ -46,6 +47,28 @@ describe('applyStoredOrdering', () => {
})
})
describe('appendMissingOrderKeys', () => {
it('appends unseen keys while preserving existing order', () => {
const result = appendMissingOrderKeys({
currentOrder: ['project-b', 'project-a'],
visibleKeys: ['project-a', 'project-b', 'project-c'],
})
expect(result).toEqual(['project-b', 'project-a', 'project-c'])
})
it('returns the same array when there are no unseen keys', () => {
const currentOrder = ['project-a', 'project-b']
const result = appendMissingOrderKeys({
currentOrder,
visibleKeys: ['project-b', 'project-a'],
})
expect(result).toBe(currentOrder)
})
})
describe('buildSidebarProjectsFromWorkspaces', () => {
it('uses workspace descriptor name and status directly', () => {
const workspaces: WorkspaceDescriptor[] = [
@@ -70,4 +93,148 @@ describe('buildSidebarProjectsFromWorkspaces', () => {
expect(projects[0]?.workspaces[0]?.name).toBe('feat/hard-cut')
expect(projects[0]?.workspaces[0]?.statusBucket).toBe('failed')
})
it('preserves stored project order even when activity changes', () => {
const initialWorkspaces: WorkspaceDescriptor[] = [
{
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
},
]
const seededOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: initialWorkspaces,
projectOrder: [],
workspaceOrderByScope: {},
}).map((project) => project.projectKey),
})
const updatedProjects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: [
{
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-03T00:00:00.000Z'),
},
],
projectOrder: seededOrder,
workspaceOrderByScope: {},
})
expect(updatedProjects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a'])
})
it('appends new projects after the stored project order', () => {
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: [
{
id: '/repo/c',
projectId: 'project-c',
name: 'feat/c',
status: 'running',
activityAt: new Date('2026-01-04T00:00:00.000Z'),
},
{
id: '/repo/b',
projectId: 'project-b',
name: 'feat/b',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: '/repo/a',
projectId: 'project-a',
name: 'feat/a',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
projectOrder: ['project-b', 'project-a', 'project-c'],
workspaceOrderByScope: {},
})
expect(projects.map((project) => project.projectKey)).toEqual(['project-b', 'project-a', 'project-c'])
})
it('preserves stored workspace order when workspace activity changes', () => {
const initialProjects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: [
{
id: '/repo/main',
projectId: 'project-1',
name: 'main',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: '/repo/feature',
projectId: 'project-1',
name: 'feature',
status: 'running',
activityAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
projectOrder: ['project-1'],
workspaceOrderByScope: {},
})
const seededWorkspaceOrder = appendMissingOrderKeys({
currentOrder: [],
visibleKeys: initialProjects[0]?.workspaces.map((workspace) => workspace.workspaceKey) ?? [],
})
const projects = buildSidebarProjectsFromWorkspaces({
serverId: 'srv',
workspaces: [
{
id: '/repo/main',
projectId: 'project-1',
name: 'main',
status: 'running',
activityAt: new Date('2026-01-02T00:00:00.000Z'),
},
{
id: '/repo/feature',
projectId: 'project-1',
name: 'feature',
status: 'running',
activityAt: new Date('2026-01-03T00:00:00.000Z'),
},
],
projectOrder: ['project-1'],
workspaceOrderByScope: {
'srv::project-1': seededWorkspaceOrder,
},
})
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
'/repo/main',
'/repo/feature',
])
})
})

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useSessionStore } from '@/stores/session-store'
import { getHostRuntimeStore } from '@/runtime/host-runtime'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
@@ -217,6 +217,23 @@ export function applyStoredOrdering<T>(input: {
return ordered
}
export function appendMissingOrderKeys(input: {
currentOrder: string[]
visibleKeys: string[]
}): string[] {
if (input.visibleKeys.length === 0) {
return input.currentOrder
}
const existingKeys = new Set(input.currentOrder)
const missingKeys = input.visibleKeys.filter((key) => !existingKeys.has(key))
if (missingKeys.length === 0) {
return input.currentOrder
}
return [...input.currentOrder, ...missingKeys]
}
function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string {
return `${serverId.trim()}::${projectKey.trim()}`
}
@@ -296,6 +313,35 @@ export function useSidebarWorkspacesList(options?: {
})
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces])
useEffect(() => {
if (!serverId || projects.length === 0) {
return
}
const nextProjectOrder = appendMissingOrderKeys({
currentOrder: persistedProjectOrder,
visibleKeys: projects.map((project) => project.projectKey),
})
if (nextProjectOrder !== persistedProjectOrder) {
useSidebarOrderStore.getState().setProjectOrder(serverId, nextProjectOrder)
}
for (const project of projects) {
const workspaceOrderScopeKey = getWorkspaceOrderScopeKey(serverId, project.projectKey)
const persistedWorkspaceOrder =
persistedWorkspaceOrderByScope[workspaceOrderScopeKey] ?? EMPTY_ORDER
const nextWorkspaceOrder = appendMissingOrderKeys({
currentOrder: persistedWorkspaceOrder,
visibleKeys: project.workspaces.map((workspace) => workspace.workspaceKey),
})
if (nextWorkspaceOrder !== persistedWorkspaceOrder) {
useSidebarOrderStore
.getState()
.setWorkspaceOrder(serverId, project.projectKey, nextWorkspaceOrder)
}
}
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, projects, serverId])
const refreshAll = useCallback(() => {
if (!isActive || !serverId || connectionStatus !== 'online' || !enabled) {
return

View File

@@ -200,7 +200,7 @@ describe("keyboard-shortcuts", () => {
expect(match?.action).toBe("workspace.tab.new");
});
it("matches Alt+Shift+W to close current tab", () => {
it("matches Alt+Shift+W to close current tab on web", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "W",
@@ -208,7 +208,20 @@ describe("keyboard-shortcuts", () => {
altKey: true,
shiftKey: true,
}),
context: shortcutContext(),
context: shortcutContext({ isTauri: false }),
});
expect(match?.action).toBe("workspace.tab.close.current");
});
it("matches Mod+W to close current tab on tauri", () => {
const match = resolveKeyboardShortcut({
event: keyboardEvent({
key: "w",
code: "KeyW",
metaKey: true,
}),
context: shortcutContext({ isMac: true, isTauri: true }),
});
expect(match?.action).toBe("workspace.tab.close.current");
@@ -348,6 +361,11 @@ describe("keyboard-shortcut help sections", () => {
"shift",
"1-9",
]);
expect(findRow(sections, "workspace-tab-close-current")?.keys).toEqual([
"alt",
"shift",
"W",
]);
});
it("uses tauri defaults for workspace and tab jump", () => {
@@ -359,6 +377,10 @@ describe("keyboard-shortcut help sections", () => {
expect(findRow(sections, "new-agent")?.keys).toEqual(["mod", "shift", "O"]);
expect(findRow(sections, "workspace-jump-index")?.keys).toEqual(["mod", "1-9"]);
expect(findRow(sections, "workspace-tab-jump-index")?.keys).toEqual(["alt", "1-9"]);
expect(findRow(sections, "workspace-tab-close-current")?.keys).toEqual([
"mod",
"W",
]);
});
it("uses mod+period as non-mac left sidebar shortcut", () => {

View File

@@ -155,7 +155,24 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
},
},
{
id: "workspace-tab-close-current-alt-shift-w",
id: "workspace-tab-close-current-mod-w-tauri",
action: "workspace.tab.close.current",
matches: (event) =>
isMod(event) &&
!event.altKey &&
!event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => context.isTauri && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["mod", "W"],
when: (context) => context.isTauri,
},
},
{
id: "workspace-tab-close-current-alt-shift-w-web",
action: "workspace.tab.close.current",
matches: (event) =>
!event.metaKey &&
@@ -163,12 +180,13 @@ const SHORTCUT_BINDINGS: readonly KeyboardShortcutBinding[] = [
event.altKey &&
event.shiftKey &&
(event.code === "KeyW" || event.key.toLowerCase() === "w"),
when: (context) => !context.commandCenterOpen,
when: (context) => !context.isTauri && !context.commandCenterOpen,
help: {
id: "workspace-tab-close-current",
section: "global",
label: "Close current tab",
keys: ["alt", "shift", "W"],
when: (context) => !context.isTauri,
},
},
{

View File

@@ -53,7 +53,6 @@ import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
import { useDraftAgentCreateFlow } from '@/hooks/use-draft-agent-create-flow'
const EMPTY_PENDING_PERMISSIONS = new Map()
const MAX_INITIAL_AGENT_TITLE_CHARS = 60
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: false,
@@ -98,22 +97,6 @@ function getValidMode(provider: AgentProvider | undefined, value: string | undef
return modes.some((mode) => mode.id === value) ? value : undefined
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0)
if (!firstContentLine) {
return null
}
const normalized = firstContentLine.replace(/\s+/g, " ").trim()
if (!normalized) {
return null
}
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim()
return clamped.length > 0 ? clamped : null
}
type DraftAgentParams = {
serverId?: string
provider?: string
@@ -872,11 +855,9 @@ function DraftAgentScreenContent({
const modeId = modeOptions.length > 0 && selectedMode !== '' ? selectedMode : undefined
const trimmedModel = selectedModel.trim()
const trimmedThinkingOptionId = selectedThinkingOptionId.trim()
const derivedTitle = deriveInitialAgentTitle(text)
const config: AgentSessionConfig = {
provider: selectedProvider,
cwd: resolvedWorkingDir,
...(derivedTitle ? { title: derivedTitle } : {}),
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
projectDisplayNameFromProjectId,
projectIconPlaceholderLabelFromDisplayName,
} from "./project-display-name";
describe("projectDisplayNameFromProjectId", () => {
it("shows owner and repo for GitHub remote ids", () => {
expect(projectDisplayNameFromProjectId("remote:github.com/getpaseo/paseo")).toBe(
"getpaseo/paseo"
);
});
it("shows the trailing directory name for local projects", () => {
expect(projectDisplayNameFromProjectId("/Users/me/dev/paseo")).toBe("paseo");
});
});
describe("projectIconPlaceholderLabelFromDisplayName", () => {
it("uses repo name instead of owner for GitHub-style display names", () => {
expect(projectIconPlaceholderLabelFromDisplayName("getpaseo/paseo")).toBe("paseo");
});
it("returns the original display name when it has no path separator", () => {
expect(projectIconPlaceholderLabelFromDisplayName("paseo")).toBe("paseo");
});
});

View File

@@ -7,3 +7,15 @@ export function projectDisplayNameFromProjectId(projectId: string): string {
const segments = projectId.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || projectId;
}
export function projectIconPlaceholderLabelFromDisplayName(
displayName: string
): string {
const trimmedDisplayName = displayName.trim();
if (!trimmedDisplayName) {
return "";
}
const segments = trimmedDisplayName.split("/").filter(Boolean);
return segments[segments.length - 1] || trimmedDisplayName;
}

View File

@@ -366,7 +366,7 @@ export function installScrollJankInvestigation(): void {
timeoutHandles.set(timeoutId, true);
timerStats.timeout.active = timeoutHandles.size;
return timeoutId as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
}) as unknown as typeof setTimeout;
globalThis.clearTimeout = ((timeoutId?: number) => {
if (typeof timeoutId === "number" && timeoutHandles.delete(timeoutId)) {
@@ -391,7 +391,7 @@ export function installScrollJankInvestigation(): void {
intervalHandles.set(intervalId, true);
timerStats.interval.active = intervalHandles.size;
return intervalId as unknown as ReturnType<typeof setInterval>;
}) as typeof setInterval;
}) as unknown as typeof setInterval;
globalThis.clearInterval = ((intervalId?: number) => {
if (typeof intervalId === "number" && intervalHandles.delete(intervalId)) {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.17",
"version": "0.1.18",
"description": "Paseo CLI - control your AI coding agents from the command line",
"type": "module",
"files": [
@@ -24,8 +24,8 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/relay": "0.1.17",
"@getpaseo/server": "0.1.17",
"@getpaseo/relay": "0.1.18",
"@getpaseo/server": "0.1.18",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"description": "Paseo desktop app (Tauri wrapper)",
"scripts": {

View File

@@ -2629,7 +2629,7 @@ dependencies = [
[[package]]
name = "paseo"
version = "0.1.17"
version = "0.1.18"
dependencies = [
"base64 0.22.1",
"log",

View File

@@ -1,6 +1,6 @@
[package]
name = "paseo"
version = "0.1.17"
version = "0.1.18"
description = "Paseo Desktop"
authors = ["moboudra"]
license = "MIT"
@@ -36,3 +36,4 @@ tauri-plugin-websocket = "2"

View File

@@ -7,9 +7,9 @@
<key>CFBundleName</key>
<string>Paseo</string>
<key>CFBundleShortVersionString</key>
<string>0.1.17</string>
<string>0.1.18</string>
<key>CFBundleVersion</key>
<string>0.1.17</string>
<string>0.1.18</string>
<key>NSMicrophoneUsageDescription</key>
<string>Paseo needs access to your microphone for voice dictation and voice mode.</string>
</dict>

View File

@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Paseo",
"version": "0.1.17",
"version": "0.1.18",
"identifier": "dev.paseo.desktop",
"build": {
"frontendDist": "../../app/dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.17",
"version": "0.1.18",
"description": "Paseo relay for bridging daemon and client connections",
"type": "module",
"publishConfig": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.17",
"version": "0.1.18",
"description": "Paseo backend server",
"type": "module",
"publishConfig": {
@@ -63,7 +63,7 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@getpaseo/relay": "0.1.17",
"@getpaseo/relay": "0.1.18",
"@lezer/common": "^1.5.0",
"@lezer/css": "^1.3.0",
"@lezer/highlight": "^1.2.3",

View File

@@ -753,6 +753,93 @@ describe("ClaudeAgentSession redesign invariants", () => {
await session.close();
});
test("completes a foreground run when only system metadata arrives before the first assistant message", async () => {
let step = 0;
sdkMocks.query.mockImplementation(() =>
createBaseQueryMock(
vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "redesign-metadata-only-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "hook_response",
session_id: "redesign-metadata-only-session",
hook_name: "SessionStart:Callback",
hook_event: "SessionStart",
stdout: "",
stderr: "",
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: { content: "assistant output" },
},
};
}
if (step === 3) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
})
)
);
const session = await createSession();
try {
const events = await Promise.race([
collectUntilTerminal(session.stream("metadata helper prompt")),
new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Timed out waiting for foreground terminal event")),
1_000
);
}),
]);
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
const assistantText = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" && event.item.type === "assistant_message"
)
.map((event) => event.item.text)
.join("");
expect(assistantText).toContain("assistant output");
} finally {
await session.close();
}
});
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
const session = await createSession();
const internal = session as unknown as {

View File

@@ -2746,7 +2746,10 @@ class ClaudeAgentSession implements AgentSession {
if (!foregroundRun || foregroundRun.promptReplaySeen) {
return;
}
if (message.type === "system" && message.subtype === "init") {
// System metadata (init/hook callbacks/etc.) can precede the first prompt
// replay for a legitimate foreground run. Treating that as churn strands
// one-shot helper runs in autonomous fallback.
if (message.type === "system") {
return;
}
this.preReplayMetadataSeen = true;

View File

@@ -44,6 +44,10 @@ describe("findProjectIcon", () => {
expect(PRIORITY_DIRS).toContain("static");
expect(PRIORITY_DIRS).toContain("assets");
});
it("includes Phoenix static assets directory", () => {
expect(PRIORITY_DIRS).toContain("priv/static");
});
});
describe("IGNORED_DIRS", () => {
@@ -97,6 +101,14 @@ describe("findProjectIcon", () => {
expect(result).toBe(join(tempDir, "static", "favicon.svg"));
});
it("finds icon in Phoenix priv/static directory", async () => {
mkdirSync(join(tempDir, "priv", "static"), { recursive: true });
writeFileSync(join(tempDir, "priv", "static", "favicon.ico"), "icon content");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "priv", "static", "favicon.ico"));
});
it("finds icon in assets directory (priority dir)", async () => {
mkdirSync(join(tempDir, "assets"));
writeFileSync(join(tempDir, "assets", "logo.png"), "icon content");
@@ -208,6 +220,14 @@ describe("findProjectIcon", () => {
expect(result).toBe(join(tempDir, "packages", "ui", "logo.svg"));
});
it("finds icon in Phoenix app priv/static directory inside monorepo", async () => {
mkdirSync(join(tempDir, "apps", "api", "priv", "static"), { recursive: true });
writeFileSync(join(tempDir, "apps", "api", "priv", "static", "favicon.ico"), "icon");
const result = await findProjectIcon(tempDir);
expect(result).toBe(join(tempDir, "apps", "api", "priv", "static", "favicon.ico"));
});
it("prioritizes root priority dirs over monorepo dirs", async () => {
mkdirSync(join(tempDir, "public"), { recursive: true });
mkdirSync(join(tempDir, "packages", "app", "public"), { recursive: true });

View File

@@ -23,9 +23,16 @@ export const ICON_PATTERNS = [
];
/**
* Directories to search first (in priority order).
* Directories or directory paths to search first (in priority order).
*/
export const PRIORITY_DIRS = ["public", "static", "assets", "images", "img"];
export const PRIORITY_DIRS = [
"public",
"static",
"priv/static",
"assets",
"images",
"img",
];
/**
* Monorepo package directory patterns to scan (e.g., packages/app, apps/web).

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.17",
"version": "0.1.18",
"private": true,
"type": "module",
"scripts": {

View File

@@ -5,7 +5,7 @@ import websitePackage from '../../package.json'
import '~/styles.css'
const desktopVersion = websitePackage.version
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_aarch64.dmg`
const macDownloadHref = `https://github.com/getpaseo/paseo/releases/download/v${desktopVersion}/Paseo_${desktopVersion}_universal.dmg`
export const Route = createFileRoute('/')({
head: () => ({