Merge branch 'main' into dev
Resolve 38 conflicts preserving both sides' work: - Server: DB-based registries (dev) + WorkspaceGitService/DaemonConfigStore (main) - App: script proxy/voice MCP (dev) + pin/hide tabs/bootstrap boundary (main) - Shared: WorkspaceScriptPayload (dev) + git/GitHub runtime schemas (main) - Deleted workspace-registry-bootstrap.ts (dev's DB approach supersedes)
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { splitMarkdownBlocks } from "../split-markdown-blocks";
|
||||
|
||||
describe("splitMarkdownBlocks", () => {
|
||||
it("returns a single block for a single paragraph", () => {
|
||||
expect(splitMarkdownBlocks("Hello world")).toEqual(["Hello world"]);
|
||||
});
|
||||
|
||||
it("splits two paragraphs separated by a double newline", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
"Second paragraph",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a fenced code block with internal double newlines as one block", () => {
|
||||
expect(splitMarkdownBlocks("```ts\nconst a = 1;\n\nconst b = 2;\n```")).toEqual([
|
||||
"```ts\nconst a = 1;\n\nconst b = 2;\n```",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat 4-space-indented backticks as a fence", () => {
|
||||
expect(splitMarkdownBlocks("Before\n\n ```\n code\n ```\n\nAfter")).toEqual([
|
||||
"Before",
|
||||
" ```\n code\n ```",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles tilde fences", () => {
|
||||
expect(splitMarkdownBlocks("Before\n\n~~~\ncode\n~~~\n\nAfter")).toEqual([
|
||||
"Before",
|
||||
"~~~\ncode\n~~~",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits mixed paragraph, code fence, and paragraph content into three blocks", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"Intro paragraph\n\n```ts\nconst a = 1;\n\nconst b = 2;\n```\n\nOutro paragraph",
|
||||
),
|
||||
).toEqual(["Intro paragraph", "```ts\nconst a = 1;\n\nconst b = 2;\n```", "Outro paragraph"]);
|
||||
});
|
||||
|
||||
it("keeps everything from an unclosed fence start as one block for streaming content", () => {
|
||||
expect(splitMarkdownBlocks("Before fence\n\n```ts\nconst a = 1;\n\nconst b = 2;")).toEqual([
|
||||
"Before fence",
|
||||
"```ts\nconst a = 1;\n\nconst b = 2;",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(splitMarkdownBlocks("")).toEqual([]);
|
||||
});
|
||||
|
||||
it("splits a heading followed by a paragraph into two blocks", () => {
|
||||
expect(splitMarkdownBlocks("# Heading\n\nParagraph text")).toEqual([
|
||||
"# Heading",
|
||||
"Paragraph text",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps consecutive list items together when there is no double newline", () => {
|
||||
expect(splitMarkdownBlocks("- First item\n- Second item\n- Third item")).toEqual([
|
||||
"- First item\n- Second item\n- Third item",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats triple newlines as a split point and filters empty blocks", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
"Second paragraph",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearAssistantImageMetadataCache,
|
||||
estimateAssistantMessageHeightFromCache,
|
||||
extractAssistantImageSources,
|
||||
getAssistantImageMetadata,
|
||||
setAssistantImageMetadata,
|
||||
} from "./assistant-image-metadata";
|
||||
|
||||
describe("assistant image metadata", () => {
|
||||
beforeEach(() => {
|
||||
clearAssistantImageMetadataCache();
|
||||
});
|
||||
|
||||
it("extracts markdown image sources", () => {
|
||||
expect(
|
||||
extractAssistantImageSources(
|
||||
'Before\n\n\n\n',
|
||||
),
|
||||
).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
|
||||
});
|
||||
|
||||
it("reuses cached metadata across canonical and raw source keys", () => {
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "/tmp/paseo-codex-screenshot.png",
|
||||
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||
serverId: "server-1",
|
||||
},
|
||||
{ width: 1200, height: 800 },
|
||||
);
|
||||
|
||||
expect(
|
||||
getAssistantImageMetadata({
|
||||
source: "/tmp/paseo-codex-screenshot.png",
|
||||
}),
|
||||
).toEqual({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
aspectRatio: 1.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("estimates assistant message height from cached image metadata", () => {
|
||||
setAssistantImageMetadata(
|
||||
{
|
||||
source: "https://example.com/landscape.png",
|
||||
},
|
||||
{ width: 1200, height: 800 },
|
||||
);
|
||||
|
||||
expect(
|
||||
estimateAssistantMessageHeightFromCache(
|
||||
"Here is the screenshot\n\n",
|
||||
),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
|
||||
export interface AssistantImageMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
|
||||
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
|
||||
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
|
||||
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
|
||||
|
||||
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
|
||||
const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
|
||||
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
|
||||
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
|
||||
const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
|
||||
const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
|
||||
|
||||
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
|
||||
cache.delete(key);
|
||||
cache.set(key, value);
|
||||
if (cache.size <= limit) {
|
||||
return;
|
||||
}
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAssistantImageSourceToken(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||
const inner = trimmed.slice(1, -1).trim();
|
||||
return inner || null;
|
||||
}
|
||||
|
||||
const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
|
||||
const source = titleMatch?.[1]?.trim() ?? trimmed;
|
||||
return source || null;
|
||||
}
|
||||
|
||||
function createSourceAliasKey(source: string): string {
|
||||
return `source:${source}`;
|
||||
}
|
||||
|
||||
function createResolutionKey(input: {
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}): string | null {
|
||||
const resolution = resolveAssistantImageSource({
|
||||
source: input.source,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
});
|
||||
if (!resolution) {
|
||||
return null;
|
||||
}
|
||||
if (resolution.kind === "direct") {
|
||||
return `direct:${resolution.uri}`;
|
||||
}
|
||||
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
|
||||
}
|
||||
|
||||
function getAssistantImageMetadataKeys(input: {
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}): string[] {
|
||||
const source = input.source.trim();
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = [createSourceAliasKey(source)];
|
||||
const resolutionKey = createResolutionKey(input);
|
||||
if (resolutionKey) {
|
||||
keys.unshift(resolutionKey);
|
||||
}
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
export function getAssistantImageMetadata(input: {
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
}): AssistantImageMetadata | null {
|
||||
for (const key of getAssistantImageMetadataKeys(input)) {
|
||||
const metadata = assistantImageMetadataCache.get(key);
|
||||
if (metadata) {
|
||||
touchCacheEntry(
|
||||
assistantImageMetadataCache,
|
||||
key,
|
||||
metadata,
|
||||
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
|
||||
);
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setAssistantImageMetadata(
|
||||
input: {
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
},
|
||||
dimensions: { width: number; height: number },
|
||||
): AssistantImageMetadata | null {
|
||||
const { width, height } = dimensions;
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata: AssistantImageMetadata = {
|
||||
width,
|
||||
height,
|
||||
aspectRatio: width / height,
|
||||
};
|
||||
|
||||
for (const key of getAssistantImageMetadataKeys(input)) {
|
||||
touchCacheEntry(
|
||||
assistantImageMetadataCache,
|
||||
key,
|
||||
metadata,
|
||||
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
|
||||
);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function extractAssistantImageSources(markdown: string): string[] {
|
||||
const cachedParse = assistantImageParseCache.get(markdown);
|
||||
if (cachedParse) {
|
||||
touchCacheEntry(
|
||||
assistantImageParseCache,
|
||||
markdown,
|
||||
cachedParse,
|
||||
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||
);
|
||||
return cachedParse.sources;
|
||||
}
|
||||
|
||||
const sources: string[] = [];
|
||||
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
|
||||
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
|
||||
if (normalized) {
|
||||
sources.push(normalized);
|
||||
}
|
||||
}
|
||||
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
|
||||
touchCacheEntry(
|
||||
assistantImageParseCache,
|
||||
markdown,
|
||||
{ sources, hasNonImageText },
|
||||
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
|
||||
const cachedParse = assistantImageParseCache.get(markdown);
|
||||
const parsed =
|
||||
cachedParse ??
|
||||
(() => {
|
||||
const sources = extractAssistantImageSources(markdown);
|
||||
const nextParsed = assistantImageParseCache.get(markdown);
|
||||
return nextParsed ?? { sources, hasNonImageText: true };
|
||||
})();
|
||||
if (parsed.sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const knownHeights = parsed.sources
|
||||
.map((source) => getAssistantImageMetadata({ source }))
|
||||
.filter((metadata): metadata is AssistantImageMetadata => metadata !== null)
|
||||
.map((metadata) =>
|
||||
Math.max(
|
||||
ASSISTANT_IMAGE_MIN_HEIGHT,
|
||||
Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
|
||||
),
|
||||
);
|
||||
|
||||
if (knownHeights.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseHeight = parsed.hasNonImageText
|
||||
? ASSISTANT_MESSAGE_BASE_HEIGHT
|
||||
: ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
|
||||
|
||||
const estimatedHeight =
|
||||
baseHeight +
|
||||
knownHeights.reduce((sum, height) => sum + height, 0) +
|
||||
ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length;
|
||||
|
||||
return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
|
||||
}
|
||||
|
||||
export function clearAssistantImageMetadataCache(): void {
|
||||
assistantImageMetadataCache.clear();
|
||||
assistantImageParseCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveAssistantImageSource } from "./assistant-image-source";
|
||||
|
||||
describe("resolveAssistantImageSource", () => {
|
||||
it("passes through direct image URIs", () => {
|
||||
expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({
|
||||
kind: "direct",
|
||||
uri: "https://example.com/image.png",
|
||||
});
|
||||
expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({
|
||||
kind: "direct",
|
||||
uri: "data:image/png;base64,abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the workspace root for relative paths", () => {
|
||||
expect(
|
||||
resolveAssistantImageSource({
|
||||
source: "screenshots/output.png",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "file_rpc",
|
||||
cwd: "/Users/test/project",
|
||||
path: "screenshots/output.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the workspace root for absolute paths inside the workspace", () => {
|
||||
expect(
|
||||
resolveAssistantImageSource({
|
||||
source: "/Users/test/project/screenshots/output.png",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "file_rpc",
|
||||
cwd: "/Users/test/project",
|
||||
path: "/Users/test/project/screenshots/output.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to filesystem root for absolute paths outside the workspace", () => {
|
||||
expect(
|
||||
resolveAssistantImageSource({
|
||||
source: "/tmp/paseo-codex-screenshot.png",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "file_rpc",
|
||||
cwd: "/",
|
||||
path: "/tmp/paseo-codex-screenshot.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes file URIs into file RPC requests", () => {
|
||||
expect(
|
||||
resolveAssistantImageSource({
|
||||
source: "file:///tmp/paseo-codex-screenshot.png",
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "file_rpc",
|
||||
cwd: "/",
|
||||
path: "/tmp/paseo-codex-screenshot.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the drive root for Windows absolute paths", () => {
|
||||
expect(
|
||||
resolveAssistantImageSource({
|
||||
source: "C:/Users/test/Desktop/screenshot.png",
|
||||
workspaceRoot: "D:/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "file_rpc",
|
||||
cwd: "C:/",
|
||||
path: "C:/Users/test/Desktop/screenshot.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { fileUriToPath } from "@/attachments/utils";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export type AssistantImageSourceResolution =
|
||||
| { kind: "direct"; uri: string }
|
||||
| { kind: "file_rpc"; cwd: string; path: string };
|
||||
|
||||
function trimTrailingSeparators(value: string): string {
|
||||
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
return value.replace(/[\\/]+$/, "");
|
||||
}
|
||||
|
||||
function normalizeForPathComparison(value: string): string {
|
||||
const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||
return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
|
||||
const candidate = normalizeForPathComparison(candidatePath);
|
||||
const root = normalizeForPathComparison(rootPath);
|
||||
if (!candidate || !root) {
|
||||
return false;
|
||||
}
|
||||
if (root === "/") {
|
||||
return candidate.startsWith("/");
|
||||
}
|
||||
if (candidate === root) {
|
||||
return true;
|
||||
}
|
||||
return candidate.startsWith(`${root}/`);
|
||||
}
|
||||
|
||||
function deriveFallbackRootFromAbsolutePath(value: string): string | null {
|
||||
if (value.startsWith("/")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
|
||||
if (driveMatch?.[1]) {
|
||||
return `${driveMatch[1]}/`;
|
||||
}
|
||||
|
||||
const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
|
||||
if (uncMatch?.[1]) {
|
||||
return uncMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveAssistantImageSource(input: {
|
||||
source: string;
|
||||
workspaceRoot?: string;
|
||||
}): AssistantImageSourceResolution | null {
|
||||
const source = input.source.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||
return { kind: "direct", uri: source };
|
||||
}
|
||||
|
||||
const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
|
||||
if (!sourcePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAbsolutePath(sourcePath)) {
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "file_rpc",
|
||||
cwd: workspaceRoot,
|
||||
path: sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceRoot = input.workspaceRoot?.trim();
|
||||
if (
|
||||
workspaceRoot &&
|
||||
isAbsolutePath(workspaceRoot) &&
|
||||
isPathWithinRoot(sourcePath, workspaceRoot)
|
||||
) {
|
||||
return {
|
||||
kind: "file_rpc",
|
||||
cwd: workspaceRoot,
|
||||
path: sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath);
|
||||
if (!fallbackRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "file_rpc",
|
||||
cwd: fallbackRoot,
|
||||
path: sourcePath,
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,7 @@ import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export type DesktopBadgeWorkspaceStatus = WorkspaceDescriptor["status"];
|
||||
|
||||
export function isWorkspaceActionableForDesktopBadge(
|
||||
status: DesktopBadgeWorkspaceStatus,
|
||||
): boolean {
|
||||
export function isWorkspaceActionableForDesktopBadge(status: DesktopBadgeWorkspaceStatus): boolean {
|
||||
return status === "attention" || status === "needs_input" || status === "failed";
|
||||
}
|
||||
|
||||
|
||||
@@ -95,9 +95,11 @@ function useRawWindowControlsPadding(): RawWindowControlsPadding {
|
||||
}, [isFullscreen]);
|
||||
}
|
||||
|
||||
export function useWindowControlsPadding(
|
||||
role: WindowControlsPaddingRole,
|
||||
): { left: number; right: number; top: number } {
|
||||
export function useWindowControlsPadding(role: WindowControlsPaddingRole): {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
} {
|
||||
const sidebarOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const explorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const focusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatDiffContentText, formatDiffGutterText, hasVisibleDiffTokens } from "./diff-rendering";
|
||||
import {
|
||||
formatDiffContentText,
|
||||
formatDiffGutterText,
|
||||
hasVisibleDiffTokens,
|
||||
} from "./diff-rendering";
|
||||
|
||||
describe("diff-rendering", () => {
|
||||
it("keeps header gutters tall even when they do not show a line number", () => {
|
||||
|
||||
@@ -314,11 +314,7 @@ export function buildHostWorkspaceOpenRoute(
|
||||
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}` as const;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
workspaceId?: string,
|
||||
) {
|
||||
export function buildHostAgentDetailRoute(serverId: string, agentId: string, workspaceId?: string) {
|
||||
const normalizedWorkspaceId = trimNonEmpty(workspaceId);
|
||||
if (normalizedWorkspaceId) {
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export function isAbsolutePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value)
|
||||
);
|
||||
return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ interface SidebarAnimationTargets {
|
||||
|
||||
export function shouldSyncSidebarAnimation(input: SidebarAnimationSyncInput): boolean {
|
||||
return (
|
||||
input.previousIsOpen !== input.nextIsOpen ||
|
||||
input.previousWindowWidth !== input.nextWindowWidth
|
||||
input.previousIsOpen !== input.nextIsOpen || input.previousWindowWidth !== input.nextWindowWidth
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "paseo",
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
@@ -34,7 +33,6 @@ function project(overrides: Partial<SidebarProjectEntry> = {}): SidebarProjectEn
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 1,
|
||||
latestActivityAt: null,
|
||||
workspaces: [workspace()],
|
||||
...overrides,
|
||||
};
|
||||
@@ -132,7 +130,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true only for single-workspace directory projects", () => {
|
||||
it("returns true only for single-workspace non-git projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(false);
|
||||
|
||||
@@ -50,8 +50,7 @@ export function buildSidebarProjectRowModel(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const collapsible =
|
||||
input.project.projectKind === "git" || input.project.workspaces.length > 1;
|
||||
const collapsible = input.project.projectKind === "git" || input.project.workspaces.length > 1;
|
||||
|
||||
return {
|
||||
kind: "project_section",
|
||||
|
||||
@@ -14,7 +14,6 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
@@ -31,7 +30,6 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: workspaces.length,
|
||||
latestActivityAt: null,
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
@@ -79,7 +77,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("respects collapsed state for single-workspace git projects", () => {
|
||||
it("still excludes collapsed single-workspace git projects because they are not flattened", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
function getFenceDelimiter(line: string) {
|
||||
const match = /^( {0,3})(`{3,}|~{3,})/.exec(line);
|
||||
return match?.[2] ?? null;
|
||||
}
|
||||
|
||||
export function splitMarkdownBlocks(text: string): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
let currentLines: string[] = [];
|
||||
let activeFenceCharacter: "`" | "~" | null = null;
|
||||
let activeFenceLength = 0;
|
||||
let sawBlockSeparator = false;
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
const isBlankLine = line.trim().length === 0;
|
||||
|
||||
if (!activeFenceCharacter && isBlankLine) {
|
||||
if (currentLines.length > 0) {
|
||||
sawBlockSeparator = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activeFenceCharacter && sawBlockSeparator) {
|
||||
blocks.push(currentLines.join("\n"));
|
||||
currentLines = [];
|
||||
sawBlockSeparator = false;
|
||||
}
|
||||
|
||||
currentLines.push(line);
|
||||
|
||||
const fenceDelimiter = getFenceDelimiter(line);
|
||||
if (!fenceDelimiter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activeFenceCharacter) {
|
||||
activeFenceCharacter = fenceDelimiter[0] as "`" | "~";
|
||||
activeFenceLength = fenceDelimiter.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fenceDelimiter[0] === activeFenceCharacter && fenceDelimiter.length >= activeFenceLength) {
|
||||
activeFenceCharacter = null;
|
||||
activeFenceLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLines.length > 0) {
|
||||
blocks.push(currentLines.join("\n"));
|
||||
}
|
||||
|
||||
return blocks.filter((block) => block.length > 0);
|
||||
}
|
||||
@@ -57,7 +57,9 @@ describe("terminal key helpers", () => {
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Escape",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -65,7 +67,9 @@ describe("terminal key helpers", () => {
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
@@ -73,7 +77,9 @@ describe("terminal key helpers", () => {
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "c",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: true, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
@@ -81,12 +87,73 @@ describe("terminal key helpers", () => {
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Escape",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: true },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("intercepts Enter with DOM shift modifier for CSI u encoding", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
shiftKey: true,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("intercepts Enter with any DOM modifier for CSI u encoding", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
ctrlKey: true,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: true,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: true,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not intercept plain Enter without modifiers", () => {
|
||||
expect(
|
||||
shouldInterceptDomTerminalKey({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
pendingModifiers: { ctrl: false, shift: false, alt: false },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("detects pending modifier state", () => {
|
||||
expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe(false);
|
||||
expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe(true);
|
||||
|
||||
@@ -85,10 +85,21 @@ export function hasPendingTerminalModifiers(modifiers: PendingTerminalModifiers)
|
||||
export function shouldInterceptDomTerminalKey(args: {
|
||||
key: string;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
altKey: boolean;
|
||||
metaKey: boolean;
|
||||
pendingModifiers: PendingTerminalModifiers;
|
||||
}): boolean {
|
||||
return hasPendingTerminalModifiers(args.pendingModifiers);
|
||||
if (hasPendingTerminalModifiers(args.pendingModifiers)) {
|
||||
return true;
|
||||
}
|
||||
// xterm.js sends plain \r for Enter regardless of modifiers.
|
||||
// Intercept modified Enter so it gets CSI u encoding (Kitty keyboard protocol),
|
||||
// which Claude Code and other TUI apps use for Shift+Enter newlines.
|
||||
if (args.key === "Enter" && (args.shiftKey || args.ctrlKey || args.altKey || args.metaKey)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function mergeTerminalModifiers(args: {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
Wrench,
|
||||
} from "lucide-react-native";
|
||||
import type { ToolCallDetail, ToolCallIconName } from "@server/server/agent/agent-sdk-types";
|
||||
import { isPaseoToolName } from "@server/server/agent/tool-name-normalization";
|
||||
import { PaseoLogo } from "@/components/icons/paseo-logo";
|
||||
|
||||
export type ToolCallIconComponent = ComponentType<{ size?: number; color?: string }>;
|
||||
|
||||
@@ -57,6 +59,9 @@ export function resolveToolCallIcon(
|
||||
if (lowerName === "speak") {
|
||||
return MicVocal;
|
||||
}
|
||||
if (isPaseoToolName(lowerName)) {
|
||||
return PaseoLogo;
|
||||
}
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ function workspace(
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
status: input.status ?? "done",
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ function createWorkspace(
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name ?? "main",
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
scripts: input.scripts ?? [],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user