mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a556d6a1 | ||
|
|
fbd3dca7ed | ||
|
|
5b321c0644 | ||
|
|
b0ca08a452 | ||
|
|
8cd51c8a47 | ||
|
|
2c27f21a90 | ||
|
|
a929ea9726 | ||
|
|
c4d544baf6 | ||
|
|
889d058658 | ||
|
|
85fa38bdde | ||
|
|
736b2da1a2 | ||
|
|
9c43e0222a | ||
|
|
4824cfd05b | ||
|
|
7e46f6a647 | ||
|
|
fb0c5b27f3 | ||
|
|
a3951495d4 | ||
|
|
00a6e636fb | ||
|
|
9af18ef2f5 | ||
|
|
fd0793dc02 |
23
CHANGELOG.md
23
CHANGELOG.md
@@ -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
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256-62dqunclpCgWCsIyRV5DMjGdwUhPP4rbjEjSO9dJ7no=
|
||||
sha256-xOrVsNfFlrlBetLE1dpRsAiRwgHiLhw3EB3EqpHDTuw=
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.94",
|
||||
"version": "0.1.95",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
31
packages/app/src/attachments/file-types.test.ts
Normal file
31
packages/app/src/attachments/file-types.test.ts
Normal 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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
94
packages/app/src/attachments/file-types.ts
Normal file
94
packages/app/src/attachments/file-types.ts
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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], []);
|
||||
|
||||
@@ -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(
|
||||
() => [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
() => [
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
80
packages/app/src/components/project-picker-options.test.ts
Normal file
80
packages/app/src/components/project-picker-options.test.ts
Normal 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" }]);
|
||||
});
|
||||
});
|
||||
48
packages/app/src/components/project-picker-options.ts
Normal file
48
packages/app/src/components/project-picker-options.ts
Normal 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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
82
packages/app/src/components/ui/form-field.tsx
Normal file
82
packages/app/src/components/ui/form-field.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -179,6 +179,7 @@ function createFakeSendClient(
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
uploadFile: async () => ({ requestId: "test", file: null, error: null }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
145
packages/app/src/hooks/use-file-picker.ts
Normal file
145
packages/app/src/hooks/use-file-picker.ts
Normal 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 };
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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 d’ouvrir 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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}}"?',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -168,7 +168,7 @@ function comment(overrides: Partial<ReviewDraftComment> = {}): ReviewDraftCommen
|
||||
|
||||
describe("useInlineReviewController", () => {
|
||||
beforeEach(() => {
|
||||
useReviewDraftStore.setState({ drafts: {}, activeModesByScope: {} });
|
||||
useReviewDraftStore.setState({ drafts: {}, diffModeOverrides: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.94",
|
||||
"version": "0.1.95",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"]>({}),
|
||||
|
||||
@@ -512,6 +512,7 @@ function createSessionForWorkspaceTests(
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
},
|
||||
filesystem: { isDirectory: async () => true },
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: ",
|
||||
bodyHTML: `<p>Screenshot: <img alt="bug" src="${privateAttachmentUrl.replaceAll("&", "&")}" /></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: `,
|
||||
});
|
||||
});
|
||||
|
||||
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: "",
|
||||
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: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves GitHub attachment image URLs unchanged when rendered images do not match", async () => {
|
||||
const body = "";
|
||||
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: {
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function toPullRequestTimelineReviewThreadItems(
|
||||
thread: z.infer<typeof PullRequestReviewThreadNodeSchema>,
|
||||
): PullRequestTimelineItem[] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
38
packages/website/src/components/alternative-page.tsx
Normal file
38
packages/website/src/components/alternative-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
packages/website/src/components/docs-breadcrumbs.tsx
Normal file
34
packages/website/src/components/docs-breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
152
packages/website/src/components/docs-nav.tsx
Normal file
152
packages/website/src/components/docs-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/website/src/components/docs-outline.tsx
Normal file
109
packages/website/src/components/docs-outline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user