Compare commits

...

19 Commits

Author SHA1 Message Date
Mohamed Boudra
10a556d6a1 chore(release): cut 0.1.95 2026-06-13 00:18:28 +07:00
Mohamed Boudra
fbd3dca7ed Changelog for 0.1.95 2026-06-13 00:16:42 +07:00
Mohamed Boudra
5b321c0644 Update ACP provider catalog version pins 2026-06-13 00:16:18 +07:00
Mohamed Boudra
b0ca08a452 Keep git review state fresh after reconnect (#1491) 2026-06-12 16:55:18 +00:00
Mohamed Boudra
8cd51c8a47 Clarify attachment upload icons 2026-06-12 22:52:41 +07:00
Mohamed Boudra
2c27f21a90 Treat SVG attachments as files 2026-06-12 22:52:41 +07:00
Mohamed Boudra
a929ea9726 Show an error for missing project paths (#1490)
* Fix missing project paths flashing in the sidebar

* Log project lifecycle changes

* Address project picker review feedback

* Show project picker transport errors

* Inject session filesystem for workspace tests
2026-06-12 23:42:21 +08:00
Mohamed Boudra
c4d544baf6 Reduce workspace render churn 2026-06-12 21:53:22 +07:00
Mohamed Boudra
889d058658 Move Terminal profiles section to end of workspace menu 2026-06-12 21:51:08 +07:00
Mohamed Boudra
85fa38bdde Fix git action priorities (#1488) 2026-06-12 21:57:35 +08:00
Mohamed Boudra
736b2da1a2 feat(app): add drag-and-drop file upload and file picker to composer (#1474)
- Desktop web: drag-and-drop non-image files onto the chat pane to upload
- Desktop web: file picker option in the composer attachment sheet
- Uploaded files are sent to the LLM as filename references in a tmp directory
- New useFilePicker hook for cross-platform file picking
- Extended useFileDropZone to support generic file drops
- Added UploadedFileAttachment type to protocol and composer attachment system
- Fixed adaptive modal sheet closing on backdrop press in selection mode
2026-06-12 21:56:51 +08:00
Mohamed Boudra
9c43e0222a Fix new workspace keyboard overlap (#1489) 2026-06-12 13:44:26 +00:00
paseo-ai[bot]
4824cfd05b fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 12:57:00 +00:00
Mohamed Boudra
7e46f6a647 Rework docs navigation and move alternatives to top-level pages (#1487)
* Rework docs navigation and move alternatives to top-level pages

- Add nested docs nav with categories, collapsible groups, breadcrumbs,
  and a right-hand page outline with heading slugs.
- Move alternatives out of /docs/alternatives into /alternatives/*
  marketing pages with 301 redirects.
- Add github-slugger for heading IDs and require explicit width on
  SiteShell so new pages can't accidentally pick the narrow prose layout.
- Remove the best-practices doc from public-docs.

* Make docs content column prose-width
2026-06-12 12:53:30 +00:00
Mohamed Boudra
fb0c5b27f3 Fix pull request image previews for uploaded attachments (#1486) 2026-06-12 20:30:30 +08:00
Mohamed Boudra
a3951495d4 refactor(app): improve terminal profile modal form and zinc button contrast
- Extract terminal profile add/edit modal into a reusable component with shared FormField/FormTextInput primitives.
- Add inline validation, save-pending state, and keyboard navigation.
- Fix zinc theme primary button foreground contrast by using accentForeground/destructiveForeground tokens.

Closes #1484
2026-06-12 19:30:14 +08:00
Mohamed Boudra
00a6e636fb Fix mobile sidebars reappearing ghost-open after heavy commits
On Android (Fabric), a large React commit landing after a sidebar
open/close settles — e.g. a long agent thread finishing its first
mount — re-applies React's stale committed props onto the native view,
reverting the UI-thread transform so the sidebar reappears with no
transition while store and worklet state correctly say "closed"
(software-mansion/react-native-reanimated#9635).

Work around it by bumping a settled-generation counter after every
settle so the animated styles refresh React's committed props from the
settled shared values. The backdrop's pointerEvents moves from the
worklet to React state for the same reason: Reanimated's prop
re-application doesn't cover it, and a reverted "auto" wedges an
invisible tap-eating backdrop.

Animations still run entirely on the UI thread; the bump only fires
after settle and writes no shared values.
2026-06-12 16:50:32 +07:00
Mohamed Boudra
9af18ef2f5 Fix 0.1.94 changelog attribution 2026-06-12 14:06:05 +07:00
paseo-ai[bot]
fd0793dc02 fix: update lockfile signatures and Nix hash [skip ci] 2026-06-12 06:42:39 +00:00
144 changed files with 4469 additions and 1016 deletions

View File

@@ -1,11 +1,31 @@
# Changelog
## 0.1.95 - 2026-06-13
### Added
- **Attach any file to agents on desktop** ([#1474](https://github.com/getpaseo/paseo/pull/1474))
### Improved
- The git push button shows before merge actions when your branch is ahead ([#1488](https://github.com/getpaseo/paseo/pull/1488))
- SVG attachments are uploaded to disk
- Switching workspaces feels smoother
### Fixed
- Fixed cases where outdated GitHub data could be shown ([#1491](https://github.com/getpaseo/paseo/pull/1491))
- Uploaded images in PR comments and review threads now load in the PR panel ([#1486](https://github.com/getpaseo/paseo/pull/1486))
- Opening a project whose folder is missing shows a clear error ([#1490](https://github.com/getpaseo/paseo/pull/1490))
- The new workspace title moves out of the way of the keyboard ([#1489](https://github.com/getpaseo/paseo/pull/1489))
- Sidebars no longer open on their own on Android
## 0.1.94 - 2026-06-12
### Added
- **Attach pull request comments, reviews, threads, and failed check logs to chat from the PR panel** ([#1400](https://github.com/getpaseo/paseo/pull/1400))
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1478](https://github.com/getpaseo/paseo/pull/1478) by [@dwyanewang](https://github.com/dwyanewang))
- **Use Paseo in Arabic, Chinese, English, French, Russian, and Spanish** ([#1282](https://github.com/getpaseo/paseo/pull/1282), [#1478](https://github.com/getpaseo/paseo/pull/1478) by [@chyendongnhanh338](https://github.com/chyendongnhanh338), [@dwyanewang](https://github.com/dwyanewang))
- **Create reusable terminal profiles from Host settings**
- **Open workspaces in Antigravity** ([#1424](https://github.com/getpaseo/paseo/pull/1424) by [@krumpyzoid](https://github.com/krumpyzoid))
@@ -15,7 +35,6 @@
- Copy file paths directly from file preview tab menus ([#1473](https://github.com/getpaseo/paseo/pull/1473))
- PR status stays current after an agent merges a branch ([#1455](https://github.com/getpaseo/paseo/pull/1455))
- Workspace tabs stay fast by retaining only the active workspace screens ([#1472](https://github.com/getpaseo/paseo/pull/1472))
- iOS simulator previews can be launched from worktrees
### Fixed

View File

@@ -1 +1 @@
sha256-62dqunclpCgWCsIyRV5DMjGdwUhPP4rbjEjSO9dJ7no=
sha256-xOrVsNfFlrlBetLE1dpRsAiRwgHiLhw3EB3EqpHDTuw=

43
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.94",
"version": "0.1.95",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.94",
"version": "0.1.95",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -37074,7 +37074,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -37405,12 +37405,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.94",
"@getpaseo/protocol": "0.1.94",
"@getpaseo/server": "0.1.94",
"@getpaseo/client": "0.1.95",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/server": "0.1.95",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37656,10 +37656,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@getpaseo/protocol": "0.1.94",
"@getpaseo/relay": "0.1.94",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/relay": "0.1.95",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -37679,7 +37679,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.94",
"version": "0.1.95",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37931,7 +37931,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.94",
"version": "0.1.95",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -37967,7 +37967,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38198,7 +38198,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"zod": "^3.23.8"
},
@@ -38219,7 +38219,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38437,14 +38437,14 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.94",
"@getpaseo/highlight": "0.1.94",
"@getpaseo/protocol": "0.1.94",
"@getpaseo/relay": "0.1.94",
"@getpaseo/client": "0.1.95",
"@getpaseo/highlight": "0.1.95",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/relay": "0.1.95",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -39216,13 +39216,14 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.94",
"version": "0.1.95",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
"@tanstack/react-router": "^1.166.4",
"@tanstack/react-start": "^1.166.4",
"framer-motion": "^12.35.2",
"github-slugger": "^2.0.0",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.94",
"version": "0.1.95",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -16,6 +16,7 @@ type NewWorkspaceDaemonClient = Pick<
>;
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
type WorkspacePayload = Pick<OpenProjectPayload, "error" | "workspace">;
export interface OpenedProject {
workspaceId: string;
@@ -24,7 +25,7 @@ export interface OpenedProject {
workspaceName: string;
}
function requireWorkspace(payload: OpenProjectPayload) {
function requireWorkspace(payload: WorkspacePayload) {
if (payload.error) {
throw new Error(payload.error);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.94",
"version": "0.1.95",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -206,6 +206,11 @@ function layoutSegment(input: LayoutSegmentInput): StreamLayoutItem[] {
});
}
// Keyed by history array identity; inner key encodes the inputs that affect history layout.
// History layout is stable across text-chunk flushes because the liveHead boundary item's
// kind and id don't change when only its text grows.
const historyLayoutCache = new WeakMap<StreamItem[], Map<string, StreamLayoutItem[]>>();
export function layoutStream(input: StreamLayoutInput): StreamLayout {
const auxiliaryTurnFooter = resolveAuxiliaryTurnFooter(input);
const historyBoundaryIndex = input.strategy.getHistoryLiveBoundaryIndex(input.history);
@@ -215,17 +220,46 @@ export function layoutStream(input: StreamLayoutInput): StreamLayout {
const liveHeadBoundaryItem =
liveHeadBoundaryIndex === null ? null : (input.liveHead[liveHeadBoundaryIndex] ?? null);
const frameOrder = input.strategy.getFrameChildOrder();
const history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
let history: StreamLayoutItem[];
if (input.history.length > 0) {
// The cache key encodes every input that can change history layout. liveHeadBoundaryItem.id
// and .kind are stable across text-only flushes (text growth doesn't change what kind of
// item borders history), so cached layout stays valid between flushes.
const historyCacheKey = [
input.agentStatus,
frameOrder,
historyBoundaryIndex ?? "null",
liveHeadBoundaryItem?.id ?? "null",
liveHeadBoundaryItem?.kind ?? "null",
auxiliaryTurnFooter?.itemId ?? "null",
].join(":");
let byKey = historyLayoutCache.get(input.history);
if (!byKey) {
byKey = new Map();
historyLayoutCache.set(input.history, byKey);
}
const cached = byKey.get(historyCacheKey);
if (cached) {
history = cached;
} else {
history = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,
items: input.history,
timingByAssistantId: input.timingByAssistantId,
auxiliaryTurnFooter,
frameOrder,
boundaryIndex: historyBoundaryIndex,
boundaryAboveItem: null,
boundaryBelowItem: liveHeadBoundaryItem,
});
byKey.set(historyCacheKey, history);
}
} else {
history = [];
}
const liveHead = layoutSegment({
strategy: input.strategy,
agentStatus: input.agentStatus,

View File

@@ -2,6 +2,7 @@ import React, {
forwardRef,
memo,
useCallback,
useContext,
useEffect,
useImperativeHandle,
useMemo,
@@ -80,6 +81,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { isWeb } from "@/constants/platform";
import type { Theme } from "@/styles/theme";
import { recordRenderProfileReasons } from "@/utils/render-profiler";
import { MountedTabActiveContext } from "@/components/split-container";
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
@@ -364,15 +366,29 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
handleInlinePathPress({ raw: filePath, path: filePath }, "main");
});
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
// cell-window renders on every 48ms flush from background agents.
// When isActive flips back to true, the context change triggers a re-render and
// the component reads the current (fresh) streamItems/streamHead from props.
const isActive = useContext(MountedTabActiveContext);
const frozenStreamItemsRef = useRef(streamItems);
const frozenStreamHeadRef = useRef(streamHead);
if (isActive) {
frozenStreamItemsRef.current = streamItems;
frozenStreamHeadRef.current = streamHead;
}
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
agentStatus: agent.status,
tail: streamItems,
head: streamHead ?? EMPTY_STREAM_HEAD,
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [agent.status, isMobile, streamHead, streamItems]);
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
const streamLayout = useMemo(
() =>
layoutStream({
@@ -684,14 +700,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(item) => renderHistoryRow(item),
[renderHistoryRow],
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item) =>
// useStableEvent keeps the function reference stable across flushes.
// layoutLiveHeadItemById and renderStreamItem are read from the ref at call time,
// so the live-head render always uses the latest layout without causing renderers
// to be a new object on every text-chunk flush.
const renderLiveHeadRow: StreamSegmentRenderers["renderLiveHeadRow"] = useStableEvent(
(item: StreamItem) =>
renderLiveHeadStreamItem({
item,
layoutItemById: layoutLiveHeadItemById,
renderStreamItem,
}),
[layoutLiveHeadItemById, renderStreamItem],
);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
return renderLiveAuxiliaryNode({

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
getMimeTypeFromPath,
getRasterImageMimeTypeFromPath,
isRasterImageFile,
isRasterImageMimeType,
isRasterImagePath,
RASTER_IMAGE_FILE_EXTENSIONS,
} from "./file-types";
describe("attachment file types", () => {
it("keeps SVG as a file while treating raster image files as images", () => {
expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml");
expect(isRasterImagePath("/tmp/logo.svg")).toBe(false);
expect(isRasterImageMimeType("image/svg+xml")).toBe(false);
expect(isRasterImageFile(new File(["<svg />"], "logo.svg", { type: "image/svg+xml" }))).toBe(
false,
);
expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png");
expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true);
expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true);
expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true);
});
it("does not offer SVG in the image picker extension list", () => {
expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual(
new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]),
);
});
});

View File

@@ -0,0 +1,94 @@
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
".pdf": "application/pdf",
".txt": "text/plain",
".md": "text/markdown",
".json": "application/json",
".js": "text/javascript",
".ts": "text/typescript",
".tsx": "text/typescript-jsx",
".jsx": "text/javascript",
".html": "text/html",
".css": "text/css",
".xml": "text/xml",
".csv": "text/csv",
".zip": "application/zip",
".gz": "application/gzip",
".tar": "application/x-tar",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".wav": "audio/wav",
".ogg": "audio/ogg",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".ppt": "application/vnd.ms-powerpoint",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
};
const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
};
const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION));
export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map(
(extension) => extension.slice(1),
);
export function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
if (extensionIndex < 0) {
return "";
}
return normalizedPath.slice(extensionIndex).toLowerCase();
}
export function getMimeTypeFromPath(path: string): string {
return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream";
}
export function getRasterImageMimeTypeFromPath(path: string): string | null {
return RASTER_IMAGE_MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? null;
}
export function isRasterImagePath(path: string): boolean {
return getRasterImageMimeTypeFromPath(path) !== null;
}
export function isRasterImageMimeType(mimeType: string | null | undefined): boolean {
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
return Boolean(normalized && RASTER_IMAGE_MIME_TYPES.has(normalized));
}
export function isRasterImageFile(file: Pick<File, "name" | "type">): boolean {
if (isRasterImageMimeType(file.type)) {
return true;
}
return file.type.trim().length === 0 && isRasterImagePath(file.name);
}

View File

@@ -1,4 +1,8 @@
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type {
AgentAttachment,
GitHubSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file";
@@ -61,6 +65,7 @@ export type PullRequestContextAttachment =
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "github_pr"; item: GitHubSearchItem };

View File

@@ -53,4 +53,8 @@ describe("parseImageDataUrl", () => {
it("ignores non-image data URLs", () => {
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
});
it("ignores SVG data URLs", () => {
expect(parseImageDataUrl("data:image/svg+xml;base64,PHN2ZyAvPg==")).toBeNull();
});
});

View File

@@ -1,5 +1,6 @@
import { generateMessageId } from "@/types/stream";
import { isAbsolutePath } from "@/utils/path";
import { isRasterImageMimeType } from "./file-types";
export function generateAttachmentId(): string {
return `att_${generateMessageId()}`;
@@ -52,7 +53,7 @@ export function parseImageDataUrl(
try {
const parsed = parseDataUrl(uri);
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
if (!isRasterImageMimeType(parsed.mimeType)) {
return null;
}
const fingerprint = `${parsed.mimeType}\0${parsed.base64.length}\0${parsed.base64.slice(0, 64)}\0${parsed.base64.slice(-64)}`;

View File

@@ -453,6 +453,7 @@ export interface AdaptiveModalSheetProps {
desktopMaxWidth?: number;
/** When provided, wraps the card content in a FileDropZone. */
onFilesDropped?: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: import("@/hooks/use-file-drop-zone").DroppedItem[]) => void;
scrollable?: boolean;
presentation?: "push" | "replace";
}
@@ -467,6 +468,7 @@ export function AdaptiveModalSheet({
testID,
desktopMaxWidth,
onFilesDropped,
onGenericFilesDropped,
scrollable = true,
presentation,
}: AdaptiveModalSheetProps) {
@@ -602,7 +604,12 @@ export function AdaptiveModalSheet({
/>
<View style={desktopCardStyle}>
{onFilesDropped ? (
<FileDropZone onFilesDropped={onFilesDropped}>{cardInner}</FileDropZone>
<FileDropZone
onFilesDropped={onFilesDropped}
onGenericFilesDropped={onGenericFilesDropped}
>
{cardInner}
</FileDropZone>
) : (
cardInner
)}

View File

@@ -319,8 +319,8 @@ export function AddHostModal({ visible, onClose, onCancel, onSaved }: AddHostMod
}, []);
const connectIcon = useMemo(
() => <Link2 size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
() => <Link2 size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const hostFieldStyle = useMemo(() => [styles.field, styles.hostField], []);
const portFieldStyle = useMemo(() => [styles.field, styles.portField], []);

View File

@@ -97,6 +97,7 @@ export function ExplorerSidebar({
windowWidth,
animateToOpen,
animateToClose,
settledGeneration,
isGesturing,
gestureAnimatingRef,
closeGestureRef,
@@ -259,22 +260,37 @@ export function ExplorerSidebar({
[isMobile, explorerWidth, resizeWidth, setExplorerWidth, viewportWidth],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
// settledGeneration as deps: after each settle the updater is rebuilt so the
// shared values are re-applied to the view, protecting against a heavy Fabric
// commit reverting the transform to stale React-committed props (#9635).
const sidebarAnimatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: translateX.value }],
}),
[settledGeneration],
);
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
// pointerEvents comes from React state, not the worklet: the Fabric revert
// protection above does not cover pointerEvents, so a worklet-driven value
// can wedge an invisible tap-eating backdrop after a heavy commit.
const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value,
}));
const backdropCombinedStyle = useMemo(
() => [explorerStaticStyles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
() => [
explorerStaticStyles.backdrop,
backdropAnimatedStyle,
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo(
() => [

View File

@@ -4,23 +4,30 @@ import Animated, { useAnimatedStyle, withTiming, useSharedValue } from "react-na
import { useEffect, useMemo } from "react";
import { Upload } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useFileDropZone } from "@/hooks/use-file-drop-zone";
import { useFileDropZone, type DroppedItem } from "@/hooks/use-file-drop-zone";
import type { ImageAttachment } from "@/composer/types";
import { isWeb } from "@/constants/platform";
interface FileDropZoneProps {
children: React.ReactNode;
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
}
const IS_WEB = isWeb;
export function FileDropZone({ children, onFilesDropped, disabled = false }: FileDropZoneProps) {
export function FileDropZone({
children,
onFilesDropped,
onGenericFilesDropped,
disabled = false,
}: FileDropZoneProps) {
const { t } = useTranslation();
const { theme } = useUnistyles();
const { isDragging, containerRef } = useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled,
});
@@ -60,7 +67,7 @@ export function FileDropZone({ children, onFilesDropped, disabled = false }: Fil
{/* Content */}
<View style={styles.overlayContent}>
<Upload size={32} color={theme.colors.primary} />
<Text style={styles.overlayText}>{t("composer.attachments.dropImagesHere")}</Text>
<Text style={styles.overlayText}>{t("composer.attachments.dropFilesHere")}</Text>
</View>
</Animated.View>
</View>

View File

@@ -41,7 +41,10 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
import {
useSidebarAnimation,
useSidebarSettledGeneration,
} from "@/contexts/sidebar-animation-context";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { useSidebarShortcutModel } from "@/hooks/use-sidebar-shortcut-model";
@@ -620,6 +623,7 @@ function MobileSidebar({
gestureAnimatingRef,
closeGestureRef,
} = useSidebarAnimation();
const settledGeneration = useSidebarSettledGeneration();
const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0);
@@ -748,14 +752,25 @@ function MobileSidebar({
[activeHostStatusColor],
);
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
// settledGeneration as deps: after each settle the updater is rebuilt so the
// shared values are re-applied to the view, protecting against a heavy Fabric
// commit reverting the transform to stale React-committed props (#9635).
const sidebarAnimatedStyle = useAnimatedStyle(
() => ({
transform: [{ translateX: translateX.value }],
}),
[settledGeneration],
);
const backdropAnimatedStyle = useAnimatedStyle(() => ({
opacity: backdropOpacity.value,
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
}));
// pointerEvents comes from React state, not the worklet: the Fabric revert
// protection above does not cover pointerEvents, so a worklet-driven value
// can wedge an invisible tap-eating backdrop after a heavy commit.
const backdropAnimatedStyle = useAnimatedStyle(
() => ({
opacity: backdropOpacity.value,
}),
[settledGeneration],
);
let overlayPointerEvents: "auto" | "none" | "box-none";
if (!isWeb) overlayPointerEvents = "box-none";
@@ -763,8 +778,12 @@ function MobileSidebar({
else overlayPointerEvents = "none";
const backdropStyle = useMemo(
() => [staticStyles.backdrop, backdropAnimatedStyle],
[backdropAnimatedStyle],
() => [
staticStyles.backdrop,
backdropAnimatedStyle,
{ pointerEvents: isOpen ? ("auto" as const) : ("none" as const) },
],
[backdropAnimatedStyle, isOpen],
);
const mobileSidebarStyle = useMemo(
() => [

View File

@@ -443,6 +443,8 @@ function getUserMessageAttachmentLabel(
return `Issue #${attachment.number}`;
case "text":
return attachment.title ?? t("message.attachments.textAttachment");
case "uploaded_file":
return attachment.fileName;
default:
return "";
}

View File

@@ -77,8 +77,8 @@ export function PairLinkModal({ visible, onClose, onCancel, onSaved }: PairLinkM
}, []);
const pairIcon = useMemo(
() => <Link size={16} color={theme.colors.palette.white} />,
[theme.colors.palette.white],
() => <Link size={16} color={theme.colors.accentForeground} />,
[theme.colors.accentForeground],
);
const handleClose = useCallback(() => {

View File

@@ -8,27 +8,57 @@ import {
View,
type PressableStateCallbackType,
} from "react-native";
import { Folder } from "lucide-react-native";
import { Folder, FolderPlus } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { shortenPath } from "@/utils/shorten-path";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useOpenProject } from "@/hooks/use-open-project";
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
import type { OpenProjectFailure, OpenProjectResult } from "@/hooks/open-project";
import { isNative } from "@/constants/platform";
import { useActiveServerId } from "@/hooks/use-active-server-id";
import {
buildProjectPickerOptions,
isOpenableProjectPath,
type ProjectPickerOption,
} from "./project-picker-options";
interface PathRowProps {
path: string;
option: ProjectPickerOption;
active: boolean;
openPathLabel: string;
onSelect: (path: string) => void;
}
function PathRow({ path, active, onSelect }: PathRowProps) {
type ProjectPickerErrorCode = NonNullable<OpenProjectFailure["errorCode"]>;
function getProjectPickerErrorMessage(
result: OpenProjectResult | undefined,
thrownError: Error | null,
translateErrorCode: (errorCode: ProjectPickerErrorCode) => string,
translateOpenFailed: () => string,
): string | null {
if (result?.ok === false) {
if (result.errorCode) {
return translateErrorCode(result.errorCode);
}
return result.error;
}
if (thrownError) {
return thrownError.message || translateOpenFailed();
}
return null;
}
function PathRow({ option, active, openPathLabel, onSelect }: PathRowProps) {
const { theme } = useUnistyles();
const Icon = option.kind === "path" ? FolderPlus : Folder;
const path = option.path;
const displayPath = shortenPath(path);
const label = option.kind === "path" ? `${openPathLabel}: ${displayPath}` : displayPath;
const handlePress = useCallback(() => {
onSelect(path);
}, [onSelect, path]);
@@ -49,10 +79,10 @@ function PathRow({ path, active, onSelect }: PathRowProps) {
<Pressable style={pressableStyle} onPress={handlePress}>
<View style={styles.rowContent}>
<View style={styles.iconSlot}>
<Folder size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
<Icon size={16} strokeWidth={2.2} color={theme.colors.foregroundMuted} />
</View>
<Text style={rowTextStyle} numberOfLines={1}>
{shortenPath(path)}
{label}
</Text>
</View>
</Pressable>
@@ -74,9 +104,25 @@ export function ProjectPickerModal() {
const inputRef = useRef<TextInput>(null);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const openProject = useOpenProject(serverId);
const openProjectMutation = useMutation({
mutationFn: (path: string) => openProject(path),
onSuccess: (result) => {
if (result.ok) {
setOpen(false);
}
},
});
const { mutate: submitPath, reset: resetSubmit, isPending: isSubmitting } = openProjectMutation;
const submitResult = openProjectMutation.data;
const errorMessage = getProjectPickerErrorMessage(
submitResult,
openProjectMutation.error,
(errorCode) => t(`projectPicker.errors.${errorCode}`),
() => t("projectPicker.errors.open_failed"),
);
const directorySuggestionsQuery = useQuery({
queryKey: ["project-picker-directory-suggestions", serverId, query],
queryFn: async () => {
@@ -97,16 +143,11 @@ export function ProjectPickerModal() {
});
const options = useMemo(() => {
const suggestedPaths = buildWorkingDirectorySuggestions({
return buildProjectPickerOptions({
recommendedPaths,
serverPaths: directorySuggestionsQuery.data ?? [],
query,
});
const trimmedQuery = query.trim();
if (!trimmedQuery || suggestedPaths.includes(trimmedQuery)) {
return suggestedPaths;
}
return [trimmedQuery, ...suggestedPaths];
}, [query, directorySuggestionsQuery.data, recommendedPaths]);
const handleClose = useCallback(() => {
@@ -114,43 +155,41 @@ export function ProjectPickerModal() {
}, [setOpen]);
const handleSelectPath = useCallback(
async (path: string) => {
(path: string) => {
const trimmed = path.trim();
if (!trimmed || !client || !serverId) return;
setIsSubmitting(true);
try {
const didOpenProject = await openProject(trimmed);
if (didOpenProject) {
setOpen(false);
}
} finally {
setIsSubmitting(false);
}
submitPath(trimmed);
},
[client, openProject, serverId, setOpen],
[client, serverId, submitPath],
);
const handleSubmitCustom = useCallback(() => {
const trimmed = query.trim();
if (!trimmed) return;
void handleSelectPath(trimmed);
if (!isOpenableProjectPath(trimmed)) return;
handleSelectPath(trimmed);
}, [handleSelectPath, query]);
const handleChangeQuery = useCallback((text: string) => {
setQuery(text);
setActiveIndex(0);
}, []);
const handleChangeQuery = useCallback(
(text: string) => {
setQuery(text);
setActiveIndex(0);
resetSubmit();
},
[resetSubmit],
);
// Reset state when opening/closing
useEffect(() => {
if (open) {
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
resetSubmit();
if (!open) {
return;
}
}, [open]);
setQuery("");
setActiveIndex(0);
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, [open, resetSubmit]);
// Clamp active index
useEffect(() => {
@@ -170,14 +209,14 @@ export function ProjectPickerModal() {
if (key === "Escape") {
event.preventDefault();
setOpen(false);
handleClose();
return;
}
if (key === "Enter") {
event.preventDefault();
if (options.length > 0 && activeIndex < options.length) {
void handleSelectPath(options[activeIndex]);
handleSelectPath(options[activeIndex].path);
} else if (query.trim()) {
handleSubmitCustom();
}
@@ -199,7 +238,7 @@ export function ProjectPickerModal() {
window.addEventListener("keydown", handler, true);
return () => window.removeEventListener("keydown", handler, true);
}, [activeIndex, handleSelectPath, handleSubmitCustom, open, options, query, setOpen]);
}, [activeIndex, handleClose, handleSelectPath, handleSubmitCustom, open, options, query]);
const panelStyle = useMemo(
() => [
@@ -219,6 +258,10 @@ export function ProjectPickerModal() {
() => [styles.input, { color: theme.colors.foreground }],
[theme.colors.foreground],
);
const errorTextStyle = useMemo(
() => [styles.errorText, { color: theme.colors.destructive }],
[theme.colors.destructive],
);
const emptyTextStyle = useMemo(
() => [styles.emptyText, { color: theme.colors.foregroundMuted }],
[theme.colors.foregroundMuted],
@@ -247,6 +290,7 @@ export function ProjectPickerModal() {
returnKeyType="go"
onSubmitEditing={handleSubmitCustom}
/>
{errorMessage ? <Text style={errorTextStyle}>{errorMessage}</Text> : null}
</View>
<ScrollView
@@ -256,16 +300,17 @@ export function ProjectPickerModal() {
showsVerticalScrollIndicator={false}
>
{isSubmitting ? <Text style={emptyTextStyle}>{t("projectPicker.opening")}</Text> : null}
{!isSubmitting && options.length === 0 && !query.trim() ? (
{!isSubmitting && options.length === 0 ? (
<Text style={emptyTextStyle}>{t("projectPicker.empty")}</Text>
) : null}
{!isSubmitting && !(options.length === 0 && !query.trim()) ? (
{!isSubmitting && options.length > 0 ? (
<>
{options.map((path, index) => (
{options.map((option, index) => (
<PathRow
key={path}
path={path}
key={`${option.kind}:${option.path}`}
option={option}
active={index === activeIndex}
openPathLabel={t("projectPicker.openPath")}
onSelect={handleSelectPath}
/>
))}
@@ -308,6 +353,11 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
outlineStyle: "none",
} as object,
errorText: {
marginTop: theme.spacing[2],
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
results: {
flexGrow: 0,
},

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { buildProjectPickerOptions, isOpenableProjectPath } from "./project-picker-options";
describe("isOpenableProjectPath", () => {
it("accepts POSIX, tilde, Windows drive-letter, and UNC paths", () => {
expect(isOpenableProjectPath("/repo")).toBe(true);
expect(isOpenableProjectPath("~/src")).toBe(true);
expect(isOpenableProjectPath("C:\\Users\\mo")).toBe(true);
expect(isOpenableProjectPath("c:/users/mo")).toBe(true);
expect(isOpenableProjectPath("\\\\server\\share")).toBe(true);
});
it("rejects relative input", () => {
expect(isOpenableProjectPath("repo")).toBe(false);
expect(isOpenableProjectPath("repo/sub")).toBe(false);
expect(isOpenableProjectPath("c:repo")).toBe(false);
expect(isOpenableProjectPath("")).toBe(false);
});
});
describe("buildProjectPickerOptions", () => {
it("does not create an open-path row for word queries", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: ["/repo/app"],
query: "repo",
});
expect(options).toEqual([
{ kind: "suggestion", path: "/repo/api" },
{ kind: "suggestion", path: "/repo/app" },
]);
});
it("puts an absolute path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: [],
query: "/repo",
});
expect(options).toEqual([
{ kind: "path", path: "/repo" },
{ kind: "suggestion", path: "/repo/api" },
]);
});
it("puts a home-relative path row first", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/Users/mo/src/api"],
serverPaths: [],
query: "~/src",
});
expect(options).toEqual([
{ kind: "path", path: "~/src" },
{ kind: "suggestion", path: "/Users/mo/src/api" },
]);
});
it("creates an open-path row for Windows paths", () => {
const options = buildProjectPickerOptions({
recommendedPaths: [],
serverPaths: [],
query: "C:\\Users\\mo\\src",
});
expect(options).toEqual([{ kind: "path", path: "C:\\Users\\mo\\src" }]);
});
it("does not duplicate an existing suggestion", () => {
const options = buildProjectPickerOptions({
recommendedPaths: ["/repo/api"],
serverPaths: ["/repo/app"],
query: "/repo/app",
});
expect(options).toEqual([{ kind: "suggestion", path: "/repo/app" }]);
});
});

View File

@@ -0,0 +1,48 @@
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
export interface BuildProjectPickerOptionsInput {
recommendedPaths: string[];
serverPaths: string[];
query: string;
}
export interface ProjectPickerPathOption {
kind: "path";
path: string;
}
export interface ProjectPickerSuggestionOption {
kind: "suggestion";
path: string;
}
export type ProjectPickerOption = ProjectPickerPathOption | ProjectPickerSuggestionOption;
// Matches the daemon's filesystem semantics, not the client's: POSIX absolute,
// tilde, Windows drive letter (C:\ or C:/), or UNC (\\server\share).
export function isOpenableProjectPath(query: string): boolean {
const trimmedQuery = query.trim();
return (
trimmedQuery.startsWith("/") ||
trimmedQuery.startsWith("~") ||
trimmedQuery.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(trimmedQuery)
);
}
export function buildProjectPickerOptions(
input: BuildProjectPickerOptionsInput,
): ProjectPickerOption[] {
const suggestedPaths = buildWorkingDirectorySuggestions(input);
const suggestions = suggestedPaths.map<ProjectPickerSuggestionOption>((path) => ({
kind: "suggestion",
path,
}));
const trimmedQuery = input.query.trim();
if (!isOpenableProjectPath(trimmedQuery) || suggestedPaths.includes(trimmedQuery)) {
return suggestions;
}
return [{ kind: "path", path: trimmedQuery }, ...suggestions];
}

View File

@@ -1,5 +1,6 @@
import {
Fragment,
createContext,
memo,
useCallback,
useEffect,
@@ -73,6 +74,10 @@ import { RenderProfile } from "@/utils/render-profiler";
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { isNative } from "@/constants/platform";
// true = this tab slot is the active (visible) tab; false = mounted but hidden.
// Defaults to true so consumers outside a slot (e.g. web preview) are unaffected.
export const MountedTabActiveContext = createContext<boolean>(true);
interface SplitContainerProps {
layout: WorkspaceLayout;
workspaceKey: string;
@@ -219,14 +224,16 @@ const MountedTabSlot = memo(function MountedTabSlot({
return (
<RenderProfile id={`DesktopMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
<MountedTabActiveContext value={isVisible}>
<View style={wrapperStyle}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
onFocusPane={handleFocusPane}
/>
</View>
</MountedTabActiveContext>
</RenderProfile>
);
});

View File

@@ -94,10 +94,10 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
},
textDefault: {
color: theme.colors.palette.white,
color: theme.colors.accentForeground,
},
textDestructive: {
color: theme.colors.palette.white,
color: theme.colors.destructiveForeground,
},
textGhost: {
color: theme.colors.foregroundMuted,

View File

@@ -0,0 +1,82 @@
import { forwardRef, useMemo, type ReactNode } from "react";
import { Text, View, type TextInput } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveTextInput, type AdaptiveTextInputProps } from "@/components/adaptive-modal-sheet";
interface FormFieldProps {
label: string;
children: ReactNode;
hint?: string;
error?: string | null;
testID?: string;
}
export function FormField({ label, children, hint, error, testID }: FormFieldProps) {
const hintTestID = useMemo(() => (testID ? `${testID}-hint` : undefined), [testID]);
const errorTestID = useMemo(() => (testID ? `${testID}-error` : undefined), [testID]);
const hintOrError = useMemo(() => {
if (error) {
return (
<Text style={styles.errorText} testID={errorTestID}>
{error}
</Text>
);
}
if (hint) {
return (
<Text style={styles.hintText} testID={hintTestID}>
{hint}
</Text>
);
}
return null;
}, [error, hint, errorTestID, hintTestID]);
return (
<View style={styles.container} testID={testID}>
<Text style={styles.label}>{label}</Text>
{children}
{hintOrError}
</View>
);
}
export const FormTextInput = forwardRef<TextInput, AdaptiveTextInputProps>(function FormTextInput(
{ style, ...props },
ref,
) {
const inputStyle = useMemo(() => [formInputStyles.input, style], [style]);
return <AdaptiveTextInput ref={ref} {...props} style={inputStyle} />;
});
const styles = StyleSheet.create((theme) => ({
container: {
gap: theme.spacing[2],
},
label: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
hintText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
errorText: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.xs,
},
}));
const formInputStyles = StyleSheet.create((theme) => ({
input: {
backgroundColor: theme.colors.surface2,
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
color: theme.colors.foreground,
borderWidth: 1,
borderColor: theme.colors.border,
fontSize: theme.fontSize.base,
},
}));

View File

@@ -20,14 +20,20 @@ export function WorkspaceShortcutTargetsSubscriber({
serverId: string | null;
}) {
const { projects } = useSidebarWorkspacesList({ serverId, enabled });
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: enabled ? serverId : null,
projects,
});
const projectNamesByKey = useProjectNamesMap(enabled ? serverId : null);
// groupMode must be resolved before gating the status-mode subscriptions below.
const groupMode = useSidebarViewStore((state) =>
enabled && serverId ? state.getGroupMode(serverId) : "project",
);
// Only subscribe to agents/workspaces when the status-group view is actually active.
// In project mode (the default), these subscriptions would fire on every agent update
// (agents Map identity is replaced on every status transition) with no effect on
// the shortcut targets, causing ~15-46 wasted re-renders per agent switch.
const isStatusMode = enabled && groupMode === "status";
const statusWorkspaces = useStatusModeWorkspaceEntries({
serverId: isStatusMode ? serverId : null,
projects,
});
const projectNamesByKey = useProjectNamesMap(isStatusMode ? serverId : null);
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
(state) => state.collapsedProjectKeys,
);

View File

@@ -179,6 +179,7 @@ function createFakeSendClient(
throw options.rejection;
}
},
uploadFile: async () => ({ requestId: "test", file: null, error: null }),
};
}

View File

@@ -49,6 +49,18 @@ export interface ComposerSendClient {
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
},
) => Promise<void>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
type: "uploaded_file";
id: string;
fileName: string;
mimeType: string;
size: number;
path: string;
} | null;
error: string | null;
}>;
}
export interface ComposerCancelClient {
@@ -95,6 +107,23 @@ export async function pickAndPersistImages(input: {
);
}
export async function uploadFileAttachments(input: {
client: ComposerSendClient;
files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>;
}): Promise<Extract<ComposerAttachment, { kind: "file" }>[]> {
const result: Extract<ComposerAttachment, { kind: "file" }>[] = [];
for (const file of input.files) {
const response = await input.client.uploadFile(file);
if (response.error || !response.file) {
throw new Error(response.error ?? "Upload failed.");
}
result.push({ kind: "file", attachment: response.file });
}
return result;
}
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
attachments: T[];
index: number;
@@ -297,6 +326,9 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.setLightboxMetadata(input.attachment.metadata);
return;
}
if (input.attachment.kind === "file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
input.openWorkspaceAttachment({ attachment: input.attachment });
return;
@@ -308,12 +340,18 @@ export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttac
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
}
function isGithubAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
}
export function toggleGithubAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
@@ -335,7 +373,7 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
@@ -358,7 +396,7 @@ export function isAttachmentSelectedForGithubItem(
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
attachment.kind !== "image" &&
isGithubAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);

View File

@@ -12,7 +12,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const reviewAttachments: AgentAttachment[] = [];
const agentAttachments: AgentAttachment[] = [];
for (const attachment of attachments) {
if (attachment.kind === "image") {
@@ -20,22 +20,27 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
if (attachment.kind === "file") {
agentAttachments.push(attachment.attachment);
continue;
}
if (isWorkspaceAttachment(attachment)) {
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
if (workspaceAttachment) {
reviewAttachments.push(workspaceAttachment);
agentAttachments.push(workspaceAttachment);
}
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
if (reviewAttachment) {
reviewAttachments.push(reviewAttachment);
agentAttachments.push(reviewAttachment);
}
}
return {
images,
attachments: reviewAttachments,
attachments: agentAttachments,
};
}

View File

@@ -27,8 +27,10 @@ import {
Pencil,
AudioLines,
CircleDot,
FileText,
GitPullRequest,
Github,
Image as ImageIcon,
Paperclip,
} from "lucide-react-native";
import Animated from "react-native-reanimated";
@@ -41,6 +43,7 @@ import {
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { useSessionStore } from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { MessageInput, type MessageInputRef, type AttachmentMenuItem } from "./input/input";
import type { ImageAttachment, MessagePayload } from "./types";
import { ICON_SIZE, type Theme } from "@/styles/theme";
@@ -59,6 +62,7 @@ import {
removeComposerAttachmentAtIndex,
sendQueuedComposerMessageNow,
toggleGithubAttachmentFromPicker,
uploadFileAttachments,
type AgentStreamWriter,
type QueueWriter,
type QueuedComposerMessage,
@@ -114,6 +118,7 @@ type AttachmentListUpdater =
| ((prev: UserComposerAttachment[]) => UserComposerAttachment[]);
function noop() {}
const noopCallback = () => {};
function resolveComposerButtonIconSize(): number {
return isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
@@ -259,6 +264,7 @@ interface RenderAttachmentTrayArgs {
labels: {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
};
@@ -359,6 +365,18 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
/>
);
}
if (attachment.kind === "file") {
return (
<FileAttachmentPill
key={attachment.attachment.id}
attachment={attachment}
index={index}
disabled={disabled}
onRemove={onRemove}
removeLabel={labels.removeFile}
/>
);
}
if (composerWorkspaceAttachment.is(attachment)) {
return composerWorkspaceAttachment.renderPill({
attachment,
@@ -642,6 +660,45 @@ function GithubAttachmentPill({
);
}
interface FileAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "file" }>;
index: number;
disabled: boolean;
onRemove: (index: number) => void;
removeLabel: string;
}
function FileAttachmentPill({
attachment,
index,
disabled,
onRemove,
removeLabel,
}: FileAttachmentPillProps) {
const handleRemove = useCallback(() => {
onRemove(index);
}, [onRemove, index]);
return (
<AttachmentPill
testID="composer-file-attachment-pill"
onOpen={noopCallback}
onRemove={handleRemove}
openAccessibilityLabel={attachment.attachment.fileName}
removeAccessibilityLabel={removeLabel}
disabled={disabled}
>
<View style={styles.filePillBody}>
<View style={styles.filePillIcon}>
<ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
</View>
<Text style={styles.filePillText} numberOfLines={1}>
{attachment.attachment.fileName}
</Text>
</View>
</AttachmentPill>
);
}
interface GithubPickerOptionProps {
label: string;
testID: string;
@@ -713,6 +770,8 @@ interface ComposerProps {
autoFocus?: boolean;
/** Callback to expose the addImages function to parent components */
onAddImages?: (addImages: (images: ImageAttachment[]) => void) => void;
/** Callback to expose the addFiles function to parent components */
onAddFiles?: (addFiles: (files: UserComposerAttachment[]) => void) => void;
/** Callback to expose a focus function to parent components (desktop only). */
onFocusInput?: (focus: () => void) => void;
/** Optional draft context for listing commands before an agent exists. */
@@ -734,6 +793,8 @@ interface ComposerProps {
isCompactLayout?: boolean;
}
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
@@ -898,6 +959,7 @@ function ComposerVoiceModeButton({
);
}
// oxlint-disable-next-line complexity
export function Composer({
agentId,
serverId,
@@ -921,6 +983,7 @@ export function Composer({
clearDraft,
autoFocus = false,
onAddImages,
onAddFiles,
onFocusInput,
commandDraftConfig,
onMessageSent,
@@ -997,6 +1060,7 @@ export function Composer({
});
const [cursorIndex, setCursorIndex] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const [isCancellingAgent, setIsCancellingAgent] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
@@ -1073,6 +1137,7 @@ export function Composer({
}, [userInput.length]);
const { pickImages } = useImageAttachmentPicker();
const { pickFiles } = useFilePicker();
const agentIdRef = useRef(agentId);
const sendAgentMessageRef = useRef<
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | null
@@ -1094,6 +1159,20 @@ export function Composer({
onAddImages?.(addImages);
}, [addImages, onAddImages]);
// Expose addFiles function to parent for drag-and-drop support
const addFiles = useCallback(
(files: UserComposerAttachment[]) => {
setSelectedAttachments((prev) => [...prev, ...files]);
},
[setSelectedAttachments],
);
/* oxlint-disable react-hooks/exhaustive-deps */
useEffect(() => {
onAddFiles?.(addFiles);
}, [addFiles, onAddFiles]);
/* oxlint-enable react-hooks/exhaustive-deps */
const focusInput = useCallback(() => {
if (isNative) return;
focusWithRetries({
@@ -1289,6 +1368,36 @@ export function Composer({
addImages(newImages);
}, [addImages, pickImages]);
const handlePickFile = useCallback(async () => {
if (!client) {
toastErrorRef.current(t("composer.errors.daemonClientDisconnected"));
return;
}
try {
const files = await pickFiles();
if (!files || files.length === 0) return;
const oversized = files.find((f) => f.bytes.byteLength > MAX_FILE_SIZE_BYTES);
if (oversized) {
toastErrorRef.current(
t("composer.errors.fileTooLarge", { size: "50MB", fileName: oversized.fileName }),
);
return;
}
setIsUploadingFile(true);
const uploaded = await uploadFileAttachments({ client, files });
addFiles(uploaded);
} catch (error) {
console.error("[Composer] Failed to upload file:", error);
toastErrorRef.current(
error instanceof Error ? error.message : t("composer.errors.uploadFailed"),
);
} finally {
setIsUploadingFile(false);
}
}, [client, pickFiles, addFiles, t]);
const handleRemoveAttachment = useCallback(
(index: number) => {
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
@@ -1583,7 +1692,7 @@ export function Composer({
{
id: "image",
label: t("composer.attachments.addImage"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
icon: <ThemedImageIcon size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickImage();
},
@@ -1596,8 +1705,20 @@ export function Composer({
setIsGithubPickerOpen(true);
},
},
...(!isNative
? [
{
id: "file",
label: t("composer.attachments.addFile"),
icon: <ThemedPaperclip size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
onSelect: () => {
void handlePickFile();
},
},
]
: []),
],
[handlePickImage, t],
[handlePickImage, handlePickFile, t],
);
const handleToggleGithubItem = useCallback(
@@ -1698,6 +1819,7 @@ export function Composer({
labels: {
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
@@ -1721,7 +1843,7 @@ export function Composer({
const messageInputContainerRef = useRef<View>(null);
const isSubmitBusy = isProcessing || isSubmitLoading;
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
const messageInputAutoFocus = autoFocus && isDesktopWebBreakpoint;
const submitLoadingPressHandler = isAgentRunning ? handleCancelAgent : undefined;
const sendErrorNode = useMemo(
@@ -1961,6 +2083,20 @@ const styles = StyleSheet.create((theme: Theme) => ({
alignItems: "center",
justifyContent: "center",
},
filePillBody: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
filePillIcon: {
justifyContent: "center",
alignItems: "center",
},
filePillText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
flexShrink: 1,
},
githubPillText: {
minWidth: 0,
flexShrink: 1,
@@ -2030,6 +2166,8 @@ const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });

View File

@@ -5,10 +5,17 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import {
runOnJS,
useSharedValue,
withTiming,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
@@ -26,6 +33,7 @@ interface ExplorerSidebarAnimationContextValue {
windowWidth: number;
animateToOpen: () => void;
animateToClose: () => void;
settledGeneration: number;
isGesturing: SharedValue<boolean>;
gestureAnimatingRef: React.MutableRefObject<boolean>;
openGestureRef: React.MutableRefObject<GestureType | undefined>;
@@ -54,6 +62,13 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// Same Fabric stale-props revert protection as in sidebar-animation-context:
// bump after every settle so consumers re-commit the settled shared values.
const [settledGeneration, setSettledGeneration] = useState(0);
const bumpSettledGeneration = useCallback(() => {
setSettledGeneration((generation) => generation + 1);
}, []);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
@@ -108,6 +123,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout) {
settleMobilePanel("file-explorer");
}
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -131,6 +147,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -145,6 +162,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
bumpSettledGeneration();
}, [
isOpen,
mobileView,
@@ -155,6 +173,7 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
isCompactLayout,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToOpen = useCallback(() => {
@@ -169,13 +188,20 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => {
if (!finished) return;
settleMobilePanel("file-explorer");
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
}, [
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => {
"worklet";
@@ -189,13 +215,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
}, [
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<ExplorerSidebarAnimationContextValue>(
() => ({
@@ -204,12 +238,21 @@ export function ExplorerSidebarAnimationProvider({ children }: { children: React
windowWidth,
animateToOpen,
animateToClose,
settledGeneration,
isGesturing,
gestureAnimatingRef,
openGestureRef,
closeGestureRef,
}),
[translateX, backdropOpacity, windowWidth, animateToOpen, animateToClose, isGesturing],
[
translateX,
backdropOpacity,
windowWidth,
animateToOpen,
animateToClose,
settledGeneration,
isGesturing,
],
);
return (

View File

@@ -69,6 +69,7 @@ import {
import { isNative } from "@/constants/platform";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -1300,6 +1301,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload));
});
const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => {
if (message.type !== "checkout_status_update") return;
applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message });
});
const unsubWorkspaceSetupProgress = client.on("workspace_setup_progress", (message) => {
if (message.type !== "workspace_setup_progress") return;
applyWorkspaceSetupProgress(message.payload);
@@ -1648,6 +1654,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentTimeline();
unsubWorkspaceUpdate();
unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate();
unsubWorkspaceSetupProgress();
unsubWorkspaceSetupStatusResponse();
unsubStatus();

View File

@@ -5,10 +5,17 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Keyboard, useWindowDimensions } from "react-native";
import { useSharedValue, withTiming, Easing, type SharedValue } from "react-native-reanimated";
import {
runOnJS,
useSharedValue,
withTiming,
Easing,
type SharedValue,
} from "react-native-reanimated";
import { type GestureType } from "react-native-gesture-handler";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
@@ -53,6 +60,10 @@ interface SidebarAnimationContextValue {
const SidebarAnimationContext = createContext<SidebarAnimationContextValue | null>(null);
// Separate context so that settle-driven re-renders only reach MobileSidebar,
// not every other useSidebarAnimation() consumer.
const SidebarSettledGenerationContext = createContext<number>(0);
function getMobileVisualPanel(mobileView: "agent" | "agent-list" | "file-explorer"): number {
if (mobileView === "agent-list") {
return MOBILE_VISUAL_PANEL_AGENT_LIST;
@@ -93,6 +104,17 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
const openGestureRef = useRef<GestureType | undefined>(undefined);
const closeGestureRef = useRef<GestureType | undefined>(undefined);
// After an open/close settles, a heavy Fabric commit can re-apply React's
// stale committed props onto the native view, reverting the UI-thread
// transform (reanimated#9635 — sidebar reappears "ghost-open"). Bumping this
// counter after every settle re-renders the consumers so the animated styles
// refresh React's committed props from the settled shared values. It must
// never write a shared value — it only triggers a React re-commit.
const [settledGeneration, setSettledGeneration] = useState(0);
const bumpSettledGeneration = useCallback(() => {
setSettledGeneration((generation) => generation + 1);
}, []);
// Track previous isOpen to detect changes
const prevIsOpen = useRef(isOpen);
const prevMobileView = useRef(mobileView);
@@ -219,6 +241,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout) {
settleMobilePanel("agent-list");
}
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -242,6 +265,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && mobileView === "agent") {
settleMobilePanel("agent");
}
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(targets.backdropOpacity, {
@@ -256,6 +280,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
if (isCompactLayout && ownsMobileViewChange) {
settleMobilePanel(mobileView);
}
bumpSettledGeneration();
}, [
isOpen,
mobileView,
@@ -268,6 +293,7 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
mobilePanelState,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToOpen = useCallback(() => {
@@ -282,13 +308,20 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => {
if (!finished) return;
settleMobilePanel("agent-list");
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(1, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, startMobilePanelTransition, settleMobilePanel]);
}, [
translateX,
backdropOpacity,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const animateToClose = useCallback(() => {
"worklet";
@@ -302,13 +335,21 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
(finished) => {
if (!finished) return;
settleMobilePanel("agent");
runOnJS(bumpSettledGeneration)();
},
);
backdropOpacity.value = withTiming(0, {
duration: ANIMATION_DURATION,
easing: ANIMATION_EASING,
});
}, [translateX, backdropOpacity, windowWidth, startMobilePanelTransition, settleMobilePanel]);
}, [
translateX,
backdropOpacity,
windowWidth,
startMobilePanelTransition,
settleMobilePanel,
bumpSettledGeneration,
]);
const value = useMemo<SidebarAnimationContextValue>(
() => ({
@@ -344,7 +385,11 @@ export function SidebarAnimationProvider({ children }: { children: ReactNode })
);
return (
<SidebarAnimationContext.Provider value={value}>{children}</SidebarAnimationContext.Provider>
<SidebarAnimationContext.Provider value={value}>
<SidebarSettledGenerationContext.Provider value={settledGeneration}>
{children}
</SidebarSettledGenerationContext.Provider>
</SidebarAnimationContext.Provider>
);
}
@@ -355,3 +400,7 @@ export function useSidebarAnimation() {
}
return context;
}
export function useSidebarSettledGeneration() {
return useContext(SidebarSettledGenerationContext);
}

View File

@@ -2,6 +2,7 @@ import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useSyncExternalStore,
type ReactNode,
@@ -57,22 +58,27 @@ export function useVoice() {
export function useVoiceOptional(): VoiceContextValue | null {
const runtime = useContext(VoiceRuntimeContext);
const snapshot = useSyncExternalStore(
runtime ? runtime.subscribe.bind(runtime) : noopSubscribe,
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
runtime ? runtime.getSnapshot.bind(runtime) : getEmptySnapshot,
runtime ? runtime.subscribe : noopSubscribe,
runtime ? runtime.getSnapshot : getEmptySnapshot,
runtime ? runtime.getSnapshot : getEmptySnapshot,
);
if (!runtime) {
return null;
}
return {
...snapshot,
startVoice: runtime.startVoice.bind(runtime),
stopVoice: runtime.stopVoice.bind(runtime),
isVoiceModeForAgent: runtime.isVoiceModeForAgent.bind(runtime),
toggleMute: runtime.toggleMute.bind(runtime),
};
// Methods on the runtime object literal close over factory-local state; they
// don't use `this`, so no binding is needed. Memoising on [snapshot, runtime]
// keeps the returned object reference stable across re-renders that don't
// change either, preventing downstream memo/useMemo misses.
return useMemo(() => {
if (!runtime) {
return null;
}
return {
...snapshot,
startVoice: runtime.startVoice,
stopVoice: runtime.stopVoice,
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
toggleMute: runtime.toggleMute,
};
}, [snapshot, runtime]);
}
export function useVoiceTelemetry() {

View File

@@ -68,10 +68,10 @@ const CATALOG_DATA = [
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.106.0",
version: "2.106.1",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.106.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.106.1", "--acp"],
},
{
id: "codewhale",
@@ -140,20 +140,20 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.1.8",
version: "0.2.1",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.1.8", "acp"],
command: ["npx", "-y", "dimcode@0.2.1", "acp"],
},
{
id: "dirac",
title: "Dirac",
description:
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
version: "0.3.44",
version: "0.4.0",
iconId: "dirac",
installLink: "https://dirac.run",
command: ["npx", "-y", "dirac-cli@0.3.44", "--acp"],
command: ["npx", "-y", "dirac-cli@0.4.0", "--acp"],
},
{
id: "factory-droid",
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
id: "fast-agent",
title: "fast-agent",
description: "Code and build agents with comprehensive multi-provider support",
version: "0.7.17",
version: "0.7.18",
iconId: "fast-agent",
installLink: "https://fast-agent.ai/acp/",
command: ["uvx", "--from", "fast-agent-acp==0.7.17", "fast-agent-acp", "-x"],
command: ["uvx", "--from", "fast-agent-acp==0.7.18", "fast-agent-acp", "-x"],
},
{
id: "gemini",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.18",
version: "1.0.19",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.18", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.19", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.17.1",
version: "0.18.0",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.17.1", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.18.0", "--acp", "--experimental-skills"],
},
{
id: "sigit",

View File

@@ -1,11 +1,17 @@
// @vitest-environment jsdom
// The review draft store persists through AsyncStorage's web shim, which needs window.
import "@/test/window-local-storage";
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
applyCheckoutStatusUpdate,
applyCheckoutStatusUpdateFromEvent,
type CheckoutPrStatusPayload,
type CheckoutStatusPayload,
peekOrFetchCheckoutStatus,
fetchCheckoutStatus,
} from "./checkout-status-cache";
const serverId = "server-1";
@@ -31,94 +37,179 @@ function checkoutStatus(overrides: Partial<CheckoutStatusPayload> = {}): Checkou
} as CheckoutStatusPayload;
}
function checkoutStatusUpdate(payload: CheckoutStatusPayload): CheckoutStatusUpdate {
return { type: "checkout_status_update", payload };
function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrStatusPayload {
return {
cwd,
status: {
url: "https://github.com/getpaseo/paseo/pull/42",
title: "My PR",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "MERGEABLE",
checks: [],
checksStatus: "success",
reviewDecision: null,
},
githubFeaturesEnabled: true,
error: null,
requestId: "pr-status-1",
...overrides,
};
}
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: CheckoutPrStatusPayload,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
payload: extraPrStatus ? { ...payload, prStatus: extraPrStatus } : payload,
};
}
function setDiffModeOverride(isDirtyAtSelection: boolean): void {
useReviewDraftStore.getState().setDiffModeOverride({
scopeKey: "review:scope",
override: { serverId, cwd, mode: "base", isDirtyAtSelection },
});
}
function createQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
describe("peekOrFetchCheckoutStatus", () => {
it("fetches from the client and writes the result to the cache on a cold read", async () => {
const queryClient = createQueryClient();
const fetched = checkoutStatus({ requestId: "cold-read" });
beforeEach(() => {
resetReviewDraftStore();
});
describe("fetchCheckoutStatus", () => {
it("fetches from the client and returns the payload", async () => {
const fetched = checkoutStatus({ requestId: "fetch-1" });
const client = { getCheckoutStatus: vi.fn(async () => fetched) };
const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
const result = await fetchCheckoutStatus({ client, serverId, cwd });
expect(result).toEqual(fetched);
expect(client.getCheckoutStatus).toHaveBeenCalledExactlyOnceWith(cwd);
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(fetched);
});
it("returns the cached snapshot without calling the client when the cache already has data", async () => {
const queryClient = createQueryClient();
const cached = checkoutStatus({ requestId: "cached" });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), cached);
const client = {
getCheckoutStatus: vi.fn(async () => checkoutStatus({ requestId: "uncached" })),
};
it("expires a manual diff-mode override when the fetched dirty state flipped", async () => {
setDiffModeOverride(true);
const client = { getCheckoutStatus: vi.fn(async () => checkoutStatus({ isDirty: false })) };
const result = await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
await fetchCheckoutStatus({ client, serverId, cwd });
expect(result).toEqual(cached);
expect(client.getCheckoutStatus).not.toHaveBeenCalled();
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
});
});
describe("applyCheckoutStatusUpdate", () => {
it("writes the pushed payload into the cache key for the matching cwd", () => {
describe("applyCheckoutStatusUpdateFromEvent", () => {
it("writes the checkout status to the cache using the cwd from the payload", () => {
const queryClient = createQueryClient();
const pushed = checkoutStatus({
requestId: "server-push",
currentBranch: "pushed-branch",
isDirty: true,
aheadBehind: { ahead: 2, behind: 1 },
aheadOfOrigin: 2,
behindOfOrigin: 1,
});
const pushed = checkoutStatus({ requestId: "push-1", isDirty: true });
applyCheckoutStatusUpdate({
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
cwd,
message: checkoutStatusUpdate(pushed),
});
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toEqual(pushed);
});
it("ignores updates whose payload cwd does not match the subscribed cwd", () => {
it("writes the PR status cache when prStatus is present, and skips it otherwise", () => {
const queryClient = createQueryClient();
const otherCwd = "/other-repo";
const otherCached = checkoutStatus({
cwd: otherCwd,
repoRoot: otherCwd,
requestId: "other-cached",
currentBranch: "other-main",
});
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), otherCached);
const pushedPr = prStatus({ requestId: "pr-1" });
applyCheckoutStatusUpdate({
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), pushedPr),
});
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, cwd))).toEqual(pushedPr);
const otherCwd = "/repo2";
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ cwd: otherCwd, repoRoot: otherCwd })),
});
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeUndefined();
});
it("keeps a manual diff-mode override while the pushed dirty state still matches", () => {
const queryClient = createQueryClient();
setDiffModeOverride(true);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus({ isDirty: true })),
});
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
});
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutPrStatusQueryKey(serverId, cwd),
prStatus({ requestId: "pr-v1" }),
);
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
queryClient.setQueryData(timelineKey, { items: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
cwd,
message: checkoutStatusUpdate(
checkoutStatus({
cwd: otherCwd,
repoRoot: otherCwd,
requestId: "server-push",
currentBranch: "pushed-branch",
checkoutStatus(),
prStatus({
requestId: "pr-v3",
status: { ...prStatus().status!, state: "closed" },
}),
),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
});
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, cwd))).toBeUndefined();
expect(queryClient.getQueryData(checkoutStatusQueryKey(serverId, otherCwd))).toEqual(
otherCached,
);
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
const queryClient = createQueryClient();
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(otherTimelineKey, { items: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), prStatus()),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
});
});

View File

@@ -1,48 +1,86 @@
import type { QueryClient } from "@tanstack/react-query";
import type { CheckoutStatusResponse, CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import equal from "fast-deep-equal/es6";
import {
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { expireStaleDiffModeOverrides } from "@/review/store";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
}
export async function peekOrFetchCheckoutStatus({
queryClient,
// Checkout status enters the app through exactly two doors: daemon pushes
// (applyCheckoutStatusUpdateFromEvent) and query fetches (fetchCheckoutStatus). Both run
// the dirty-state reactions, so they hold regardless of which screens are mounted.
export async function fetchCheckoutStatus({
client,
serverId,
cwd,
}: {
queryClient: QueryClient;
client: CheckoutStatusClient;
serverId: string;
cwd: string;
}): Promise<CheckoutStatusPayload> {
const queryKey = checkoutStatusQueryKey(serverId, cwd);
const cached = queryClient.getQueryData<CheckoutStatusPayload>(queryKey);
if (cached) {
return cached;
}
const snapshot = await client.getCheckoutStatus(cwd);
queryClient.setQueryData(queryKey, snapshot);
return snapshot;
const payload = await client.getCheckoutStatus(cwd);
expireStaleDiffModeOverrides({ serverId, cwd, isDirty: payload.isGit && payload.isDirty });
return payload;
}
export function applyCheckoutStatusUpdate({
export function applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
cwd,
message,
}: {
queryClient: QueryClient;
serverId: string;
cwd: string;
message: CheckoutStatusUpdate;
}): void {
if (message.payload.cwd !== cwd) {
const { payload } = message;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
expireStaleDiffModeOverrides({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
});
const prStatus = payload.prStatus;
if (!prStatus) {
return;
}
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), message.payload);
const previous = queryClient.getQueryData<CheckoutPrStatusPayload>(
checkoutPrStatusQueryKey(serverId, prStatus.cwd),
);
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, prStatus.cwd), prStatus);
// The PR activity timeline has no push channel; mark it stale when the pushed PR status
// meaningfully changed. Active panes refetch immediately, evicted ones on next mount.
if (hasPrStatusChanged(previous, prStatus)) {
void invalidatePrPaneTimelineForCheckout(queryClient, { serverId, cwd: prStatus.cwd });
}
}
// requestId changes on every emission and carries no PR state.
function prStatusWithoutVolatileFields(
prStatus: CheckoutPrStatusPayload,
): Omit<CheckoutPrStatusPayload, "requestId"> {
const { requestId: _requestId, ...rest } = prStatus;
return rest;
}
function hasPrStatusChanged(
previous: CheckoutPrStatusPayload | undefined,
next: CheckoutPrStatusPayload,
): boolean {
if (!previous) {
return true;
}
return !equal(prStatusWithoutVolatileFields(previous), prStatusWithoutVolatileFields(next));
}

View File

@@ -101,11 +101,10 @@ import {
import {
buildReviewDraftScopeKey,
buildReviewDraftKey,
useActiveReviewDraftMode,
useReviewAttachmentSnapshot,
useSetActiveReviewDraftMode,
useResolvedDiffMode,
useSetDiffModeOverride,
type ReviewDraftComment,
type ReviewDraftMode,
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
InlineReviewGutterCell,
@@ -1593,7 +1592,6 @@ export function GitDiffPane({
const isMobile = useIsCompactFormFactor();
const showDesktopWebScrollbar = isWeb && !isMobile;
const canUseSplitLayout = isWeb && !isMobile;
const [diffModeOverride, setDiffModeOverride] = useState<ReviewDraftMode | null>(null);
const { preferences: changesPreferences, updatePreferences: updateChangesPreferences } =
useChangesPreferences();
const wrapLines = changesPreferences.wrapLines;
@@ -1614,9 +1612,6 @@ export function GitDiffPane({
void updateChangesPreferences({ hideWhitespace: !changesPreferences.hideWhitespace });
}, [changesPreferences.hideWhitespace, updateChangesPreferences]);
// handleSelectUncommitted/handleSelectBase are defined later, after reviewDraftScopeKey
// and setActiveReviewMode are available, so they can record the active review mode.
const handleLayoutUnified = useCallback(() => {
handleLayoutChange("unified");
}, [handleLayoutChange]);
@@ -1691,8 +1686,6 @@ export function GitDiffPane({
const statusState = deriveStatusState({ status, isStatusLoading, isStatusError, statusError });
const { isGit, notGit, statusErrorMessage, baseRef, hasUncommittedChanges } = statusState;
// Auto-select diff mode based on state: uncommitted when dirty, base when clean
const autoDiffMode: ReviewDraftMode = hasUncommittedChanges ? "uncommitted" : "base";
const reviewDraftScopeKey = useMemo(
() =>
buildReviewDraftScopeKey({
@@ -1704,8 +1697,11 @@ export function GitDiffPane({
}),
[baseRef, changesPreferences.hideWhitespace, cwd, serverId, workspaceId],
);
const activeReviewMode = useActiveReviewDraftMode({ scopeKey: reviewDraftScopeKey });
const diffMode = diffModeOverride ?? activeReviewMode ?? autoDiffMode;
const diffMode = useResolvedDiffMode({
scopeKey: reviewDraftScopeKey,
hasUncommittedChanges,
});
const setDiffModeOverride = useSetDiffModeOverride();
const {
files,
@@ -1731,17 +1727,20 @@ export function GitDiffPane({
}),
[baseRef, changesPreferences.hideWhitespace, cwd, diffMode, serverId, workspaceId],
);
const setActiveReviewMode = useSetActiveReviewDraftMode();
const handleSelectUncommitted = useCallback(() => {
setDiffModeOverride("uncommitted");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "uncommitted" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode: "uncommitted", isDirtyAtSelection: hasUncommittedChanges },
});
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
const handleSelectBase = useCallback(() => {
setDiffModeOverride("base");
setActiveReviewMode({ scopeKey: reviewDraftScopeKey, mode: "base" });
}, [reviewDraftScopeKey, setActiveReviewMode]);
setDiffModeOverride({
scopeKey: reviewDraftScopeKey,
override: { serverId, cwd, mode: "base", isDirtyAtSelection: hasUncommittedChanges },
});
}, [cwd, hasUncommittedChanges, reviewDraftScopeKey, serverId, setDiffModeOverride]);
const reviewActions = useInlineReviewController({
reviewDraftKey,
@@ -1994,11 +1993,6 @@ export function GitDiffPane({
}
}, [allExpanded, files, setDiffExpandedPathsForWorkspace, workspaceStateKey]);
// Clear diff mode override when auto mode changes (e.g., after commit)
useEffect(() => {
setDiffModeOverride(null);
}, [autoDiffMode]);
const renderFlatItem = useCallback(
({ item }: { item: DiffFlatItem }) => {
if (item.type === "header") {

View File

@@ -190,6 +190,25 @@ describe("git-actions-policy", () => {
});
});
it("prioritizes push over pull request merge when local commits are unpushed", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 2,
aheadOfOrigin: 2,
hasPullRequest: true,
pullRequestUrl: "https://example.com/pr/456",
pullRequestState: "open",
pullRequestMergeable: "MERGEABLE",
pullRequestGithub: githubStatus(),
shipDefault: "pr",
}),
);
expect(actions.primary).toMatchObject({ id: "push", label: "Push" });
});
it("shows update-from-base only on feature branches that are behind the base branch", () => {
const actions = buildGitActions(
createInput({
@@ -358,7 +377,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
label: "Merge",
});
});
@@ -379,7 +398,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
label: "Merge",
});
});
@@ -439,11 +458,11 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
label: "Merge",
});
});
it("promotes Create PR over push and local merge when PR is the ship default", () => {
it("promotes push over Create PR when local commits are unpushed", () => {
const actions = buildGitActions(
createInput({
hasRemote: true,
@@ -455,8 +474,8 @@ describe("git-actions-policy", () => {
);
expect(actions.primary).toMatchObject({
id: "pr",
label: "Create PR",
id: "push",
label: "Push",
});
});
@@ -493,7 +512,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Squash and merge",
label: "Merge",
});
expect(actions.secondary.some((action) => action.id === "merge-branch")).toBe(true);
});
@@ -564,7 +583,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-squash",
label: "Squash and merge",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -573,7 +592,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-merge",
label: "Create a merge commit",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -582,7 +601,7 @@ describe("git-actions-policy", () => {
},
{
id: "merge-pr-rebase",
label: "Rebase and merge",
label: "Merge",
pendingLabel: "Merging PR...",
successLabel: "PR merged",
disabled: false,
@@ -684,7 +703,7 @@ describe("git-actions-policy", () => {
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Squash and merge" });
expect(actions.primary).toMatchObject({ id: "merge-pr-squash", label: "Merge" });
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
"push",
@@ -725,7 +744,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "enable-pr-auto-merge-squash",
label: "Enable auto-merge with squash",
label: "Auto merge",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",
@@ -832,7 +851,7 @@ describe("git-actions-policy", () => {
expect(actions.primary).toMatchObject({
id: "merge-pr-merge",
label: "Create a merge commit",
label: "Merge",
});
expect(actions.secondary.map((action) => action.id)).toEqual([
"pull",

View File

@@ -326,6 +326,9 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (canPull(input)) {
return "pull";
}
if (canPush(input)) {
return "push";
}
if (canMergePr(input)) {
return getDefaultDirectPullRequestMergeActionId(input);
}
@@ -338,9 +341,6 @@ function getPrimaryActionId(input: BuildGitActionsInput): GitActionId | null {
if (input.shipDefault === "pr" && canUsePullRequestActionAsShipDefault(input)) {
return "pr";
}
if (canPush(input)) {
return "push";
}
if (!input.isOnBaseBranch && input.aheadCount > 0) {
return "merge-branch";
}

View File

@@ -244,7 +244,9 @@ export function usePrPaneData({
},
enabled: shouldFetchTimeline,
staleTime: Infinity,
refetchOnMount: false,
// Refetch on mount only after explicit invalidation (reconnect, or a pushed PR status
// change) — see useCheckoutStatusQuery for the rationale.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,

View File

@@ -5,6 +5,7 @@ import {
checkoutPrStatusQueryKey,
checkoutStatusQueryKey,
invalidateCheckoutGitQueriesForClient,
invalidateCheckoutGitQueriesForServer,
} from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
@@ -59,4 +60,47 @@ describe("checkout query keys", () => {
queryClient.clear();
});
it("invalidates fetch-based checkout queries server-wide without touching other servers", async () => {
const queryClient = new QueryClient();
const otherServerId = "server-2";
const otherCwd = "/tmp/repo-2";
queryClient.setQueryData(checkoutStatusQueryKey(serverId, cwd), { isGit: true });
queryClient.setQueryData(checkoutStatusQueryKey(serverId, otherCwd), { isGit: true });
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), { status: { number: 12 } });
queryClient.setQueryData(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }), {
items: [],
});
// Subscription-fed diff queries are deliberately not part of the server-wide sweep.
queryClient.setQueryData(checkoutDiffQueryKey(serverId, cwd, "base", "main", true), {
files: [],
});
queryClient.setQueryData(checkoutStatusQueryKey(otherServerId, cwd), { isGit: true });
await invalidateCheckoutGitQueriesForServer(queryClient, serverId);
expect(queryClient.getQueryState(checkoutStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(checkoutStatusQueryKey(serverId, otherCwd))?.isInvalidated,
).toBe(true);
expect(queryClient.getQueryState(checkoutPrStatusQueryKey(serverId, cwd))?.isInvalidated).toBe(
true,
);
expect(
queryClient.getQueryState(prPaneTimelineQueryKey({ serverId, cwd, prNumber: 12 }))
?.isInvalidated,
).toBe(true);
expect(
queryClient.getQueryState(checkoutDiffQueryKey(serverId, cwd, "base", "main", true))
?.isInvalidated,
).toBe(false);
expect(
queryClient.getQueryState(checkoutStatusQueryKey(otherServerId, cwd))?.isInvalidated,
).toBe(false);
queryClient.clear();
});
});

View File

@@ -6,6 +6,11 @@ interface CheckoutQueryIdentity {
cwd: string;
}
interface CheckoutQueryScope {
serverId: string;
cwd?: string;
}
type CheckoutQueryKey = readonly unknown[];
export function checkoutStatusQueryKey(serverId: string, cwd: string) {
@@ -46,17 +51,41 @@ export async function invalidateCheckoutGitQueriesForClient(
]);
}
// checkoutDiff is excluded: diff queries are subscription-fed (queryFn: skipToken) and
// receive a fresh snapshot on every resubscribe, so invalidation cannot and need not
// refetch them.
export async function invalidateCheckoutGitQueriesForServer(
queryClient: QueryClient,
serverId: string,
) {
const kinds = ["checkoutStatus", "checkoutPrStatus", prPaneTimelineQueryKind];
await Promise.all(
kinds.map((kind) =>
queryClient.invalidateQueries({ predicate: checkoutQueryPredicate(kind, { serverId }) }),
),
);
}
export async function invalidatePrPaneTimelineForCheckout(
queryClient: QueryClient,
identity: CheckoutQueryIdentity,
) {
await queryClient.invalidateQueries({
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
});
}
function checkoutQueryPredicate(
queryKind: CheckoutQueryKey[0],
identity: CheckoutQueryIdentity,
scope: CheckoutQueryScope,
): (query: Query) => boolean {
return (query) => {
const key = query.queryKey;
return (
isCheckoutQueryKey(key) &&
key[0] === queryKind &&
key[1] === identity.serverId &&
key[2] === identity.cwd
key[1] === scope.serverId &&
(scope.cwd === undefined || key[2] === scope.cwd)
);
};
}

View File

@@ -1,5 +1,4 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
@@ -81,24 +80,9 @@ export function useCheckoutPrStatusQuery({
enabled = true,
}: UseCheckoutPrStatusQueryOptions) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
const prStatus = message.payload.prStatus;
if (!prStatus || prStatus.cwd !== cwd) {
return;
}
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
@@ -109,7 +93,9 @@ export function useCheckoutPrStatusQuery({
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
refetchOnMount: false,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
@@ -131,24 +117,9 @@ export function useWorkspacePrHint({
enabled = true,
}: UseCheckoutPrStatusQueryOptions): PrHint | null {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
const prStatus = message.payload.prStatus;
if (!prStatus || prStatus.cwd !== cwd) {
return;
}
queryClient.setQueryData(checkoutPrStatusQueryKey(serverId, cwd), prStatus);
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery<CheckoutPrStatusPayload, Error, PrHint | null>({
queryKey: checkoutPrStatusQueryKey(serverId, cwd),
queryFn: async () => {
@@ -159,7 +130,9 @@ export function useWorkspacePrHint({
},
enabled: !!client && isConnected && !!cwd && enabled,
staleTime: Infinity,
refetchOnMount: false,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
select: selectWorkspacePrHint,

View File

@@ -1,14 +1,8 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import {
applyCheckoutStatusUpdate,
type CheckoutStatusClient,
type CheckoutStatusPayload,
peekOrFetchCheckoutStatus,
} from "./checkout-status-cache";
import { fetchCheckoutStatus } from "./checkout-status-cache";
export type { CheckoutStatusPayload } from "./checkout-status-cache";
@@ -19,40 +13,25 @@ interface UseCheckoutStatusQueryOptions {
cwd: string;
}
function fetchCheckoutStatus(
client: CheckoutStatusClient,
cwd: string,
): Promise<CheckoutStatusPayload> {
return client.getCheckoutStatus(cwd);
}
export function useCheckoutStatusQuery({ serverId, cwd }: UseCheckoutStatusQueryOptions) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
if (!client || !isConnected || !cwd) {
return;
}
return client.on("checkout_status_update", (message) => {
applyCheckoutStatusUpdate({ queryClient, serverId, cwd, message });
});
}, [client, isConnected, cwd, queryClient, serverId]);
const query = useQuery({
queryKey: checkoutStatusQueryKey(serverId, cwd),
queryFn: async () => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await peekOrFetchCheckoutStatus({ queryClient, client, serverId, cwd });
return await fetchCheckoutStatus({ client, serverId, cwd });
},
enabled: !!client && isConnected && !!cwd,
staleTime: Infinity,
refetchOnMount: false,
// Freshness is push-driven (checkout_status_update applied globally); with
// staleTime: Infinity, refetchOnMount only fires after an explicit invalidation
// (e.g. reconnect), which is exactly when the push stream may have been missed.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});
@@ -81,7 +60,7 @@ export function useCheckoutStatusCacheOnly({ serverId, cwd }: UseCheckoutStatusQ
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await fetchCheckoutStatus(client, cwd);
return await fetchCheckoutStatus({ client, serverId, cwd });
},
enabled: false,
staleTime: CHECKOUT_STATUS_STALE_TIME,

View File

@@ -1,4 +1,5 @@
import type { DesktopDialogBridge } from "@/desktop/host";
import { RASTER_IMAGE_FILE_EXTENSIONS } from "@/attachments/file-types";
import { i18n } from "@/i18n/i18next";
import { isAbsolutePath } from "@/utils/path";
@@ -17,20 +18,6 @@ export interface ExpoImagePickerAssetLike {
file?: File | null;
}
const IMAGE_FILE_EXTENSIONS = [
"png",
"jpg",
"jpeg",
"gif",
"webp",
"avif",
"heic",
"heif",
"tiff",
"bmp",
"svg",
];
function shouldTreatAsFileUri(uri: string): boolean {
return uri.startsWith("file://") || isAbsolutePath(uri);
}
@@ -89,7 +76,7 @@ export async function openImagePathsWithDesktopDialog(
filters: [
{
name: i18n.t("imageAttachmentPicker.dialogFilterName"),
extensions: IMAGE_FILE_EXTENSIONS,
extensions: RASTER_IMAGE_FILE_EXTENSIONS,
},
],
title: i18n.t("imageAttachmentPicker.dialogTitle"),

View File

@@ -1,7 +1,23 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { OpenProjectResponseMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
export interface OpenProjectSuccess {
ok: true;
}
export interface OpenProjectFailure {
ok: false;
errorCode: OpenProjectErrorCode | null;
error: string | null;
}
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
export interface OpenProjectDirectlyInput {
serverId: string;
projectPath: string;
@@ -13,16 +29,22 @@ export interface OpenProjectDirectlyInput {
navigateToWorkspace: (serverId: string, workspaceId: string) => void;
}
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
export async function openProjectDirectly(
input: OpenProjectDirectlyInput,
): Promise<OpenProjectResult> {
const normalizedServerId = input.serverId.trim();
const trimmedPath = input.projectPath.trim();
if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
return false;
return { ok: false, errorCode: null, error: null };
}
const payload = await input.client.openProject(trimmedPath);
if (payload.error || !payload.workspace) {
return false;
return {
ok: false,
errorCode: payload.errorCode ?? null,
error: payload.error,
};
}
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
@@ -34,10 +56,10 @@ export async function openProjectDirectly(input: OpenProjectDirectlyInput): Prom
workspaceId: workspace.id,
});
if (!workspaceKey) {
return false;
return { ok: false, errorCode: null, error: null };
}
input.openDraftTab(workspaceKey);
input.navigateToWorkspace(normalizedServerId, workspace.id);
return true;
return { ok: true };
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useCallback, useSyncExternalStore } from "react";
import { useMemo, useCallback, useRef, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
@@ -44,6 +45,13 @@ export function useAggregatedAgents(options?: {
runtime.refreshAllAgentDirectories();
}, [runtime]);
// Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when
// none of its fields changed, so downstream memo/shallow comparisons can bail early.
const prevAgentsRef = useRef<Map<string, AggregatedAgent>>(new Map());
// Preserved sorted array — returned as-is when every element kept its identity
// and order, so callers using reference equality skip re-renders entirely.
const prevSortedRef = useRef<AggregatedAgent[]>([]);
const result = useMemo(() => {
// runtimeVersion is referenced so the memo recomputes when runtime state changes.
void runtimeVersion;
@@ -79,7 +87,11 @@ export function useAggregatedAgents(options?: {
createdAt: agent.createdAt,
labels: agent.labels,
};
allAgents.push(nextAgent);
const cacheKey = `${serverId}:${agent.id}`;
const prev = prevAgentsRef.current.get(cacheKey);
// Preserve object identity when fields are unchanged so callers can use
// reference equality (useShallow, memo) to skip re-renders.
allAgents.push(prev !== undefined && equal(prev, nextAgent) ? prev : nextAgent);
}
}
@@ -98,8 +110,25 @@ export function useAggregatedAgents(options?: {
return rightTime - leftTime;
});
// Update the identity cache for the next render pass.
const nextCache = new Map<string, AggregatedAgent>();
for (const agent of allAgents) {
nextCache.set(`${agent.serverId}:${agent.id}`, agent);
}
prevAgentsRef.current = nextCache;
// If every element kept its reference identity and the order is the same,
// return the previous array so downstream reference comparisons can bail.
const prevSorted = prevSortedRef.current;
const stableAgents =
allAgents.length === prevSorted.length &&
allAgents.every((agent, i) => agent === prevSorted[i])
? prevSorted
: allAgents;
prevSortedRef.current = stableAgents;
// Check if we have any cached data
const hasAnyData = allAgents.length > 0;
const hasAnyData = stableAgents.length > 0;
// Align list loading with the runtime directory-sync machine.
const isLoading = daemons.some((daemon) => {
@@ -111,7 +140,7 @@ export function useAggregatedAgents(options?: {
const isRevalidating = isLoading && hasAnyData;
return {
agents: allAgents,
agents: stableAgents,
isLoading,
isInitialLoad,
isRevalidating,

View File

@@ -2,10 +2,26 @@ import { useState, useRef, useEffect } from "react";
import type { ImageAttachment } from "@/composer/types";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import {
getRasterImageMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { isWeb } from "@/constants/platform";
export interface DroppedFileItem {
kind: "web-file";
file: File;
}
export interface DroppedPathItem {
kind: "desktop-path";
path: string;
}
export type DroppedItem = DroppedFileItem | DroppedPathItem;
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
onGenericFilesDropped?: (items: DroppedItem[]) => void;
disabled?: boolean;
}
@@ -15,20 +31,6 @@ interface UseFileDropZoneReturn {
}
const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".heic": "image/heic",
".heif": "image/heif",
".avif": "image/avif",
".tif": "image/tiff",
".tiff": "image/tiff",
};
type DesktopDragDropPayload =
| {
@@ -50,26 +52,8 @@ interface DesktopDragDropEvent {
payload: DesktopDragDropPayload;
}
function isImageFile(file: File): boolean {
return file.type.startsWith("image/");
}
function getFileExtension(path: string): string {
const normalizedPath = path.split("#", 1)[0]?.split("?", 1)[0] ?? path;
const extensionIndex = normalizedPath.lastIndexOf(".");
if (extensionIndex < 0) {
return "";
}
return normalizedPath.slice(extensionIndex).toLowerCase();
}
function isImagePath(path: string): boolean {
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
}
async function filePathToImageAttachment(path: string): Promise<ImageAttachment> {
const extension = getFileExtension(path);
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
const mimeType = getRasterImageMimeTypeFromPath(path) ?? "image/jpeg";
return await persistAttachmentFromFileUri({ uri: path, mimeType });
}
@@ -83,18 +67,24 @@ async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
export function useFileDropZone({
onFilesDropped,
onGenericFilesDropped,
disabled = false,
}: UseFileDropZoneOptions): UseFileDropZoneReturn {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLElement | null>(null);
const dragCounterRef = useRef(0);
const onFilesDroppedRef = useRef(onFilesDropped);
const onGenericFilesDroppedRef = useRef(onGenericFilesDropped);
// Keep callback ref up to date
// Keep callback refs up to date
useEffect(() => {
onFilesDroppedRef.current = onFilesDropped;
}, [onFilesDropped]);
useEffect(() => {
onGenericFilesDroppedRef.current = onGenericFilesDropped;
}, [onGenericFilesDropped]);
// Reset drag state when disabled changes
useEffect(() => {
if (disabled) {
@@ -156,7 +146,16 @@ export function useFileDropZone({
if (disabled) return;
const imagePaths = payload.paths.filter(isImagePath);
const items: DroppedPathItem[] = payload.paths.map((path) => ({
kind: "desktop-path",
path,
}));
if (onGenericFilesDroppedRef.current && items.length > 0) {
onGenericFilesDroppedRef.current(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
if (imagePaths.length === 0) {
return;
}
@@ -238,7 +237,16 @@ export function useFileDropZone({
if (disabled) return;
const files = Array.from(e.dataTransfer?.files ?? []);
const imageFiles = files.filter(isImageFile);
const genericItems: DroppedItem[] = files.map((file) => ({
kind: "web-file",
file,
}));
if (onGenericFilesDroppedRef.current && genericItems.length > 0) {
onGenericFilesDroppedRef.current(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
if (imageFiles.length === 0) return;

View File

@@ -0,0 +1,145 @@
import { useCallback, useRef } from "react";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { copyDesktopAttachmentFile } from "@/desktop/attachments/desktop-file-commands";
import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url";
import { isWeb } from "@/constants/platform";
import { getFileExtension, getMimeTypeFromPath } from "@/attachments/file-types";
export interface PickedFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
function base64ToUint8Array(base64: string): Uint8Array {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export async function readDesktopFileBytes(path: string): Promise<Uint8Array> {
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: path,
extension: getFileExtension(path).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
return base64ToUint8Array(base64);
}
async function pickFilesWithDesktopDialog(): Promise<PickedFile[] | null> {
const dialog = getDesktopHost()?.dialog;
const dialogOpen = dialog?.open;
if (typeof dialogOpen !== "function") {
throw new Error("Desktop dialog API is not available.");
}
const selection = await dialogOpen({
directory: false,
multiple: true,
});
if (!selection) {
return null;
}
const paths = Array.isArray(selection) ? selection : [selection];
if (paths.length === 0) {
return null;
}
const result: PickedFile[] = [];
for (const filePath of paths) {
const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath;
const mimeType = getMimeTypeFromPath(filePath);
// Copy into managed storage so we can read it through the existing secure IPC.
const { path: managedPath } = await copyDesktopAttachmentFile({
attachmentId: crypto.randomUUID(),
sourcePath: filePath,
extension: getFileExtension(filePath).slice(1) || null,
});
const base64 = await readDesktopFileBase64(managedPath);
const bytes = base64ToUint8Array(base64);
result.push({ fileName, mimeType, bytes });
}
return result;
}
function pickFilesWithWebInput(): Promise<PickedFile[] | null> {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", async () => {
const files = Array.from(input.files ?? []);
if (files.length === 0) {
resolve(null);
return;
}
const result: PickedFile[] = [];
for (const file of files) {
const bytes = new Uint8Array(await file.arrayBuffer());
result.push({
fileName: file.name,
mimeType: file.type || getMimeTypeFromPath(file.name),
bytes,
});
}
resolve(result);
});
input.addEventListener("cancel", () => {
resolve(null);
});
document.body.appendChild(input);
input.click();
// Clean up after a short delay to allow the change event to fire
setTimeout(() => {
input.remove();
}, 60_000);
});
}
export function useFilePicker() {
const isPickingRef = useRef(false);
const pickFiles = useCallback(async (): Promise<PickedFile[] | null> => {
if (isPickingRef.current) {
return null;
}
isPickingRef.current = true;
try {
if (isWeb && isElectronRuntime()) {
return await pickFilesWithDesktopDialog();
}
if (isWeb) {
return await pickFilesWithWebInput();
}
// Native not supported yet
return null;
} catch (error) {
console.error("[FilePicker] Failed to pick files:", error);
throw error;
} finally {
isPickingRef.current = false;
}
}, []);
return { pickFiles };
}

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { openProjectDirectly } from "@/hooks/open-project";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -91,11 +91,11 @@ describe("openProjectDirectly", () => {
projectPath: PROJECT_PATH,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
openProject: async () => ({
requestId: "request-1",
error: null,
workspace: workspacePayload,
})),
}),
},
mergeWorkspaces: session.mergeWorkspaces,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
@@ -103,7 +103,7 @@ describe("openProjectDirectly", () => {
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toBe(true);
expect(result).toEqual({ ok: true });
expect(session.merges).toHaveLength(1);
expect(session.merges[0]?.serverId).toBe(SERVER_ID);
expect(session.merges[0]?.workspaces[0]).toMatchObject({
@@ -127,11 +127,12 @@ describe("openProjectDirectly", () => {
projectPath: PROJECT_PATH,
isConnected: true,
client: {
openProject: vi.fn(async () => ({
openProject: async () => ({
requestId: "request-2",
error: "Failed to open project",
error: "Directory not found: /repo/project",
errorCode: "directory_not_found" as const,
workspace: null,
})),
}),
},
mergeWorkspaces: session.mergeWorkspaces,
setHasHydratedWorkspaces: session.setHasHydratedWorkspaces,
@@ -139,7 +140,11 @@ describe("openProjectDirectly", () => {
navigateToWorkspace: navigator.navigateToWorkspace,
});
expect(result).toBe(false);
expect(result).toEqual({
ok: false,
errorCode: "directory_not_found",
error: "Directory not found: /repo/project",
});
expect(session.merges).toEqual([]);
expect(session.hydrated).toEqual([]);
expect(layout.openedTabs).toEqual([]);

View File

@@ -4,9 +4,11 @@ import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { generateDraftId } from "@/stores/draft-keys";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { openProjectDirectly } from "@/hooks/open-project";
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
export function useOpenProject(
serverId: string | null,
): (path: string) => Promise<OpenProjectResult> {
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
@@ -147,22 +149,31 @@ export function useSidebarWorkspaceEntry(
serverId: string | null,
workspaceId: string | null,
): SidebarWorkspaceEntry | null {
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
const agents = useSessionStore((state) =>
serverId ? state.sessions[serverId]?.agents : undefined,
// Deep-compare so that adding/removing unrelated pending creates doesn't re-render this row.
const pendingCreateAttempts = useStoreWithEqualityFn(
useCreateFlowStore,
(state) => state.pendingByDraftId,
workspaceEqualityFns.deep,
);
const projectWorkspaceEntry = useCallback(
(workspace: WorkspaceDescriptor): SidebarWorkspaceEntry =>
createSidebarWorkspaceEntry({
// Single subscription: reads workspace + agents together, computes the full entry, and
// deep-compares the output. Agents-Map identity churn (setAgents replaces the Map on every
// status transition) never causes a React re-render unless the derived entry actually changes.
return useStoreWithEqualityFn(
useSessionStore,
(state) => {
const workspace = selectWorkspace(state, serverId, workspaceId);
if (!workspace) return null;
const agents = serverId ? state.sessions[serverId]?.agents : undefined;
return createSidebarWorkspaceEntry({
serverId: serverId ?? "",
workspace,
pendingCreateAttempts,
agents,
}),
[agents, pendingCreateAttempts, serverId],
});
},
equal,
);
return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry);
}
const EMPTY_ORDER: string[] = [];

View File

@@ -99,12 +99,15 @@ export const ar: TranslationResources = {
},
attachments: {
addImage: "أضف صورة",
addFile: "Upload file",
addIssueOrPr: "أضف مشكلة أو PR",
dropImagesHere: "إسقاط الصور هنا",
dropFilesHere: "Drop files here",
editQueuedMessage: "تحرير الرسالة في قائمة الانتظار",
sendQueuedMessageNow: "إرسال رسالة في قائمة الانتظار الآن",
openImage: "فتح مرفق الصورة",
removeImage: "إزالة مرفق الصورة",
removeFile: "Remove file attachment",
openGithub: "افتح{{kind}}#{{number}}",
removeGithub: "إزالة{{kind}}#{{number}}",
browserElement: "العنصر ·{{tag}}",
@@ -119,6 +122,8 @@ export const ar: TranslationResources = {
noHostSelected: "لم يتم تحديد مضيف",
initialPromptRequired: "مطلوب موجه الأولي",
alreadyLoading: "جارٍ التحميل بالفعل",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "أرشفة الوكيل الحالي",
@@ -585,16 +590,16 @@ export const ar: TranslationResources = {
success: "مؤرشف",
},
mergePr: {
squash: "الاسكواش والاندماج",
merge: "إنشاء التزام الدمج",
rebase: "إعادة الأساس والدمج",
squash: "دمج",
merge: "دمج",
rebase: "دمج",
pending: "دمج PR...",
success: "تم دمج PR",
},
autoMerge: {
enableSquash: "تمكين الدمج التلقائي مع الاسكواش",
enableMerge: "تمكين الدمج التلقائي مع التزام الدمج",
enableRebase: "تمكين الدمج التلقائي مع rebase",
enableSquash: "دمج تلقائي",
enableMerge: "دمج تلقائي",
enableRebase: "دمج تلقائي",
enabled: "تم تمكين الدمج التلقائي",
enabling: "تمكين الدمج التلقائي...",
disabling: "تعطيل الدمج التلقائي...",
@@ -1020,6 +1025,11 @@ export const ar: TranslationResources = {
placeholder: "اكتب مسار الدليل...",
opening: "افتتاح المشروع...",
empty: "ابدأ بكتابة المسار",
errors: {
directory_not_found: "لم يتم العثور على الدليل.",
open_failed: "تعذر فتح المشروع.",
},
openPath: "فتح المسار",
},
branchSwitcher: {
currentBranch: "الفرع الحالي:{{branchName}}. اضغط لتبديل الفرع.",
@@ -1642,7 +1652,10 @@ export const ar: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -97,12 +97,15 @@ export const en = {
},
attachments: {
addImage: "Add image",
addFile: "Upload file",
addIssueOrPr: "Add issue or PR",
dropImagesHere: "Drop images here",
dropFilesHere: "Drop files here",
editQueuedMessage: "Edit queued message",
sendQueuedMessageNow: "Send queued message now",
openImage: "Open image attachment",
removeImage: "Remove image attachment",
removeFile: "Remove file attachment",
openGithub: "Open {{kind}} #{{number}}",
removeGithub: "Remove {{kind}} #{{number}}",
browserElement: "Element · {{tag}}",
@@ -117,6 +120,8 @@ export const en = {
noHostSelected: "No host selected",
initialPromptRequired: "Initial prompt is required",
alreadyLoading: "Already loading",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archive the current agent",
@@ -583,16 +588,16 @@ export const en = {
success: "Archived",
},
mergePr: {
squash: "Squash and merge",
merge: "Create a merge commit",
rebase: "Rebase and merge",
squash: "Merge",
merge: "Merge",
rebase: "Merge",
pending: "Merging PR...",
success: "PR merged",
},
autoMerge: {
enableSquash: "Enable auto-merge with squash",
enableMerge: "Enable auto-merge with merge commit",
enableRebase: "Enable auto-merge with rebase",
enableSquash: "Auto merge",
enableMerge: "Auto merge",
enableRebase: "Auto merge",
enabled: "Auto-merge enabled",
enabling: "Enabling auto-merge...",
disabling: "Disabling auto-merge...",
@@ -1025,6 +1030,11 @@ export const en = {
placeholder: "Type a directory path...",
opening: "Opening project...",
empty: "Start typing a path",
errors: {
directory_not_found: "Directory not found.",
open_failed: "Could not open project.",
},
openPath: "Open path",
},
branchSwitcher: {
currentBranch: "Current branch: {{branchName}}. Press to switch branch.",
@@ -1648,7 +1658,10 @@ export const en = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -99,12 +99,15 @@ export const es: TranslationResources = {
},
attachments: {
addImage: "Agregar imagen",
addFile: "Upload file",
addIssueOrPr: "Agregar problema oPR",
dropImagesHere: "Suelta imágenes aquí",
dropFilesHere: "Drop files here",
editQueuedMessage: "Editar mensaje en cola",
sendQueuedMessageNow: "Enviar mensaje en cola ahora",
openImage: "Abrir imagen adjunta",
removeImage: "Quitar imagen adjunta",
removeFile: "Remove file attachment",
openGithub: "Abrir{{kind}}#{{number}}",
removeGithub: "Quitar{{kind}}#{{number}}",
browserElement: "Elemento ·{{tag}}",
@@ -119,6 +122,8 @@ export const es: TranslationResources = {
noHostSelected: "Ningún anfitrión seleccionado",
initialPromptRequired: "Se requiere aviso inicial",
alreadyLoading: "Ya cargando",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archivar el agente actual",
@@ -590,16 +595,16 @@ export const es: TranslationResources = {
success: "Archivado",
},
mergePr: {
squash: "Aplastar y fusionar",
merge: "Crear una confirmación de fusión",
rebase: "Rebase y fusionar",
squash: "Fusionar",
merge: "Fusionar",
rebase: "Fusionar",
pending: "FusionandoPR...",
success: "PRfusionado",
},
autoMerge: {
enableSquash: "Habilitar la fusión automática con squash",
enableMerge: "Habilitar la fusión automática con confirmación de fusión",
enableRebase: "Habilitar la fusión automática con rebase",
enableSquash: "Fusión automática",
enableMerge: "Fusión automática",
enableRebase: "Fusión automática",
enabled: "Combinación automática habilitada",
enabling: "Habilitando la fusión automática...",
disabling: "Desactivando la fusión automática...",
@@ -1052,6 +1057,11 @@ export const es: TranslationResources = {
placeholder: "Escriba una ruta de directorio...",
opening: "Proyecto de apertura...",
empty: "Comience a escribir una ruta",
errors: {
directory_not_found: "No se encontró el directorio.",
open_failed: "No se pudo abrir el proyecto.",
},
openPath: "Abrir ruta",
},
branchSwitcher: {
currentBranch: "Sucursal actual:{{branchName}}. Presione para cambiar de rama.",
@@ -1680,7 +1690,10 @@ export const es: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -100,12 +100,15 @@ export const fr: TranslationResources = {
},
attachments: {
addImage: "Ajouter une image",
addFile: "Upload file",
addIssueOrPr: "Ajouter un problème ouPR",
dropImagesHere: "Déposez des images ici",
dropFilesHere: "Drop files here",
editQueuedMessage: "Modifier le message en file d'attente",
sendQueuedMessageNow: "Envoyer le message en file d'attente maintenant",
openImage: "Ouvrir la pièce jointe de l'image",
removeImage: "Supprimer l'image jointe",
removeFile: "Remove file attachment",
openGithub: "Ouvrir{{kind}}#{{number}}",
removeGithub: "Supprimer{{kind}}#{{number}}",
browserElement: "Élément ·{{tag}}",
@@ -120,6 +123,8 @@ export const fr: TranslationResources = {
noHostSelected: "Aucun hôte sélectionné",
initialPromptRequired: "Une invite initiale est requise",
alreadyLoading: "Déjà en cours de chargement",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Archiver l'agent actuel",
@@ -590,16 +595,16 @@ export const fr: TranslationResources = {
success: "Archivé",
},
mergePr: {
squash: "Écraser et fusionner",
merge: "Créer un commit de fusion",
rebase: "Rebase et fusionner",
squash: "Fusionner",
merge: "Fusionner",
rebase: "Fusionner",
pending: "Fusion dePR...",
success: "PRfusionné",
},
autoMerge: {
enableSquash: "Activer la fusion automatique avec squash",
enableMerge: "Activer la fusion automatique avec la validation de fusion",
enableRebase: "Activer la fusion automatique avec rebase",
enableSquash: "Fusion automatique",
enableMerge: "Fusion automatique",
enableRebase: "Fusion automatique",
enabled: "Fusion automatique activée",
enabling: "Activation de la fusion automatique...",
disabling: "Désactivation de la fusion automatique...",
@@ -1054,6 +1059,11 @@ export const fr: TranslationResources = {
placeholder: "Tapez un chemin de répertoire...",
opening: "Projet d'ouverture...",
empty: "Commencez à taper un chemin",
errors: {
directory_not_found: "Répertoire introuvable.",
open_failed: "Impossible douvrir le projet.",
},
openPath: "Ouvrir le chemin",
},
branchSwitcher: {
currentBranch: "Branche actuelle:{{branchName}}. Appuyez pour changer de branche.",
@@ -1685,7 +1695,10 @@ export const fr: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -99,12 +99,15 @@ export const ru: TranslationResources = {
},
attachments: {
addImage: "Добавить изображение",
addFile: "Upload file",
addIssueOrPr: "Добавить проблему или PR",
dropImagesHere: "Скиньте изображения сюда",
dropFilesHere: "Drop files here",
editQueuedMessage: "Изменить сообщение в очереди",
sendQueuedMessageNow: "Отправить сообщение в очереди сейчас",
openImage: "Открыть прикрепленное изображение",
removeImage: "Удалить прикрепленное изображение",
removeFile: "Remove file attachment",
openGithub: "Открыть{{kind}}#{{number}}",
removeGithub: "Удалить{{kind}}#{{number}}",
browserElement: "Элемент ·{{tag}}",
@@ -119,6 +122,8 @@ export const ru: TranslationResources = {
noHostSelected: "Хост не выбран",
initialPromptRequired: "Требуется начальное приглашение",
alreadyLoading: "Уже загружается",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "Архивировать текущего агента",
@@ -589,16 +594,16 @@ export const ru: TranslationResources = {
success: "В архиве",
},
mergePr: {
squash: "Сжать и объединить",
merge: "Создать коммит слияния",
rebase: "Перебазировать и объединить",
squash: "Объединить",
merge: "Объединить",
rebase: "Объединить",
pending: "Объединение PR...",
success: "PR объединен",
},
autoMerge: {
enableSquash: "Включить автоматическое объединение со сквошом",
enableMerge: "Включить автоматическое слияние с фиксацией слияния",
enableRebase: "Включить автоматическое слияние с перебазированием",
enableSquash: "Автообъединение",
enableMerge: "Автообъединение",
enableRebase: "Автообъединение",
enabled: "Автоматическое объединение включено",
enabling: "Включение автоматического объединения...",
disabling: "Отключение автоматического объединения...",
@@ -1042,6 +1047,11 @@ export const ru: TranslationResources = {
placeholder: "Введите путь к каталогу...",
opening: "Открытие проекта...",
empty: "Начните вводить путь",
errors: {
directory_not_found: "Каталог не найден.",
open_failed: "Не удалось открыть проект.",
},
openPath: "Открыть путь",
},
branchSwitcher: {
currentBranch: "Текущая ветка:{{branchName}}. Нажмите, чтобы переключить ветку.",
@@ -1672,7 +1682,10 @@ export const ru: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -99,12 +99,15 @@ export const zhCN: TranslationResources = {
},
attachments: {
addImage: "添加图片",
addFile: "Upload file",
addIssueOrPr: "添加 issue 或 PR",
dropImagesHere: "将图片拖放到这里",
dropFilesHere: "Drop files here",
editQueuedMessage: "编辑排队消息",
sendQueuedMessageNow: "立即发送排队消息",
openImage: "打开图片附件",
removeImage: "移除图片附件",
removeFile: "Remove file attachment",
openGithub: "打开 {{kind}} #{{number}}",
removeGithub: "移除 {{kind}} #{{number}}",
browserElement: "元素 · {{tag}}",
@@ -119,6 +122,8 @@ export const zhCN: TranslationResources = {
noHostSelected: "未选择 Host",
initialPromptRequired: "初始 prompt 必填",
alreadyLoading: "正在加载",
uploadFailed: "Failed to upload file",
fileTooLarge: "{{fileName}} is too large (max {{size}})",
},
clientCommands: {
archiveAgent: "归档当前 Agent",
@@ -583,16 +588,16 @@ export const zhCN: TranslationResources = {
success: "已归档",
},
mergePr: {
squash: "Squash and merge",
merge: "Create a merge commit",
rebase: "Rebase and merge",
squash: "Merge",
merge: "Merge",
rebase: "Merge",
pending: "正在 merge PR...",
success: "PR 已 merge",
},
autoMerge: {
enableSquash: "启用 squash auto-merge",
enableMerge: "启用 merge commit auto-merge",
enableRebase: "启用 rebase auto-merge",
enableSquash: "Auto merge",
enableMerge: "Auto merge",
enableRebase: "Auto merge",
enabled: "Auto-merge 已启用",
enabling: "正在启用 auto-merge...",
disabling: "正在禁用 auto-merge...",
@@ -1005,6 +1010,11 @@ export const zhCN: TranslationResources = {
placeholder: "输入目录路径...",
opening: "正在打开 project...",
empty: "开始输入路径",
errors: {
directory_not_found: "找不到目录。",
open_failed: "无法打开项目。",
},
openPath: "打开路径",
},
branchSwitcher: {
currentBranch: "当前分支:{{branchName}}。按下以切换分支。",
@@ -1623,7 +1633,10 @@ export const zhCN: TranslationResources = {
nameLabel: "Name",
commandLabel: "Command",
argsLabel: "Arguments",
nameRequired: "Name is required",
commandRequired: "Command is required",
argsHint: "Space-separated arguments passed to the command",
saving: "Saving...",
remove: "Remove",
removeConfirmTitle: "Remove profile?",
removeConfirmMessage: 'Remove "{{name}}"?',

View File

@@ -15,10 +15,24 @@ import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/composer";
import { AgentModeControl } from "@/composer/agent-controls/mode-control";
import { FileDropZone } from "@/components/file-drop-zone";
import { uploadFileAttachments } from "@/composer/actions";
import {
getMimeTypeFromPath,
isRasterImageFile,
isRasterImagePath,
} from "@/attachments/file-types";
import { readDesktopFileBytes } from "@/hooks/use-file-picker";
import type { DroppedItem } from "@/hooks/use-file-drop-zone";
import type { UserComposerAttachment } from "@/attachments/types";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/composer/types";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
import {
ToastViewport,
useToastHost,
type ToastApi,
type ToastState,
} from "@/components/toast-host";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import {
useWorkspaceAttachments,
@@ -678,10 +692,11 @@ function ChatAgentContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const panelToast = useToastHost();
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
const streamViewRef = useRef<AgentStreamViewHandle>(null);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const addFilesRef = useRef<((files: UserComposerAttachment[]) => void) | null>(null);
const clearOnAgentBlurRef = useRef<() => void>(() => {});
const wasPaneFocusedRef = useRef(isPaneFocused);
const reconnectToastArmedRef = useRef(false);
@@ -698,6 +713,46 @@ function ChatAgentContent({
addImagesRef.current = addImages;
}, []);
const handleAddFilesCallback = useCallback(
(addFiles: (files: UserComposerAttachment[]) => void) => {
addFilesRef.current = addFiles;
},
[],
);
const handleGenericFilesDropped = useCallback(
async (items: DroppedItem[]) => {
if (!client || !isConnected) return;
const nonImageItems = items.filter((item) => {
if (item.kind === "web-file") return !isRasterImageFile(item.file);
return !isRasterImagePath(item.path);
});
if (nonImageItems.length === 0) return;
try {
const files = await Promise.all(
nonImageItems.map(async (item) => {
if (item.kind === "web-file") {
return {
fileName: item.file.name,
mimeType: item.file.type || getMimeTypeFromPath(item.file.name),
bytes: new Uint8Array(await item.file.arrayBuffer()),
};
}
const fileName = item.path.split("/").pop() ?? item.path.split("\\").pop() ?? item.path;
const bytes = await readDesktopFileBytes(item.path);
return { fileName, mimeType: getMimeTypeFromPath(item.path), bytes };
}),
);
const uploaded = await uploadFileAttachments({ client, files });
addFilesRef.current?.(uploaded);
} catch (error) {
console.error("[AgentPanel] Failed to upload dropped files:", error);
toastApi.error(error instanceof Error ? error.message : "Failed to upload file");
}
},
[client, isConnected, toastApi],
);
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
@@ -796,7 +851,7 @@ function ChatAgentContent({
if (connectionStatus === "online") {
if (reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = false;
panelToast.dismiss();
dismissToast();
}
return;
}
@@ -805,12 +860,12 @@ function ChatAgentContent({
}
if (!reconnectToastArmedRef.current) {
reconnectToastArmedRef.current = true;
panelToast.api.show(t("agentPanel.states.reconnecting"), {
toastApi.show(t("agentPanel.states.reconnecting"), {
durationMs: null,
testID: "agent-reconnecting-toast",
});
}
}, [connectionStatus, panelToast, t]);
}, [connectionStatus, dismissToast, toastApi, t]);
useEffect(() => {
if (!isPaneFocused || !agentId || !isConnected || !hasSession) {
@@ -1044,22 +1099,27 @@ function ChatAgentContent({
effectiveAgent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
panelToast={panelToast}
toastApi={toastApi}
toast={toastState}
dismiss={dismissToast}
streamViewRef={streamViewRef}
animatedContentStyle={animatedContentStyle}
handleFilesDropped={handleFilesDropped}
handleGenericFilesDropped={handleGenericFilesDropped}
handleAddImagesCallback={handleAddImagesCallback}
handleAddFilesCallback={handleAddFilesCallback}
handleComposerHeightChange={handleComposerHeightChange}
handleMessageSent={handleMessageSent}
showHistorySyncOverlay={showHistorySyncOverlay}
cwd={agentCwd}
attentionController={attentionController}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
function ChatAgentReadyContent({
const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
serverId,
agentId,
isPaneFocused,
@@ -1068,16 +1128,21 @@ function ChatAgentReadyContent({
effectiveAgent,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
panelToast,
toastApi,
toast,
dismiss,
streamViewRef,
animatedContentStyle,
handleFilesDropped,
handleGenericFilesDropped,
handleAddImagesCallback,
handleAddFilesCallback,
handleComposerHeightChange,
handleMessageSent,
showHistorySyncOverlay,
cwd,
attentionController,
onAttentionInputFocus,
onAttentionPromptSend,
onOpenWorkspaceFile,
}: {
serverId: string;
@@ -1088,25 +1153,46 @@ function ChatAgentReadyContent({
effectiveAgent: AgentScreenAgent;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
panelToast: ReturnType<typeof useToastHost>;
toastApi: ToastApi;
toast: ToastState | null;
dismiss: () => void;
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
animatedContentStyle: object[];
handleFilesDropped: (files: ImageAttachment[]) => void;
handleGenericFilesDropped: (items: DroppedItem[]) => void;
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
handleAddFilesCallback: (addFiles: (files: UserComposerAttachment[]) => void) => void;
handleComposerHeightChange: (height: number) => void;
handleMessageSent: () => void;
showHistorySyncOverlay: boolean;
cwd: string;
attentionController: ReturnType<typeof useAgentAttentionClear>;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const agentInputDraft = useAgentInputDraft({
const rawAgentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
});
// Stabilize the agentInputDraft object identity so that memo(AgentComposerSection) can bail out
// when only toast state changes (which does not affect any draft field).
const { text, setText, attachments, setAttachments, clear, isHydrated, composerState } =
rawAgentInputDraft;
const agentInputDraft = useMemo(
(): AgentInputDraft => ({
text,
setText,
attachments,
setAttachments,
clear,
isHydrated,
composerState,
}),
[text, setText, attachments, setAttachments, clear, isHydrated, composerState],
);
const streamSection = (
<RenderProfile id={`AgentStreamSection:${agentId}`}>
<AgentStreamSection
@@ -1116,7 +1202,7 @@ function ChatAgentReadyContent({
agent={effectiveAgent}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
toast={toastApi}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</RenderProfile>
@@ -1132,9 +1218,10 @@ function ChatAgentReadyContent({
cwd={cwd}
isSubmitLoading={false}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={handleAddImagesCallback}
onAddFiles={handleAddFilesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
@@ -1148,7 +1235,11 @@ function ChatAgentReadyContent({
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<FileDropZone
onFilesDropped={handleFilesDropped}
onGenericFilesDropped={handleGenericFilesDropped}
disabled={isArchivingCurrentAgent}
>
<View style={styles.container}>
{contentContainer}
@@ -1160,11 +1251,7 @@ function ChatAgentReadyContent({
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
<ToastViewport toast={toast} onDismiss={dismiss} placement="panel" />
</View>
</FileDropZone>
@@ -1178,7 +1265,7 @@ function ChatAgentReadyContent({
</View>
</RewindComposerRestoreProvider>
);
}
});
const AgentStreamSection = memo(function AgentStreamSection({
streamViewRef,
@@ -1246,7 +1333,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
);
});
function AgentComposerSection({
const AgentComposerSection = memo(function AgentComposerSection({
agentId,
serverId,
isPaneFocused,
@@ -1258,6 +1345,7 @@ function AgentComposerSection({
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange,
onMessageSent,
}: {
@@ -1272,6 +1360,7 @@ function AgentComposerSection({
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
@@ -1296,11 +1385,12 @@ function AgentComposerSection({
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
/>
);
}
});
function ActiveAgentComposer({
agentId,
@@ -1312,6 +1402,7 @@ function ActiveAgentComposer({
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
onAddFiles,
onComposerHeightChange,
onMessageSent,
}: {
@@ -1324,6 +1415,7 @@ function ActiveAgentComposer({
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
onAddFiles: (addFiles: (files: UserComposerAttachment[]) => void) => void;
onComposerHeightChange: (height: number) => void;
onMessageSent: () => void;
}) {
@@ -1465,6 +1557,7 @@ function ActiveAgentComposer({
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
onAddFiles={onAddFiles}
onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}

View File

@@ -2,15 +2,17 @@ export {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
buildReviewDraftScopeKey,
expireStaleDiffModeOverrides,
getReviewDraftComments,
resetReviewDraftStore,
useActiveReviewDraftMode,
useClearReviewDraft,
useReviewAttachmentSnapshot,
useSetActiveReviewDraftMode,
useResolvedDiffMode,
useSetDiffModeOverride,
addReviewDraftComment,
type BuildReviewDraftKeyInput,
type BuildReviewDraftScopeKeyInput,
type DiffModeOverride,
type ReviewDraftCommentInput,
type ReviewDraftComment,
type ReviewDraftMode,

View File

@@ -11,32 +11,80 @@ export interface ReviewDraftComment {
updatedAt: string;
}
// A manual mode selection is valid only while the checkout's dirty state matches the
// value at the time of selection. serverId/cwd identify the checkout so the override can
// be expired when its dirty state changes (see expireStaleDiffModeOverridesInState).
export interface DiffModeOverride {
serverId: string;
cwd: string;
mode: ReviewDraftMode;
isDirtyAtSelection: boolean;
}
export interface ReviewDraftStoreState {
drafts: Record<string, ReviewDraftComment[]>;
activeModesByScope: Record<string, ReviewDraftMode>;
// In-memory only — not persisted. Keyed by scope key.
diffModeOverrides: Record<string, DiffModeOverride>;
}
// Only drafts are persisted; diffModeOverrides is intentionally excluded.
export interface SerializedReviewDraftState {
drafts: Record<string, ReviewDraftComment[]>;
activeModesByScope: Record<string, ReviewDraftMode>;
}
export function setActiveModeInState(
export function setDiffModeOverrideInState(
state: ReviewDraftStoreState,
input: { scopeKey: string; mode: ReviewDraftMode },
input: { scopeKey: string; override: DiffModeOverride },
): ReviewDraftStoreState {
if (state.activeModesByScope[input.scopeKey] === input.mode) {
return state;
}
return {
...state,
activeModesByScope: {
...state.activeModesByScope,
[input.scopeKey]: input.mode,
diffModeOverrides: {
...state.diffModeOverrides,
[input.scopeKey]: input.override,
},
};
}
// Drops every override for the checkout whose dirty state no longer matches the value it
// was selected under. Called whenever a checkout status enters the app (push or fetch),
// so expiry does not depend on any screen being mounted.
export function expireStaleDiffModeOverridesInState(
state: ReviewDraftStoreState,
input: { serverId: string; cwd: string; isDirty: boolean },
): ReviewDraftStoreState {
const staleScopeKeys = Object.entries(state.diffModeOverrides)
.filter(
([, override]) =>
override.serverId === input.serverId &&
override.cwd === input.cwd &&
override.isDirtyAtSelection !== input.isDirty,
)
.map(([scopeKey]) => scopeKey);
if (staleScopeKeys.length === 0) {
return state;
}
const next = { ...state.diffModeOverrides };
for (const scopeKey of staleScopeKeys) {
delete next[scopeKey];
}
return { ...state, diffModeOverrides: next };
}
// Pure read — returns the effective mode without mutating state. The staleness check is
// kept even though stale overrides are expired at the data boundary: a render can observe
// a fresh dirty state before the expiry lands, and resolution must be correct under any
// interleaving.
export function resolveDiffMode(input: {
override: DiffModeOverride | undefined;
hasUncommittedChanges: boolean;
}): ReviewDraftMode {
const { override, hasUncommittedChanges } = input;
if (override && override.isDirtyAtSelection === hasUncommittedChanges) {
return override.mode;
}
return hasUncommittedChanges ? "uncommitted" : "base";
}
export function addCommentToState(
state: ReviewDraftStoreState,
input: { key: string; comment: ReviewDraftComment },
@@ -108,18 +156,18 @@ export function serializeReviewDraftState(
): SerializedReviewDraftState {
return {
drafts: state.drafts,
activeModesByScope: state.activeModesByScope,
};
}
export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
if (!state || typeof state !== "object") {
return { drafts: {}, activeModesByScope: {} };
return { drafts: {}, diffModeOverrides: {} };
}
const persisted = state as { drafts?: unknown; activeModesByScope?: unknown };
// activeModesByScope may be present in old persisted JSON — tolerate and ignore it.
const persisted = state as { drafts?: unknown };
const drafts = persisted.drafts;
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
return { drafts: {}, activeModesByScope: {} };
return { drafts: {}, diffModeOverrides: {} };
}
const normalized: Record<string, ReviewDraftComment[]> = {};
@@ -132,20 +180,7 @@ export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
);
}
const activeModesByScope: Record<string, ReviewDraftMode> = {};
const persistedActiveModes = persisted.activeModesByScope;
if (
persistedActiveModes &&
typeof persistedActiveModes === "object" &&
!Array.isArray(persistedActiveModes)
) {
for (const [key, mode] of Object.entries(persistedActiveModes)) {
if (mode === "base" || mode === "uncommitted") {
activeModesByScope[key] = mode;
}
}
}
return { drafts: normalized, activeModesByScope };
return { drafts: normalized, diffModeOverrides: {} };
}
export function isReviewDraftComment(value: unknown): value is ReviewDraftComment {

View File

@@ -9,15 +9,25 @@ import {
addCommentToState,
clearReviewInState,
deleteCommentFromState,
type DiffModeOverride,
expireStaleDiffModeOverridesInState,
normalizePersistedState,
resolveDiffMode,
type ReviewDraftComment,
type ReviewDraftStoreState,
setActiveModeInState,
serializeReviewDraftState,
setDiffModeOverrideInState,
updateCommentInState,
} from "./state";
function emptyState(): ReviewDraftStoreState {
return { drafts: {}, activeModesByScope: {} };
return { drafts: {}, diffModeOverrides: {} };
}
function makeOverride(
input: Pick<DiffModeOverride, "mode" | "isDirtyAtSelection">,
): DiffModeOverride {
return { serverId: "server-1", cwd: "/repo", ...input };
}
function makeComment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftComment {
@@ -95,7 +105,7 @@ describe("buildReviewDraftKey", () => {
).toBe("review:server=local:cwd=%2Frepo:mode=base:base=main:ignoreWhitespace=false");
});
it("builds a mode-free scope key for active review mode sharing", () => {
it("builds a mode-free scope key for diff mode override sharing", () => {
const scope = buildReviewDraftScopeKey({
serverId: "local",
workspaceId: "workspace-1",
@@ -112,7 +122,7 @@ describe("buildReviewDraftKey", () => {
});
describe("normalizePersistedState", () => {
it("filters invalid draft comments and drops unknown active modes", () => {
it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => {
const normalized = normalizePersistedState({
drafts: {
"review:key": [
@@ -128,17 +138,14 @@ describe("normalizePersistedState", () => {
{ id: "bad", filePath: "src/example.ts" },
],
},
// Old persisted field — must be tolerated and ignored, not migrated.
activeModesByScope: {
"review:scope:base": "base",
"review:scope:dirty": "uncommitted",
"review:scope:bad": "other",
},
});
expect(normalized.activeModesByScope).toEqual({
"review:scope:base": "base",
"review:scope:dirty": "uncommitted",
});
expect(normalized.diffModeOverrides).toEqual({});
expect(normalized.drafts["review:key"]).toEqual([
{
id: "comment-1",
@@ -153,15 +160,144 @@ describe("normalizePersistedState", () => {
});
it("returns empty state for null, non-object, or malformed inputs", () => {
expect(normalizePersistedState(null)).toEqual({ drafts: {}, activeModesByScope: {} });
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, activeModesByScope: {} });
expect(normalizePersistedState(null)).toEqual({ drafts: {}, diffModeOverrides: {} });
expect(normalizePersistedState("nope")).toEqual({ drafts: {}, diffModeOverrides: {} });
expect(normalizePersistedState({ drafts: [] })).toEqual({
drafts: {},
activeModesByScope: {},
diffModeOverrides: {},
});
});
});
describe("serializeReviewDraftState", () => {
it("serialized output does not contain activeModesByScope or diffModeOverrides", () => {
const state = setDiffModeOverrideInState(
addCommentToState(emptyState(), { key: "review:key", comment: makeComment() }),
{
scopeKey: "review:scope",
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
},
);
const serialized = serializeReviewDraftState(state);
expect(Object.keys(serialized)).toEqual(["drafts"]);
expect("activeModesByScope" in serialized).toBe(false);
expect("diffModeOverrides" in serialized).toBe(false);
expect(serialized.drafts["review:key"]).toHaveLength(1);
});
});
describe("diff mode override", () => {
it("resolves to auto mode from the dirty state when no override exists", () => {
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: true })).toBe(
"uncommitted",
);
expect(resolveDiffMode({ override: undefined, hasUncommittedChanges: false })).toBe("base");
});
it("honors the override while isDirty matches the value at selection, across remounts", () => {
const state = setDiffModeOverrideInState(emptyState(), {
scopeKey: "review:scope",
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
});
// Resolution is a plain store read, so it gives the same answer on every (re)mount.
const override = state.diffModeOverrides["review:scope"];
expect(resolveDiffMode({ override, hasUncommittedChanges: true })).toBe("base");
});
it("masks a stale override before its expiry lands", () => {
const state = setDiffModeOverrideInState(emptyState(), {
scopeKey: "review:scope",
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: true }),
});
const override = state.diffModeOverrides["review:scope"];
expect(resolveDiffMode({ override, hasUncommittedChanges: false })).toBe("base");
});
it("expires overrides for the checkout whose dirty state flipped, keeping the rest", () => {
let state = setDiffModeOverrideInState(emptyState(), {
scopeKey: "review:scope-a",
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
});
state = setDiffModeOverrideInState(state, {
scopeKey: "review:scope-b",
override: makeOverride({ mode: "uncommitted", isDirtyAtSelection: false }),
});
state = setDiffModeOverrideInState(state, {
scopeKey: "review:scope-other",
override: {
serverId: "server-2",
cwd: "/other",
mode: "base",
isDirtyAtSelection: true,
},
});
const next = expireStaleDiffModeOverridesInState(state, {
serverId: "server-1",
cwd: "/repo",
isDirty: false,
});
expect(next.diffModeOverrides["review:scope-a"]).toBeUndefined();
expect(next.diffModeOverrides["review:scope-b"]).toBeDefined();
expect(next.diffModeOverrides["review:scope-other"]).toBeDefined();
});
it("does not resurrect an expired override when the dirty state flips back", () => {
let state = setDiffModeOverrideInState(emptyState(), {
scopeKey: "review:scope",
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
});
// The checkout goes clean (e.g. agent commits): the override expires at the boundary.
state = expireStaleDiffModeOverridesInState(state, {
serverId: "server-1",
cwd: "/repo",
isDirty: false,
});
// The checkout goes dirty again: nothing to expire, and nothing comes back.
state = expireStaleDiffModeOverridesInState(state, {
serverId: "server-1",
cwd: "/repo",
isDirty: true,
});
expect(state.diffModeOverrides["review:scope"]).toBeUndefined();
expect(
resolveDiffMode({
override: state.diffModeOverrides["review:scope"],
hasUncommittedChanges: true,
}),
).toBe("uncommitted");
});
it("keeps state identity when no override is stale", () => {
const state = setDiffModeOverrideInState(emptyState(), {
scopeKey: "review:scope",
override: makeOverride({ mode: "base", isDirtyAtSelection: true }),
});
expect(
expireStaleDiffModeOverridesInState(state, {
serverId: "server-1",
cwd: "/repo",
isDirty: true,
}),
).toBe(state);
expect(
expireStaleDiffModeOverridesInState(emptyState(), {
serverId: "server-1",
cwd: "/repo",
isDirty: false,
}),
).toEqual(emptyState());
});
});
describe("review draft reducers", () => {
it("adds, updates, and deletes draft comments by key", () => {
let state = emptyState();
@@ -203,18 +339,6 @@ describe("review draft reducers", () => {
expect(deleteCommentFromState(state, { key: "review:key", id: "missing" })).toBe(state);
expect(clearReviewInState(state, { key: "other-key" })).toBe(state);
});
it("keeps state identity when setActiveMode does not change the mode", () => {
const state = setActiveModeInState(emptyState(), {
scopeKey: "review:scope",
mode: "base",
});
expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "base" })).toBe(state);
expect(setActiveModeInState(state, { scopeKey: "review:scope", mode: "uncommitted" })).not.toBe(
state,
);
});
});
describe("buildReviewAttachmentSnapshot", () => {

View File

@@ -8,22 +8,31 @@ import {
addCommentToState,
clearReviewInState,
deleteCommentFromState,
type DiffModeOverride,
expireStaleDiffModeOverridesInState,
normalizePersistedState,
resolveDiffMode,
type ReviewDraftComment,
type ReviewDraftMode,
type ReviewDraftSide,
type ReviewDraftStoreState,
serializeReviewDraftState,
setActiveModeInState,
setDiffModeOverrideInState,
updateCommentInState,
} from "@/review/state";
import { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
export type { ReviewDraftComment, ReviewDraftMode, ReviewDraftSide } from "@/review/state";
export type {
DiffModeOverride,
ReviewDraftComment,
ReviewDraftMode,
ReviewDraftSide,
} from "@/review/state";
const STORE_VERSION = 1;
// v2 dropped persisted activeModesByScope (diff mode overrides are in-memory only).
const STORE_VERSION = 2;
const CONTEXT_RADIUS = 3;
const EMPTY_REVIEW_DRAFT_COMMENTS: ReviewDraftComment[] = [];
@@ -55,7 +64,7 @@ export type ReviewDraftCommentInput = Omit<ReviewDraftComment, "id" | "createdAt
Partial<Pick<ReviewDraftComment, "id" | "createdAt" | "updatedAt">>;
interface ReviewDraftStoreActions {
setActiveMode: (input: { scopeKey: string; mode: ReviewDraftMode }) => void;
setDiffModeOverride: (input: { scopeKey: string; override: DiffModeOverride }) => void;
addComment: (input: { key: string; comment: ReviewDraftCommentInput }) => ReviewDraftComment;
updateComment: (input: {
key: string;
@@ -129,9 +138,9 @@ export const useReviewDraftStore = create<ReviewDraftStore>()(
persist(
(set) => ({
drafts: {},
activeModesByScope: {},
setActiveMode: (input) => {
set((state) => setActiveModeInState(state, input));
diffModeOverrides: {},
setDiffModeOverride: (input) => {
set((state) => setDiffModeOverrideInState(state, input));
},
addComment: ({ key, comment }) => {
const nextComment = createDraftComment(comment);
@@ -271,8 +280,18 @@ export function useReviewDraftComments(key: string): ReviewDraftComment[] {
return useReviewDraftStore((state) => state.drafts[key] ?? EMPTY_REVIEW_DRAFT_COMMENTS);
}
export function useSetActiveReviewDraftMode(): ReviewDraftStoreActions["setActiveMode"] {
return useReviewDraftStore((state) => state.setActiveMode);
export function useSetDiffModeOverride(): ReviewDraftStoreActions["setDiffModeOverride"] {
return useReviewDraftStore((state) => state.setDiffModeOverride);
}
// Non-React entry point: called from the checkout-status data boundary, where dirty-state
// changes enter the app regardless of which screens are mounted.
export function expireStaleDiffModeOverrides(input: {
serverId: string;
cwd: string;
isDirty: boolean;
}): void {
useReviewDraftStore.setState((state) => expireStaleDiffModeOverridesInState(state, input));
}
export function useClearReviewDraft(): ReviewDraftStoreActions["clearReview"] {
@@ -291,7 +310,7 @@ export function getReviewDraftComments(key: string): ReviewDraftComment[] | unde
}
export function resetReviewDraftStore(): void {
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
}
export function useReviewDraftCommentsForAttachment(input: {
@@ -309,8 +328,16 @@ export function useReviewCommentCount(key: string): number {
return useReviewDraftStore((state) => state.drafts[key]?.length ?? 0);
}
export function useActiveReviewDraftMode(input: { scopeKey: string }): ReviewDraftMode | null {
return useReviewDraftStore((state) => state.activeModesByScope[input.scopeKey] ?? null);
export function useResolvedDiffMode(input: {
scopeKey: string;
hasUncommittedChanges: boolean;
}): ReviewDraftMode {
return useReviewDraftStore((state) =>
resolveDiffMode({
override: state.diffModeOverrides[input.scopeKey],
hasUncommittedChanges: input.hasUncommittedChanges,
}),
);
}
export function useReviewAttachmentSnapshot(input: {

View File

@@ -168,7 +168,7 @@ function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftCommen
describe("useInlineReviewController", () => {
beforeEach(() => {
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
});
afterEach(() => {

View File

@@ -38,6 +38,8 @@ import {
} from "@/desktop/daemon/desktop-daemon-transport";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/query/query-client";
export type HostRuntimeConnectionStatus = "idle" | "connecting" | "online" | "offline" | "error";
export type HostRegistryStatus = "loading" | "ready";
@@ -1767,6 +1769,10 @@ export class HostRuntimeStore {
snapshot.connectionStatus === "online" && previousStatus !== "online";
if (didTransitionOnline) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
// queries refetch now and evicted ones on their next mount.
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
}
// Runtime owns directory bootstrap policy, including reconnect and delayed

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native";
import ReanimatedAnimated from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id";
@@ -33,6 +34,7 @@ import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
@@ -1184,6 +1186,15 @@ export function NewWorkspaceScreen({
[isCompact, insets.bottom],
);
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
mode: "translate",
});
const centeredStyle = useMemo(
() => [styles.centered, composerKeyboardStyle],
[composerKeyboardStyle],
);
const agentControlsWithDisabled = useMemo(
() =>
composerState
@@ -1327,7 +1338,7 @@ export function NewWorkspaceScreen({
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={contentStyle}>
<TitlebarDragRegion />
<View style={styles.centered}>
<ReanimatedAnimated.View style={centeredStyle}>
<View style={styles.composerTitleContainer}>
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
@@ -1355,7 +1366,7 @@ export function NewWorkspaceScreen({
footer={composerFooter}
/>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</View>
</ReanimatedAnimated.View>
</View>
</View>
</FileDropZone>

View File

@@ -20,12 +20,15 @@ import {
getTerminalProfileIcon,
resolveTerminalProfiles,
} from "@getpaseo/protocol/terminal-profiles";
import { AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
ProfileDraft,
TerminalProfileEditModal,
} from "@/screens/settings/terminal-profile-edit-modal";
import { startDesktopDaemon, stopDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
@@ -1109,127 +1112,8 @@ function parseArgsString(raw: string): string[] | undefined {
.filter(Boolean);
}
interface ProfileDraft {
name: string;
command: string;
args: string;
}
const EMPTY_PROFILE_DRAFT: ProfileDraft = { name: "", command: "", args: "" };
interface TerminalProfileEditModalProps {
visible: boolean;
title: string;
initialDraft: ProfileDraft;
onClose: () => void;
onSave: (draft: ProfileDraft) => void;
}
function TerminalProfileEditModal({
visible,
title,
initialDraft,
onClose,
onSave,
}: TerminalProfileEditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(initialDraft.name);
const [command, setCommand] = useState(initialDraft.command);
const [args, setArgs] = useState(initialDraft.args);
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
useEffect(() => {
if (!visible) return;
setName(initialDraft.name);
setCommand(initialDraft.command);
setArgs(initialDraft.args);
}, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
const handleSave = useCallback(() => {
onSave({ name, command, args });
}, [onSave, name, command, args]);
const canSave = name.trim().length > 0 && command.trim().length > 0;
return (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={onClose}
testID="terminal-profile-edit-modal"
desktopMaxWidth={480}
>
<View style={profileModalStyles.body}>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.nameLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.name}
resetKey={visible ? "open" : "closed"}
onChangeText={setName}
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-name-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.commandLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.command}
resetKey={visible ? "open" : "closed"}
onChangeText={setCommand}
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-command-input"
/>
</View>
<View style={profileModalStyles.fieldGroup}>
<Text style={profileModalStyles.fieldLabel}>
{t("settings.host.terminalProfiles.argsLabel")}
</Text>
<AdaptiveTextInput
initialValue={initialDraft.args}
resetKey={visible ? "open" : "closed"}
onChangeText={setArgs}
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
testID="terminal-profile-args-input"
/>
<Text style={profileModalStyles.fieldHint}>
{t("settings.host.terminalProfiles.argsHint")}
</Text>
</View>
<View style={profileModalStyles.actions}>
<Button
variant="secondary"
size="sm"
style={profileModalStyles.actionButton}
onPress={onClose}
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
size="sm"
style={profileModalStyles.actionButton}
onPress={handleSave}
disabled={!canSave}
testID="terminal-profile-save-button"
>
{t("settings.host.terminalProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
interface TerminalProfileRowProps {
profile: TerminalProfile;
isFirst: boolean;
@@ -1338,7 +1222,6 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
draft: ProfileDraft;
} | null>(null);
const [isAdding, setIsAdding] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const profiles = useMemo(
() => (config ? resolveTerminalProfiles(config.terminalProfiles) : null),
@@ -1347,38 +1230,28 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
const saveProfiles = useCallback(
async (next: TerminalProfile[]) => {
setIsSaving(true);
try {
await patchConfig({ terminalProfiles: next });
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
} finally {
setIsSaving(false);
}
await patchConfig({ terminalProfiles: next });
},
[patchConfig, t],
[patchConfig],
);
const handleAddOpen = useCallback(() => setIsAdding(true), []);
const handleAddClose = useCallback(() => setIsAdding(false), []);
const handleAddSave = useCallback(
(draft: ProfileDraft) => {
async (draft: ProfileDraft) => {
const current = profiles ? [...profiles] : [];
const next: TerminalProfile[] = [
...current,
{
id: generateProfileId(),
name: draft.name.trim(),
command: draft.command.trim(),
name: draft.name,
command: draft.command,
args: parseArgsString(draft.args),
},
];
await saveProfiles(next);
setIsAdding(false);
void saveProfiles(next);
},
[profiles, saveProfiles],
);
@@ -1402,20 +1275,20 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
const handleEditClose = useCallback(() => setEditingProfile(null), []);
const handleEditSave = useCallback(
(draft: ProfileDraft) => {
async (draft: ProfileDraft) => {
if (!editingProfile || !profiles) return;
const next: TerminalProfile[] = profiles.map((p) =>
p.id === editingProfile.id
? {
...p,
name: draft.name.trim(),
command: draft.command.trim(),
name: draft.name,
command: draft.command,
args: parseArgsString(draft.args),
}
: p,
);
await saveProfiles(next);
setEditingProfile(null);
void saveProfiles(next);
},
[editingProfile, profiles, saveProfiles],
);
@@ -1432,9 +1305,16 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
confirmLabel: t("settings.host.terminalProfiles.remove"),
cancelLabel: t("common.actions.cancel"),
destructive: true,
}).then((confirmed) => {
}).then(async (confirmed) => {
if (!confirmed || !profiles) return;
void saveProfiles(profiles.filter((p) => p.id !== id));
try {
await saveProfiles(profiles.filter((p) => p.id !== id));
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
return;
});
},
@@ -1442,29 +1322,43 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
);
const handleMoveUp = useCallback(
(id: string) => {
async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index <= 0) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index - 1, 0, item);
void saveProfiles(next);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles],
[profiles, saveProfiles, t],
);
const handleMoveDown = useCallback(
(id: string) => {
async (id: string) => {
if (!profiles) return;
const index = profiles.findIndex((p) => p.id === id);
if (index < 0 || index >= profiles.length - 1) return;
const next = [...profiles];
const [item] = next.splice(index, 1);
next.splice(index + 1, 0, item);
void saveProfiles(next);
try {
await saveProfiles(next);
} catch (error) {
Alert.alert(
t("common.errors.unableToSave"),
error instanceof Error ? error.message : String(error),
);
}
},
[profiles, saveProfiles],
[profiles, saveProfiles, t],
);
const addButton = useMemo(
@@ -1474,11 +1368,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
size="sm"
leftIcon={addProfileIcon}
onPress={handleAddOpen}
disabled={isSaving || !isConnected || !profiles}
disabled={!isConnected || !profiles}
testID="terminal-profiles-add-button"
/>
),
[handleAddOpen, isSaving, isConnected, profiles],
[handleAddOpen, isConnected, profiles],
);
if (!isConnected) {
@@ -1530,6 +1424,7 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) {
initialDraft={EMPTY_PROFILE_DRAFT}
onClose={handleAddClose}
onSave={handleAddSave}
testID="terminal-profile-edit-modal"
/>
{editingProfile ? (
@@ -1585,34 +1480,6 @@ const terminalProfileStyles = StyleSheet.create((theme) => ({
},
}));
const profileModalStyles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
fieldGroup: {
gap: theme.spacing[1.5],
},
fieldLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
},
fieldHint: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
},
}));
const styles = StyleSheet.create((theme) => ({
identityEditButton: {
padding: theme.spacing[1],

View File

@@ -0,0 +1,376 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TerminalProfileEditModal, type ProfileDraft } from "./terminal-profile-edit-modal";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 2: 8, 3: 12, 4: 16 },
fontSize: { sm: 13, base: 15, xs: 11 },
fontWeight: { medium: 500 },
borderRadius: { lg: 8 },
colors: {
surface2: "#222",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#555",
palette: { red: { 300: "#f87171" } },
},
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
useUnistyles: () => ({ theme }),
}));
vi.mock("@/constants/platform", () => ({
isWeb: true,
isNative: false,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const inputPropsByTestID = vi.hoisted(() => ({
map: new Map<string, { onChangeText?: (next: string) => void; onSubmitEditing?: () => void }>(),
}));
vi.mock("@/components/adaptive-modal-sheet", async () => {
const ReactModule = await import("react");
const AdaptiveModalSheet = ({
visible,
title,
children,
onClose,
testID,
}: {
visible: boolean;
title: string;
children: React.ReactNode;
onClose: () => void;
testID?: string;
}) => {
if (!visible) return null;
return ReactModule.createElement(
"div",
{ "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title },
ReactModule.createElement(
"button",
{
type: "button",
"data-testid": "adaptive-modal-sheet-close",
onClick: onClose,
},
"Close",
),
children,
);
};
const AdaptiveTextInput = ReactModule.forwardRef<HTMLInputElement, Record<string, unknown>>(
(props, ref) => {
const p = props as {
initialValue?: string;
defaultValue?: string;
editable?: boolean;
testID?: string;
onChangeText?: (next: string) => void;
onSubmitEditing?: () => void;
};
if (p.testID) {
inputPropsByTestID.map.set(p.testID, {
onChangeText: p.onChangeText,
onSubmitEditing: p.onSubmitEditing,
});
}
return ReactModule.createElement("input", {
ref,
defaultValue: p.initialValue ?? p.defaultValue ?? "",
disabled: p.editable === false,
"data-testid": p.testID,
onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value),
onKeyDown: (e: { key: string; preventDefault: () => void }) => {
if (e.key === "Enter") {
e.preventDefault();
p.onSubmitEditing?.();
}
},
});
},
);
return { AdaptiveModalSheet, AdaptiveTextInput };
});
vi.mock("@/components/ui/button", async () => {
const ReactModule = await import("react");
return {
Button: ({
children,
onPress,
disabled,
testID,
}: {
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
testID?: string;
}) =>
ReactModule.createElement(
"button",
{
type: "button",
"data-testid": testID,
disabled: disabled || undefined,
onClick: () => !disabled && onPress?.(),
},
children,
),
};
});
let root: Root | null = null;
let container: HTMLElement | null = null;
beforeEach(() => {
const dom = new JSDOM("<!doctype html><html><body></body></html>");
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("window", dom.window);
vi.stubGlobal("document", dom.window.document);
vi.stubGlobal("HTMLElement", dom.window.HTMLElement);
vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement);
vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent);
vi.stubGlobal("Node", dom.window.Node);
vi.stubGlobal("navigator", dom.window.navigator);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(() => {});
});
afterEach(() => {
if (root) {
act(() => {
root?.unmount();
});
}
root = null;
container = null;
vi.unstubAllGlobals();
vi.useRealTimers();
});
interface RenderOptions {
visible?: boolean;
title?: string;
initialDraft?: ProfileDraft;
onClose?: () => void;
onSave?: (draft: ProfileDraft) => Promise<void>;
}
function renderModal(options: RenderOptions = {}): void {
const {
visible = true,
title = "Add terminal profile",
initialDraft = { name: "", command: "", args: "" },
onClose = vi.fn(),
onSave = vi.fn().mockResolvedValue(undefined),
} = options;
act(() => {
root?.render(
<TerminalProfileEditModal
visible={visible}
title={title}
initialDraft={initialDraft}
onClose={onClose}
onSave={onSave}
testID="terminal-profile-edit-modal"
/>,
);
});
}
function queryInput(testID: string): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>(`[data-testid="${testID}"]`);
}
function querySubmit(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="terminal-profile-save-button"]');
}
function queryCancel(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>(
'[data-testid="terminal-profile-cancel-button"]',
);
}
function queryError(testID: string): HTMLElement | null {
return document.querySelector<HTMLElement>(`[data-testid="${testID}"]`);
}
function click(element: Element | null): void {
if (!element) throw new Error("Cannot click null element");
act(() => {
element.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
});
}
function typeInto(testID: string, value: string): void {
act(() => {
const input = queryInput(testID);
if (!input) throw new Error(`Input ${testID} not found`);
input.value = value;
inputPropsByTestID.map.get(testID)?.onChangeText?.(value);
});
}
function pressEnter(testID: string): void {
act(() => {
inputPropsByTestID.map.get(testID)?.onSubmitEditing?.();
});
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("TerminalProfileEditModal", () => {
it("renders with the initial draft pre-filled", () => {
renderModal({
initialDraft: { name: "Claude Code", command: "claude", args: "--skip" },
});
expect(queryInput("terminal-profile-name-input")?.value).toBe("Claude Code");
expect(queryInput("terminal-profile-command-input")?.value).toBe("claude");
expect(queryInput("terminal-profile-args-input")?.value).toBe("--skip");
});
it("calls onSave with trimmed values and closes on success", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const onClose = vi.fn();
renderModal({ onSave, onClose });
typeInto("terminal-profile-name-input", " Codex ");
typeInto("terminal-profile-command-input", " codex ");
typeInto("terminal-profile-args-input", " --flag ");
click(querySubmit());
await flush();
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({
name: "Codex",
command: "codex",
args: " --flag ",
});
expect(onClose).toHaveBeenCalledTimes(1);
});
it("shows inline validation errors for empty required fields", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
renderModal({ onSave });
click(querySubmit());
await flush();
expect(onSave).not.toHaveBeenCalled();
expect(queryError("terminal-profile-name-field-error")?.textContent).toContain(
"settings.host.terminalProfiles.nameRequired",
);
expect(queryError("terminal-profile-command-field-error")?.textContent).toContain(
"settings.host.terminalProfiles.commandRequired",
);
});
it("clears validation errors when the user types", async () => {
renderModal();
click(querySubmit());
await flush();
expect(queryError("terminal-profile-name-field-error")).not.toBeNull();
typeInto("terminal-profile-name-input", "Codex");
await flush();
expect(queryError("terminal-profile-name-field-error")).toBeNull();
});
it("disables save and cancel while onSave is pending", async () => {
let resolve: () => void = () => {};
const onSave = vi.fn(
() =>
new Promise<void>((r) => {
resolve = r;
}),
);
renderModal({ onSave });
typeInto("terminal-profile-name-input", "Codex");
typeInto("terminal-profile-command-input", "codex");
click(querySubmit());
await flush();
expect(onSave).toHaveBeenCalledTimes(1);
expect(querySubmit()?.disabled).toBe(true);
expect(queryCancel()?.disabled).toBe(true);
await act(async () => {
resolve();
await Promise.resolve();
});
});
it("keeps the modal open with an error when onSave rejects", async () => {
const onSave = vi.fn().mockRejectedValue(new Error("Server said no"));
const onClose = vi.fn();
renderModal({ onSave, onClose });
typeInto("terminal-profile-name-input", "Codex");
typeInto("terminal-profile-command-input", "codex");
click(querySubmit());
await flush();
expect(onClose).not.toHaveBeenCalled();
expect(
document.querySelector<HTMLElement>('[data-testid="terminal-profile-submit-error"]')
?.textContent,
).toContain("Server said no");
expect(querySubmit()?.disabled).toBe(false);
});
it("calls onClose when the cancel button is clicked", () => {
const onClose = vi.fn();
const onSave = vi.fn().mockResolvedValue(undefined);
renderModal({ onClose, onSave });
click(queryCancel());
expect(onClose).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
it("submits the form when Enter is pressed in the args field", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
renderModal({ onSave });
typeInto("terminal-profile-name-input", "Codex");
typeInto("terminal-profile-command-input", "codex");
pressEnter("terminal-profile-args-input");
await flush();
expect(onSave).toHaveBeenCalledTimes(1);
expect(onSave).toHaveBeenCalledWith({
name: "Codex",
command: "codex",
args: "",
});
});
});

View File

@@ -0,0 +1,259 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Text, TextInput, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { FormField, FormTextInput } from "@/components/ui/form-field";
export interface ProfileDraft {
name: string;
command: string;
args: string;
}
interface FieldErrors {
name?: string;
command?: string;
}
interface TerminalProfileEditModalProps {
visible: boolean;
title: string;
initialDraft: ProfileDraft;
onClose: () => void;
onSave: (draft: ProfileDraft) => Promise<void>;
testID?: string;
}
export function TerminalProfileEditModal({
visible,
title,
initialDraft,
onClose,
onSave,
testID,
}: TerminalProfileEditModalProps) {
const { t } = useTranslation();
const [name, setName] = useState(initialDraft.name);
const [command, setCommand] = useState(initialDraft.command);
const [args, setArgs] = useState(initialDraft.args);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [submitError, setSubmitError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const nameInputRef = useRef<TextInput>(null);
const commandInputRef = useRef<TextInput>(null);
const argsInputRef = useRef<TextInput>(null);
const handleNameChange = useCallback((value: string) => {
setName(value);
setFieldErrors((current) => ({ ...current, name: undefined }));
}, []);
const handleCommandChange = useCallback((value: string) => {
setCommand(value);
setFieldErrors((current) => ({ ...current, command: undefined }));
}, []);
const handleArgsChange = useCallback((value: string) => {
setArgs(value);
}, []);
const sheetHeader = useMemo<SheetHeader>(() => ({ title }), [title]);
useEffect(() => {
if (!visible) {
setIsPending(false);
return;
}
setName(initialDraft.name);
setCommand(initialDraft.command);
setArgs(initialDraft.args);
setFieldErrors({});
setSubmitError(null);
setIsPending(false);
const timeout = setTimeout(() => {
nameInputRef.current?.focus();
}, 50);
return () => clearTimeout(timeout);
}, [visible, initialDraft.name, initialDraft.command, initialDraft.args]);
const validate = useCallback((): boolean => {
const errors: FieldErrors = {};
if (name.trim().length === 0) {
errors.name = t("settings.host.terminalProfiles.nameRequired");
}
if (command.trim().length === 0) {
errors.command = t("settings.host.terminalProfiles.commandRequired");
}
setFieldErrors(errors);
return Object.keys(errors).length === 0;
}, [command, name, t]);
const handleSave = useCallback(async () => {
if (isPending) return;
setSubmitError(null);
if (!validate()) {
return;
}
setIsPending(true);
try {
await onSave({ name: name.trim(), command: command.trim(), args });
onClose();
} catch (error) {
setSubmitError(error instanceof Error ? error.message : t("common.errors.unableToSave"));
} finally {
setIsPending(false);
}
}, [args, command, isPending, name, onClose, onSave, t, validate]);
const handleCancel = useCallback(() => {
if (isPending) return;
onClose();
}, [isPending, onClose]);
const handleNameSubmit = useCallback(() => {
commandInputRef.current?.focus();
}, []);
const handleCommandSubmit = useCallback(() => {
argsInputRef.current?.focus();
}, []);
const handleArgsSubmit = useCallback(() => {
void handleSave();
}, [handleSave]);
const nameError = fieldErrors.name;
const commandError = fieldErrors.command;
return (
<AdaptiveModalSheet
visible={visible}
header={sheetHeader}
onClose={handleCancel}
testID={testID}
desktopMaxWidth={480}
>
<View style={styles.body}>
<FormField
label={t("settings.host.terminalProfiles.nameLabel")}
error={nameError}
testID="terminal-profile-name-field"
>
<FormTextInput
ref={nameInputRef}
initialValue={initialDraft.name}
resetKey={visible ? "open" : "closed"}
onChangeText={handleNameChange}
placeholder={t("settings.host.terminalProfiles.namePlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="next"
onSubmitEditing={handleNameSubmit}
nativeID="terminal-profile-name-input"
accessibilityLabel={t("settings.host.terminalProfiles.nameLabel")}
testID="terminal-profile-name-input"
/>
</FormField>
<FormField
label={t("settings.host.terminalProfiles.commandLabel")}
error={commandError}
testID="terminal-profile-command-field"
>
<FormTextInput
ref={commandInputRef}
initialValue={initialDraft.command}
resetKey={visible ? "open" : "closed"}
onChangeText={handleCommandChange}
placeholder={t("settings.host.terminalProfiles.commandPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="next"
onSubmitEditing={handleCommandSubmit}
nativeID="terminal-profile-command-input"
accessibilityLabel={t("settings.host.terminalProfiles.commandLabel")}
testID="terminal-profile-command-input"
/>
</FormField>
<FormField
label={t("settings.host.terminalProfiles.argsLabel")}
hint={t("settings.host.terminalProfiles.argsHint")}
testID="terminal-profile-args-field"
>
<FormTextInput
ref={argsInputRef}
initialValue={initialDraft.args}
resetKey={visible ? "open" : "closed"}
onChangeText={handleArgsChange}
placeholder={t("settings.host.terminalProfiles.argsPlaceholder")}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
returnKeyType="done"
onSubmitEditing={handleArgsSubmit}
nativeID="terminal-profile-args-input"
accessibilityLabel={t("settings.host.terminalProfiles.argsLabel")}
testID="terminal-profile-args-input"
/>
</FormField>
{submitError ? (
<Text style={styles.submitError} testID="terminal-profile-submit-error">
{submitError}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
testID="terminal-profile-cancel-button"
>
{t("common.actions.cancel")}
</Button>
<Button
variant="default"
style={styles.actionButton}
onPress={handleSave}
disabled={isPending}
testID="terminal-profile-save-button"
>
{isPending
? t("settings.host.terminalProfiles.saving")
: t("settings.host.terminalProfiles.save")}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
const styles = StyleSheet.create((theme) => ({
body: {
gap: theme.spacing[4],
paddingBottom: theme.spacing[2],
},
actions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
actionButton: {
flex: 1,
},
submitError: {
color: theme.colors.palette.red[300],
fontSize: theme.fontSize.sm,
},
}));

View File

@@ -60,7 +60,7 @@ import {
FloatingPanelPortalHostNameProvider,
} from "@/components/ui/floating-panel-portal";
import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { SplitContainer } from "@/components/split-container";
import { MountedTabActiveContext, SplitContainer } from "@/components/split-container";
import { SourceControlPanelIcon } from "@/components/icons/source-control-panel-icon";
import { WorkspaceGitActions } from "@/git/workspace-actions";
import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-in-editor-button";
@@ -102,6 +102,7 @@ import { useWorkspace } from "@/stores/session-store-hooks";
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
import type { CheckoutStatusPayload } from "@/git/use-status-query";
import { checkoutStatusQueryKey } from "@/git/query-keys";
import { fetchCheckoutStatus } from "@/git/checkout-status-cache";
import { confirmDialog } from "@/utils/confirm-dialog";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
@@ -855,13 +856,15 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
return (
<RenderProfile id={`MobileMountedTabSlot:${tabDescriptor.kind}:${tabDescriptor.tabId}`}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
<MountedTabActiveContext value={isVisible}>
<View style={slotStyle} pointerEvents={isVisible ? "auto" : "none"}>
<WorkspacePaneContent
content={content}
isWorkspaceFocused={isWorkspaceFocused}
isPaneFocused={isPaneFocused}
/>
</View>
</MountedTabActiveContext>
</RenderProfile>
);
});
@@ -1103,31 +1106,6 @@ function WorkspaceHeaderMenu({
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={menuNewTerminalIcon}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-edit-terminal-profiles"
onSelect={handleEditProfiles}
>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
@@ -1174,6 +1152,31 @@ function WorkspaceHeaderMenu({
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={menuNewTerminalIcon}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
profile={profile}
disabled={createTerminalDisabled}
onCreateTerminalWithProfile={onCreateTerminalWithProfile}
/>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
testID="workspace-header-edit-terminal-profiles"
onSelect={handleEditProfiles}
>
{t("workspace.tabs.actions.editTerminalProfiles")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -1713,10 +1716,16 @@ function useWorkspaceCheckoutStatus(input: {
if (!input.client || !input.workspaceDirectory) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
return await input.client.getCheckoutStatus(input.workspaceDirectory);
return await fetchCheckoutStatus({
client: input.client,
serverId: input.normalizedServerId,
cwd: input.workspaceDirectory,
});
},
staleTime: Infinity,
refetchOnMount: false,
// Refetch on mount only after explicit invalidation (e.g. reconnect) — see
// useCheckoutStatusQuery for the rationale.
refetchOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
});

View File

@@ -95,6 +95,24 @@ describe("collectImageFilesFromClipboardData", () => {
expect(files).toEqual([imagePng]);
});
it("ignores SVG clipboard files", () => {
const svgFile = new File(["<svg />"], "logo.svg", {
type: "image/svg+xml",
});
const files = collectImageFilesFromClipboardData({
items: [
createClipboardItem({
kind: "file",
type: "image/svg+xml",
file: svgFile,
}),
],
});
expect(files).toEqual([]);
});
it("returns an empty array when clipboard data is missing", () => {
expect(collectImageFilesFromClipboardData(undefined)).toEqual([]);
});

View File

@@ -1,5 +1,6 @@
import type { AttachmentMetadata } from "@/attachments/types";
import { persistAttachmentFromBlob } from "@/attachments/service";
import { isRasterImageMimeType } from "@/attachments/file-types";
export interface ClipboardItemLike {
kind?: string;
@@ -25,7 +26,7 @@ export function collectImageFilesFromClipboardData(
if (item?.kind !== "file") {
continue;
}
if (!item.type?.startsWith("image/")) {
if (!isRasterImageMimeType(item.type)) {
continue;
}
const file = item.getAsFile?.();

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.94",
"version": "0.1.95",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.94",
"@getpaseo/protocol": "0.1.94",
"@getpaseo/server": "0.1.94",
"@getpaseo/client": "0.1.95",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/server": "0.1.95",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.94",
"version": "0.1.95",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.94",
"@getpaseo/relay": "0.1.94",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/relay": "0.1.95",
"zod": "^3.23.8"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.94",
"version": "0.1.95",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.94",
"version": "0.1.95",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.94",
"version": "0.1.95",
"files": [
"dist",
"!dist/**/*.map"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.94",
"version": "0.1.95",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",

View File

@@ -2622,6 +2622,8 @@ export const OpenProjectResponseMessageSchema = z.object({
requestId: z.string(),
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
// Unknown codes from newer daemons degrade to null; clients fall back to `error`.
errorCode: z.enum(["directory_not_found"]).nullish().catch(null),
}),
});
@@ -4064,6 +4066,7 @@ export type DictationStreamFinishMessage = z.infer<typeof DictationStreamFinishM
export type DictationStreamCancelMessage = z.infer<typeof DictationStreamCancelMessageSchema>;
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
export type AgentAttachment = z.infer<typeof AgentAttachmentSchema>;
export type UploadedFileAttachment = z.infer<typeof UploadedFileAttachmentSchema>;
export type FirstAgentContext = z.infer<typeof FirstAgentContextSchema>;
export type ReviewAttachment = z.infer<typeof ReviewAttachmentSchema>;
export type ListProviderModelsRequestMessage = z.infer<

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.94",
"version": "0.1.95",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.94",
"version": "0.1.95",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -59,10 +59,10 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/client": "0.1.94",
"@getpaseo/highlight": "0.1.94",
"@getpaseo/protocol": "0.1.94",
"@getpaseo/relay": "0.1.94",
"@getpaseo/client": "0.1.95",
"@getpaseo/highlight": "0.1.95",
"@getpaseo/protocol": "0.1.95",
"@getpaseo/relay": "0.1.95",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",

View File

@@ -0,0 +1,93 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, expect, test } from "vitest";
import { DaemonClient, type DaemonEvent } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
const cleanupPaths = new Set<string>();
const cleanupDaemons = new Set<TestPaseoDaemon>();
const cleanupClients = new Set<DaemonClient>();
let previousSupervised: string | undefined;
beforeEach(() => {
previousSupervised = process.env.PASEO_SUPERVISED;
process.env.PASEO_SUPERVISED = "0";
});
afterEach(async () => {
restoreSupervisedEnv();
await Promise.all(Array.from(cleanupClients, (client) => client.close().catch(() => undefined)));
cleanupClients.clear();
await Promise.all(Array.from(cleanupDaemons, (daemon) => daemon.close().catch(() => undefined)));
cleanupDaemons.clear();
await Promise.all(
Array.from(cleanupPaths, (target) => rm(target, { recursive: true, force: true })),
);
cleanupPaths.clear();
});
function restoreSupervisedEnv(): void {
if (previousSupervised === undefined) {
delete process.env.PASEO_SUPERVISED;
return;
}
process.env.PASEO_SUPERVISED = previousSupervised;
}
// Repro for the "project flashes in the sidebar then disappears" report.
// The project picker submits the typed query verbatim, so open_project can
// receive a path that does not exist on the daemon's disk. The daemon must not
// answer with a success + workspace upsert that it immediately retracts.
test("openProject on a nonexistent directory does not broadcast an upsert that is immediately removed", async () => {
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "missing-dir-agents" } });
const workspaceEvents: Array<{ kind: "upsert" | "remove"; workspaceId: string }> = [];
client.subscribe((event: DaemonEvent) => {
if (event.type !== "workspace_update") {
return;
}
workspaceEvents.push({ kind: event.payload.kind, workspaceId: event.workspaceId });
});
await client.fetchWorkspaces({ subscribe: { subscriptionId: "missing-dir-workspaces" } });
const tempParent = await mkdtemp(path.join(os.tmpdir(), "paseo-open-project-"));
cleanupPaths.add(tempParent);
const missingPath = path.join(tempParent, "this-directory-does-not-exist");
const response = await client.openProject(missingPath);
expect(response.workspace).toBeNull();
expect(response.error).toBe(`Directory not found: ${missingPath}`);
expect(response.errorCode).toBe("directory_not_found");
expect(workspaceEvents).toEqual([]);
}, 30000);
test("openProject expands tilde before creating the workspace", async () => {
const daemon = await createTestPaseoDaemon();
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "tilde-project-agents" } });
await client.fetchWorkspaces({ subscribe: { subscriptionId: "tilde-project-workspaces" } });
const home = process.env.HOME || os.homedir();
const workspacePath = await mkdtemp(path.join(home, ".paseo-open-project-"));
cleanupPaths.add(workspacePath);
const queryPath = `~/${path.relative(home, workspacePath)}`;
const response = await client.openProject(queryPath);
expect(response.error).toBeNull();
expect(response.errorCode).toBeUndefined();
expect(response.workspace?.workspaceDirectory).toBe(workspacePath);
}, 30000);

View File

@@ -2,6 +2,7 @@ import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
@@ -533,6 +534,17 @@ interface AudioBufferState {
totalPCMBytes: number;
}
export interface SessionFileSystem {
isDirectory(path: string): Promise<boolean>;
}
const nodeSessionFileSystem: SessionFileSystem = {
async isDirectory(path) {
const stats = await stat(path).catch(() => null);
return stats?.isDirectory() ?? false;
},
};
// Stub types for features under development (modules not yet available)
type AgentMcpTransportFactory = () => Promise<unknown>;
@@ -564,6 +576,7 @@ export interface SessionOptions {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
filesystem?: SessionFileSystem;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
@@ -719,6 +732,19 @@ interface AgentTimelineProjectionSelection {
hasNewer: boolean;
}
type RegistryTransition = "created" | "unarchived" | "existing";
interface ArchivedRecordSnapshot {
archivedAt?: string | null;
}
function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition {
if (!record) {
return "created";
}
return record.archivedAt ? "unarchived" : "existing";
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -770,6 +796,7 @@ export class Session {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly filesystem: SessionFileSystem;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
@@ -844,6 +871,7 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
filesystem,
chatService,
scheduleService,
loopService,
@@ -893,6 +921,7 @@ export class Session {
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.filesystem = filesystem ?? nodeSessionFileSystem;
this.chatService = chatService;
this.scheduleService = scheduleService;
this.loopService = loopService;
@@ -6755,9 +6784,10 @@ export class Session {
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const archiveTimestamp = archivedAt ?? new Date().toISOString();
const existingWorkspace = await archivePersistedWorkspaceRecord({
workspaceId,
archivedAt,
archivedAt: archiveTimestamp,
workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry,
});
@@ -6766,6 +6796,22 @@ export class Session {
return;
}
if (!existingWorkspace.archivedAt) {
const activeSiblings = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
this.sessionLogger.info(
{
workspaceId,
workspaceCwd: existingWorkspace.cwd,
projectId: existingWorkspace.projectId,
projectArchived: activeSiblings.length === 0,
archivedAt: archiveTimestamp,
},
"Workspace archived",
);
}
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
@@ -7156,11 +7202,58 @@ export class Session {
private async handleOpenProjectRequest(
request: Extract<SessionInboundMessage, { type: "open_project_request" }>,
): Promise<void> {
const requestedCwd = request.cwd;
const cwd = expandTilde(requestedCwd);
const directoryExists = await this.filesystem.isDirectory(cwd).catch(() => false);
if (!directoryExists) {
this.sessionLogger.info(
{ requestedCwd, resolvedCwd: cwd, reason: "directory_not_found" },
"Open project rejected",
);
this.emit({
type: "open_project_response",
payload: {
requestId: request.requestId,
workspace: null,
error: `Directory not found: ${cwd}`,
errorCode: "directory_not_found",
},
});
return;
}
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
const projectsBefore = new Map<string, PersistedProjectRecord>();
for (const project of await this.projectRegistry.list()) {
projectsBefore.set(project.projectId, project);
}
const workspacesBefore = new Map<string, PersistedWorkspaceRecord>();
for (const workspaceRecord of await this.workspaceRegistry.list()) {
workspacesBefore.set(workspaceRecord.workspaceId, workspaceRecord);
}
const workspace = await this.findOrCreateWorkspaceForDirectory(cwd);
const project = await this.projectRegistry.get(workspace.projectId);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
const descriptor = await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
this.sessionLogger.info(
{
requestedCwd,
resolvedCwd: cwd,
workspaceCwd: workspace.cwd,
workspaceId: workspace.workspaceId,
workspaceKind: workspace.kind,
workspaceTransition: describeRegistryTransition(
workspacesBefore.get(workspace.workspaceId) ?? null,
),
projectId: workspace.projectId,
projectKind: project?.kind ?? null,
projectTransition: describeRegistryTransition(
projectsBefore.get(workspace.projectId) ?? null,
),
},
"Project opened",
);
this.emit({
type: "open_project_response",
payload: {
@@ -7183,7 +7276,7 @@ export class Session {
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to open project";
this.sessionLogger.error({ err: error, cwd: request.cwd }, "Failed to open project");
this.sessionLogger.error({ err: error, cwd }, "Failed to open project");
this.emit({
type: "open_project_response",
payload: {

View File

@@ -138,6 +138,7 @@ function createHarness(input: {
workspaces.delete(id);
},
}),
filesystem: { isDirectory: async () => true },
chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
loopService: createStub<SessionOptions["loopService"]>({}),

View File

@@ -512,6 +512,7 @@ function createSessionForWorkspaceTests(
archive: async () => {},
remove: async () => {},
},
filesystem: { isDirectory: async () => true },
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),

View File

@@ -73,6 +73,26 @@ function createTestLogger() {
return logger as unknown as pino.Logger;
}
interface CapturedLogRecord {
message: string;
payload: unknown;
}
function createCapturingLogger() {
const infoRecords: CapturedLogRecord[] = [];
const logger = {
child: () => logger,
trace: () => undefined,
debug: () => undefined,
info: (payload: unknown, message?: string) => {
infoRecords.push({ payload, message: message ?? "" });
},
warn: () => undefined,
error: () => undefined,
};
return { logger: logger as unknown as pino.Logger, infoRecords };
}
function createWorkspaceGitServiceStub(
metadataByCwd: Record<
string,
@@ -692,4 +712,80 @@ describe("WorkspaceReconciliationService", () => {
expect(onChanges).toHaveBeenCalledTimes(1);
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
});
test("logs reconciliation changes with affected paths and reasons", async () => {
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
const { logger, infoRecords } = createCapturingLogger();
projects.set(
"p1",
createPersistedProjectRecord({
projectId: "p1",
rootPath: "/tmp/does-not-exist-log-test",
kind: "non_git",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
workspaces.set(
"w1",
createPersistedWorkspaceRecord({
workspaceId: "w1",
projectId: "p1",
cwd: "/tmp/does-not-exist-log-test",
kind: "directory",
displayName: "ghost",
createdAt: timestamp,
updatedAt: timestamp,
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
});
await service.runOnce();
expect(infoRecords).toEqual([
{
message: "Workspace reconciliation applied changes",
payload: expect.objectContaining({
changeCount: 2,
changes: expect.arrayContaining([
{
kind: "workspace_archived",
workspaceId: "w1",
directory: "/tmp/does-not-exist-log-test",
reason: "directory_missing",
},
{
kind: "project_archived",
projectId: "p1",
directory: "/tmp/does-not-exist-log-test",
reason: "no_active_workspaces",
},
]),
durationMs: expect.any(Number),
}),
},
]);
});
test("does not log reconciliation when no changes are applied", async () => {
const { projectRegistry, workspaceRegistry } = createTestRegistries();
const { logger, infoRecords } = createCapturingLogger();
const service = new WorkspaceReconciliationService({
projectRegistry,
workspaceRegistry,
logger,
});
await service.runOnce();
expect(infoRecords).toEqual([]);
});
});

View File

@@ -109,13 +109,7 @@ export class WorkspaceReconciliationService {
if (this.running) return;
this.running = true;
try {
const result = await this.reconcile();
if (result.changesApplied.length > 0) {
this.logger.info(
{ changeCount: result.changesApplied.length, durationMs: result.durationMs },
"Reconciliation pass completed with changes",
);
}
await this.reconcile();
} catch (error) {
this.logger.error({ err: error }, "Reconciliation pass failed");
} finally {
@@ -201,7 +195,19 @@ export class WorkspaceReconciliationService {
this.onChanges(changes);
}
return { changesApplied: changes, durationMs: Date.now() - start };
const result = { changesApplied: changes, durationMs: Date.now() - start };
if (changes.length > 0) {
this.logger.info(
{
changeCount: changes.length,
durationMs: result.durationMs,
changes,
},
"Workspace reconciliation applied changes",
);
}
return result;
}
private async mergeDuplicateProjectsByRoot(

View File

@@ -303,6 +303,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
id: "PRR_approved",
state: "APPROVED",
body: "Looks good to me.",
bodyHTML: "<p>Looks good to me.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1",
submittedAt: "2026-04-02T13:52:14Z",
author: {
@@ -315,6 +316,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
id: "PRR_empty_commented",
state: "COMMENTED",
body: "",
bodyHTML: "",
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2",
submittedAt: "2026-04-02T13:50:00Z",
author: null,
@@ -327,6 +329,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
{
id: "IC_later",
body: "Can we add a regression test?",
bodyHTML: "<p>Can we add a regression test?</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
createdAt: "2026-04-02T13:55:00Z",
author: {
@@ -875,6 +878,7 @@ describe("GitHubService", () => {
});
expect(runner.calls[0]?.args[3]).toContain("reviews(first: 100)");
expect(runner.calls[0]?.args[3]).toContain("comments(first: 100)");
expect(runner.calls[0]?.args[3]).toContain("bodyHTML");
expect(runner.calls[0]?.args[3]).toContain("avatarUrl");
expect(runner.calls[0]?.args[3]).toContain("reviewThreads(first: 100)");
expect(timeline).toEqual({
@@ -920,6 +924,200 @@ describe("GitHubService", () => {
});
});
it("rewrites GitHub attachment image URLs in timeline comments", async () => {
const privateAttachmentUrl =
"https://private-user-images.githubusercontent.com/123/asset.png?jwt=abc&expires=123";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_attachment",
body: "Screenshot: ![bug](https://github.com/user-attachments/assets/raw-asset)",
bodyHTML: `<p>Screenshot: <img alt="bug" src="${privateAttachmentUrl.replaceAll("&", "&amp;")}" /></p>`,
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-4",
createdAt: "2026-04-02T13:56:00Z",
author: null,
},
],
pageInfo: { hasNextPage: false },
},
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 42,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(timeline.items).toHaveLength(1);
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "IC_attachment",
body: `Screenshot: ![bug](${privateAttachmentUrl})`,
});
});
it("rewrites GitHub attachment image URLs in review thread comments", async () => {
const privateAttachmentUrl =
"https://private-user-images.githubusercontent.com/123/thread.png?jwt=thread";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [],
pageInfo: { hasNextPage: false },
},
reviewThreads: {
nodes: [
{
id: "PRRT_attachment",
path: "packages/app/src/git/pull-request-panel/data.ts",
line: 24,
startLine: 20,
isResolved: false,
isOutdated: false,
comments: {
nodes: [
{
id: "PRRC_attachment",
body: '<img src="https://github.com/user-attachments/assets/thread-asset" alt="thread" />',
bodyHTML: `<p><img alt="thread" src="${privateAttachmentUrl}" /></p>`,
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r2",
createdAt: "2026-04-02T13:51:00Z",
author: null,
pullRequestReview: { id: "PRR_empty_commented" },
},
],
pageInfo: { hasNextPage: false },
},
},
],
pageInfo: { hasNextPage: false },
},
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 42,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(timeline.items).toHaveLength(1);
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "PRRC_attachment",
body: `<img src="${privateAttachmentUrl}" alt="thread" />`,
});
});
it("leaves external badge images unchanged", async () => {
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_badge",
body: "![build](https://img.shields.io/github/actions/workflow/status/getpaseo/paseo/ci.yml)",
bodyHTML:
'<p><img alt="build" src="https://camo.githubusercontent.com/badge-signature" /></p>',
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-5",
createdAt: "2026-04-02T13:57:00Z",
author: null,
},
],
pageInfo: { hasNextPage: false },
},
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 42,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "IC_badge",
body: "![build](https://img.shields.io/github/actions/workflow/status/getpaseo/paseo/ci.yml)",
});
});
it("leaves GitHub attachment image URLs unchanged when rendered images do not match", async () => {
const body = "![bug](https://github.com/user-attachments/assets/raw-asset)";
const runner = createRunner([
pullRequestTimelineJson({
reviews: {
nodes: [],
pageInfo: { hasNextPage: false },
},
comments: {
nodes: [
{
id: "IC_mismatch",
body,
bodyHTML: "<p>No rendered image.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-6",
createdAt: "2026-04-02T13:58:00Z",
author: null,
},
],
pageInfo: { hasNextPage: false },
},
}),
]);
const service = createGitHubService({
runner: runner.runner,
resolveGhPath: async () => "/usr/bin/gh",
now: () => 100,
});
const timeline = await service.getPullRequestTimeline({
cwd: "/repo",
prNumber: 42,
repoOwner: "parentOwner",
repoName: "parentRepo",
});
expect(timeline.items[0]).toMatchObject({
kind: "comment",
id: "IC_mismatch",
body,
});
});
it("maps inline review thread comments as chronological PR timeline comments with location", async () => {
const runner = createRunner([
pullRequestTimelineJson({
@@ -937,6 +1135,7 @@ describe("GitHubService", () => {
{
id: "PRRC_1",
body: "This should include line context.",
bodyHTML: "<p>This should include line context.</p>",
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
createdAt: "2026-04-02T13:51:00Z",
author: {

View File

@@ -227,6 +227,7 @@ const PullRequestTimelineReviewNodeSchema = z.object({
id: z.string().catch(""),
state: z.string().catch(""),
body: z.string().nullable().catch(null),
bodyHTML: z.string().nullable().catch(null),
url: z.string().catch(""),
submittedAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
@@ -235,6 +236,7 @@ const PullRequestTimelineReviewNodeSchema = z.object({
const PullRequestTimelineCommentNodeSchema = z.object({
id: z.string().catch(""),
body: z.string().nullable().catch(null),
bodyHTML: z.string().nullable().catch(null),
url: z.string().catch(""),
createdAt: z.string().nullable().catch(null),
author: TimelineAuthorSchema,
@@ -413,6 +415,7 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
id
state
body
bodyHTML
url
submittedAt
author {
@@ -429,6 +432,7 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
nodes {
id
body
bodyHTML
url
createdAt
author {
@@ -453,6 +457,7 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
nodes {
id
body
bodyHTML
url
createdAt
author {
@@ -2265,7 +2270,7 @@ function toPullRequestTimelineReviewItem(
author: review.author?.login ?? "unknown",
authorUrl: review.author?.url ?? null,
avatarUrl: review.author?.avatarUrl ?? null,
body: review.body ?? "",
body: normalizeGitHubTimelineBody(review.body ?? "", review.bodyHTML ?? ""),
createdAt: parseOptionalTime(review.submittedAt ?? null),
url: review.url,
reviewState,
@@ -2282,12 +2287,131 @@ function toPullRequestTimelineCommentItem(
author: comment.author?.login ?? "unknown",
authorUrl: comment.author?.url ?? null,
avatarUrl: comment.author?.avatarUrl ?? null,
body: comment.body ?? "",
body: normalizeGitHubTimelineBody(comment.body ?? "", comment.bodyHTML ?? ""),
createdAt: parseOptionalTime(comment.createdAt ?? null),
url: comment.url,
};
}
interface ImageSourceReference {
src: string;
start: number;
end: number;
}
const RAW_MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(\s*([^\s)]+)(?:\s+["'][^)]*["'])?\s*\)/g;
const HTML_IMAGE_RE = /<img\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1[^>]*>/gi;
const GITHUB_RENDERED_IMAGE_HOSTS = new Set([
"camo.githubusercontent.com",
"private-user-images.githubusercontent.com",
]);
function normalizeGitHubTimelineBody(body: string, bodyHTML: string): string {
const rawImages = extractRawImageSourceReferences(body);
if (rawImages.length === 0) {
return body;
}
const renderedSources = extractRenderedImageSources(bodyHTML);
if (renderedSources.length !== rawImages.length) {
return body;
}
let cursor = 0;
let normalized = "";
for (let index = 0; index < rawImages.length; index += 1) {
const rawImage = rawImages[index];
const renderedSrc = renderedSources[index];
if (
!rawImage ||
!renderedSrc ||
!isRawGitHubAttachmentSource(rawImage.src) ||
!isGitHubRenderedImageSource(renderedSrc)
) {
return body;
}
normalized += body.slice(cursor, rawImage.start);
normalized += renderedSrc;
cursor = rawImage.end;
}
normalized += body.slice(cursor);
return normalized;
}
function extractRawImageSourceReferences(source: string): ImageSourceReference[] {
const references = [
...extractHtmlImageSourceReferences(source),
...extractMarkdownImageSourceReferences(source),
];
return references.sort((left, right) => left.start - right.start);
}
function extractRenderedImageSources(source: string): string[] {
return extractHtmlImageSourceReferences(source).map((reference) => reference.src);
}
function extractHtmlImageSourceReferences(source: string): ImageSourceReference[] {
const references: ImageSourceReference[] = [];
HTML_IMAGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = HTML_IMAGE_RE.exec(source)) !== null) {
const src = decodeHtmlAttribute(match[2] ?? "");
if (!src) {
continue;
}
const rawAttributeSrc = match[2] ?? "";
const start = match.index + match[0].indexOf(rawAttributeSrc);
references.push({ src, start, end: start + rawAttributeSrc.length });
}
return references;
}
function extractMarkdownImageSourceReferences(source: string): ImageSourceReference[] {
const references: ImageSourceReference[] = [];
RAW_MARKDOWN_IMAGE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = RAW_MARKDOWN_IMAGE_RE.exec(source)) !== null) {
const src = match[1] ?? "";
if (!src) {
continue;
}
const start = match.index + match[0].indexOf(src);
references.push({ src, start, end: start + src.length });
}
return references;
}
function isRawGitHubAttachmentSource(src: string): boolean {
try {
const url = new URL(src);
return (
url.protocol === "https:" &&
url.hostname === "github.com" &&
url.pathname.startsWith("/user-attachments/assets/")
);
} catch {
return false;
}
}
function isGitHubRenderedImageSource(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "https:" && GITHUB_RENDERED_IMAGE_HOSTS.has(url.hostname);
} catch {
return false;
}
}
function decodeHtmlAttribute(value: string): string {
return value
.replaceAll("&amp;", "&")
.replaceAll("&quot;", '"')
.replaceAll("&#39;", "'")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">");
}
function toPullRequestTimelineReviewThreadItems(
thread: z.infer<typeof PullRequestReviewThreadNodeSchema>,
): PullRequestTimelineItem[] {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.94",
"version": "0.1.95",
"private": true,
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"@tanstack/react-router": "^1.166.4",
"@tanstack/react-start": "^1.166.4",
"framer-motion": "^12.35.2",
"github-slugger": "^2.0.0",
"lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",

View File

@@ -0,0 +1,38 @@
import { DocsMarkdown } from "~/components/docs-markdown";
import { SiteShell } from "~/components/site-shell";
import { getAlternativePage } from "~/data/alternative-pages";
import { pageMeta } from "~/meta";
export function alternativeRouteOptions(slug: string) {
const page = getAlternativePage(slug);
return {
head: () =>
pageMeta(
page?.title ?? "Alternative - Paseo",
page?.description ?? "",
`/alternatives/${slug}`,
),
component: function AlternativePageRoute() {
return <AlternativePageContent slug={slug} />;
},
};
}
function AlternativePageContent({ slug }: { slug: string }) {
const page = getAlternativePage(slug);
if (!page) {
return (
<SiteShell width="default">
<p className="text-muted-foreground">Page not found.</p>
</SiteShell>
);
}
return (
<SiteShell width="default">
<DocsMarkdown>{page.content}</DocsMarkdown>
</SiteShell>
);
}

View File

@@ -0,0 +1,34 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs";
interface DocsBreadcrumbsProps {
doc: Doc;
tree: DocsNavNode[];
}
export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) {
const groups = getDocBreadcrumbGroups(doc, tree);
return (
<nav aria-label="Breadcrumb" className="not-prose mb-6">
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<li>
<Link to="/docs" className="hover:text-foreground transition-colors">
Docs
</Link>
</li>
{groups.map((group) => (
<li key={group.label} className="flex items-center gap-2">
<ChevronRight size={14} className="text-border" />
<span>{group.label}</span>
</li>
))}
<li className="flex items-center gap-2">
<ChevronRight size={14} className="text-border" />
<span className="text-foreground">{doc.frontmatter.nav}</span>
</li>
</ol>
</nav>
);
}

View File

@@ -70,12 +70,14 @@ const docsMarkdownComponents: Components = {
export function DocsMarkdown({ children }: { children: string }) {
return (
<ReactMarkdown
remarkPlugins={docsRemarkPlugins}
rehypePlugins={docsRehypePlugins}
components={docsMarkdownComponents}
>
{children}
</ReactMarkdown>
<div className="docs-prose">
<ReactMarkdown
remarkPlugins={docsRemarkPlugins}
rehypePlugins={docsRehypePlugins}
components={docsMarkdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type DocsNavNode } from "~/docs";
interface DocsNavProps {
nodes: DocsNavNode[];
mobile?: boolean;
onNavigate?: () => void;
}
const ACTIVE_OPTIONS_EXACT = { exact: true };
function nodeContainsHref(node: DocsNavNode, href: string): boolean {
if (node.type === "page") return node.href === href;
return node.children.some((child) => nodeContainsHref(child, href));
}
function clsx(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
function PageLink({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "page" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
const location = useLocation();
const isActive = location.pathname === node.href;
return (
<Link
to={node.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={onNavigate}
className={clsx(
"block px-3 py-2 text-sm rounded-md transition-colors",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
isActive && (mobile ? "text-foreground" : "bg-muted text-foreground"),
)}
>
{node.label}
</Link>
);
}
function GroupNode({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "group" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
const location = useLocation();
const currentHref = location.pathname;
const containsActive = useMemo(() => nodeContainsHref(node, currentHref), [node, currentHref]);
const [isOpen, setIsOpen] = useState(containsActive);
const toggle = useCallback(() => setIsOpen((open) => !open), []);
useEffect(() => {
setIsOpen(containsActive);
}, [containsActive]);
return (
<div>
<button
type="button"
onClick={toggle}
className={clsx(
"w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-md transition-colors",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
containsActive && "text-foreground",
)}
>
<span>{node.label}</span>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{isOpen && (
<div className="ml-3 pl-3 border-l border-border space-y-0.5">
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
)}
</div>
);
}
function CategoryNode({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "category" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
return (
<div className={mobile ? "space-y-1" : "space-y-1 mt-6 first:mt-0"}>
<div className="px-3 py-2 text-xs font-medium text-foreground">{node.label}</div>
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
);
}
function NavTree({ nodes, mobile, onNavigate }: DocsNavProps) {
return (
<div className="space-y-0.5">
{nodes.map((node) => {
if (node.type === "category") {
return (
<CategoryNode
key={`category-${node.label}`}
node={node}
mobile={mobile}
onNavigate={onNavigate}
/>
);
}
if (node.type === "group") {
return (
<GroupNode
key={`group-${node.segment}`}
node={node}
mobile={mobile}
onNavigate={onNavigate}
/>
);
}
return (
<PageLink key={`page-${node.href}`} node={node} mobile={mobile} onNavigate={onNavigate} />
);
})}
</div>
);
}
export function DocsNav({ nodes, mobile, onNavigate }: DocsNavProps) {
return (
<div className={mobile ? undefined : "-ml-3"}>
<NavTree nodes={nodes} mobile={mobile} onNavigate={onNavigate} />
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
import { type DocHeading } from "~/docs";
interface DocsOutlineProps {
headings: DocHeading[];
}
function clsx(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
function OutlineLink({
heading,
active,
onActivate,
}: {
heading: DocHeading;
active: boolean;
onActivate: (id: string) => void;
}) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
window.history.replaceState(null, "", `#${heading.id}`);
onActivate(heading.id);
}
},
[heading.id, onActivate],
);
return (
<a
href={`#${heading.id}`}
onClick={handleClick}
className={clsx(
"block py-1 transition-colors border-l-2 -ml-px",
heading.depth === 2 && "pl-3",
heading.depth === 3 && "pl-6",
heading.depth >= 4 && "pl-9",
active
? "text-foreground border-primary"
: "text-muted-foreground hover:text-foreground border-transparent",
)}
>
{heading.text}
</a>
);
}
export function DocsOutline({ headings }: DocsOutlineProps) {
const [activeId, setActiveId] = useState<string | null>(() =>
headings.length > 0 ? headings[0].id : null,
);
useEffect(() => {
setActiveId(headings.length > 0 ? headings[0].id : null);
}, [headings]);
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => entry.target.id);
if (visible.length > 0) {
setActiveId(visible[0]);
}
},
{
rootMargin: "-10% 0px -70% 0px",
threshold: 0,
},
);
for (const heading of headings) {
const element = document.getElementById(heading.id);
if (element) observer.observe(element);
}
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<nav
aria-label="On this page"
className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4"
>
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">On this page</div>
<ul className="space-y-1 border-l border-border">
{headings.map((heading) => (
<li key={heading.id} className="text-sm">
<OutlineLink
heading={heading}
active={activeId === heading.id}
onActivate={setActiveId}
/>
</li>
))}
</ul>
</nav>
);
}

View File

@@ -1,3 +1,4 @@
import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
interface SiteFooterProps {
@@ -7,9 +8,10 @@ interface SiteFooterProps {
export function SiteFooter({ width = "default" }: SiteFooterProps) {
const widthClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0";
const alternatives = getAlternativePages();
return (
<footer className={`${widthClasses} mx-auto`}>
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-8 text-sm">
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-white/60 font-medium">Product</p>
<div className="space-y-2">
@@ -80,6 +82,20 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</a>
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Alternatives</p>
<div className="space-y-2">
{alternatives.map((page) => (
<a
key={page.slug}
href={page.href}
className="block text-muted-foreground hover:text-foreground transition-colors"
>
{page.name}
</a>
))}
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Community</p>
<div className="space-y-2">

View File

@@ -4,10 +4,10 @@ import { SiteHeader } from "~/components/site-header";
interface SiteShellProps {
children: ReactNode;
width?: "default" | "prose";
width: "default" | "prose";
}
export function SiteShell({ children, width = "default" }: SiteShellProps) {
export function SiteShell({ children, width }: SiteShellProps) {
const mainClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto";
return (

View File

@@ -2,7 +2,7 @@
title: Open Source Claude Desktop Alternative With Linux, Mobile, and Multi-Provider Support
description: Paseo is an open source Claude Desktop alternative for developers who want Linux, self-hosting, native mobile apps, and Claude Code alongside Codex, OpenCode, Copilot, and more.
nav: Claude Desktop
order: 106
order: 55
---
# Paseo vs Claude Desktop
@@ -115,4 +115,4 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel
| MCP server for orchestration | Yes | MCP support inside Claude Code |
| Self-hosted daemon | Yes | No |
See also: [Paseo vs Codex App](/docs/alternatives/codex-app), [Paseo vs OpenCode Desktop](/docs/alternatives/opencode-desktop), [Paseo vs Conductor](/docs/alternatives/conductor).
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor).

Some files were not shown because too many files have changed in this diff Show More