Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ node_modules
apps/*/dist
.astro
packages/*/dist
# all build output, any package depth (e.g. pixso-move/*/dist)
dist/
.env
.env.local
build/
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { useEffect, type ReactNode } from "react";
import { useNavigate } from "@tanstack/react-router";

import ThreadSidebar from "./Sidebar";
import { RightPanelSheet } from "./RightPanelSheet";
import { Sidebar, SidebarProvider, SidebarRail } from "./ui/sidebar";
import { useMediaQuery } from "../hooks/useMediaQuery";
import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout";
import { PixsoPanel, PixsoPanelInlineSidebar, usePixsoStore } from "../ru-fork/pixso-move";
import {
clearShortcutModifierState,
syncShortcutModifierStateFromKeyboardEvent,
Expand All @@ -13,6 +17,9 @@ const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16;
const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16;
export function AppSidebarLayout({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const pixsoOpen = usePixsoStore((state) => state.panelOpen);
const closePixso = usePixsoStore((state) => state.closePanel);
const useRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY);

useEffect(() => {
const onWindowKeyDown = (event: KeyboardEvent) => {
Expand Down Expand Up @@ -70,6 +77,13 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) {
<SidebarRail />
</Sidebar>
{children}
{useRightPanelSheet ? (
<RightPanelSheet open={pixsoOpen} onClose={closePixso}>
<PixsoPanel onClose={closePixso} mode="sheet" />
</RightPanelSheet>
) : (
<PixsoPanelInlineSidebar open={pixsoOpen} onClose={closePixso} />
)}
</SidebarProvider>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TerminalIcon,
TriangleAlertIcon,
} from "lucide-react";
import { PixsoNavGroup } from "../ru-fork/pixso-move";
import {
ChangeRequestStatusIcon,
prStatusIndicator,
Expand Down Expand Up @@ -2657,6 +2658,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
<PixsoNavGroup />
{showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? (
<SidebarGroup className="px-2 pt-2 pb-0">
<Alert variant="warning" className="rounded-2xl border-warning/40 bg-warning/8">
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/ru-fork/pixso-move/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Pixso Move — typed HTTP client for the pixso-move server (a separate Effect/sqlite service,
* default http://127.0.0.1:7787). Every read is gated by the `x-designer-id` header, which is
* the designer's shared key. Kept dependency-free (plain fetch) and isolated in ru-fork.
*/

export interface PixsoNodeSummary {
readonly nodeId: string;
readonly rootName: string;
readonly addedAt: string;
/** base64 PNG (no data: prefix). */
readonly preview: string;
}

export interface PixsoNodeRecord {
readonly nodeId: string;
readonly designerId: string;
readonly rootName: string;
readonly nodesJson: string;
readonly preview: string;
readonly addedAt: string;
}

export type PixsoProcessingStatus = "pending" | "processing" | "done" | "error";

export interface PixsoProcessingResult {
readonly nodeId: string;
readonly resultTag: string;
readonly status: PixsoProcessingStatus;
readonly attempts: number;
readonly result: string | null;
readonly error: string | null;
readonly createdAt: string;
readonly startedAt: string | null;
readonly finishedAt: string | null;
}

const base = (serverUrl: string): string => serverUrl.trim().replace(/\/+$/, "");

const getJson = async <T>(serverUrl: string, designerId: string, path: string): Promise<T> => {
const response = await fetch(`${base(serverUrl)}${path}`, {
headers: { "x-designer-id": designerId },
});
if (!response.ok) {
throw new Error(`Сервер ответил ${response.status}`);
}
return (await response.json()) as T;
};

export const fetchNodes = (serverUrl: string, designerId: string) =>
getJson<ReadonlyArray<PixsoNodeSummary>>(serverUrl, designerId, "/nodes");

export const fetchNode = (serverUrl: string, designerId: string, nodeId: string) =>
getJson<PixsoNodeRecord>(serverUrl, designerId, `/node?id=${encodeURIComponent(nodeId)}`);

export const fetchProcessing = (serverUrl: string, designerId: string, nodeId: string) =>
getJson<ReadonlyArray<PixsoProcessingResult>>(
serverUrl,
designerId,
`/processing-data?nodeId=${encodeURIComponent(nodeId)}`,
);

export const previewDataUrl = (preview: string): string => `data:image/png;base64,${preview}`;
49 changes: 49 additions & 0 deletions apps/web/src/ru-fork/pixso-move/components/CodeCollapsible.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ChevronDownIcon } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Collapsible, CollapsibleContent } from "~/components/ui/collapsible";
import { cn } from "~/lib/utils";

/**
* A collapsible code panel, matching the MCP `ToolList` accordion exactly: a single rounded,
* `overflow-hidden` border; a plain header button (title + optional trailing badge + a chevron
* that rotates with the controlled open state); and the body separated by a top border (no
* second border / corners of its own). The body is height-capped and vertically scrollable.
*/
export function CodeCollapsible({
title,
trailing,
defaultOpen = false,
children,
}: {
title: ReactNode;
trailing?: ReactNode;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);

return (
<div className="overflow-hidden rounded-md border border-border/60 bg-background/40">
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left"
aria-expanded={open}
>
<span className="min-w-0 flex-1 truncate">{title}</span>
{trailing}
<ChevronDownIcon
className={cn(
"size-3.5 shrink-0 text-muted-foreground/60 transition-transform",
open && "rotate-180",
)}
/>
</button>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleContent>
<div className="max-h-60 overflow-y-auto border-t border-border/50">{children}</div>
</CollapsibleContent>
</Collapsible>
</div>
);
}
109 changes: 109 additions & 0 deletions apps/web/src/ru-fork/pixso-move/components/GalleryView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { LayersIcon } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyMedia,
EmptyTitle,
} from "~/components/ui/empty";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Spinner } from "~/components/ui/spinner";
import { cn } from "~/lib/utils";
import { previewDataUrl } from "../api";
import { formatAddedAt } from "../format";
import { usePixsoNodes } from "../queries";
import { usePixsoStore } from "../store";

/** Catalog of stored macets. Fetches only after the user presses refresh (manual sync). */
export function GalleryView() {
const settings = usePixsoStore((state) => state.settings);
const nonce = usePixsoStore((state) => state.refreshNonce);
const openNode = usePixsoStore((state) => state.openNode);
const openSettings = usePixsoStore((state) => state.openSettings);
const hasKey = settings.designerId.trim().length > 0;
const query = usePixsoNodes(settings.serverUrl, settings.designerId, nonce);

if (!hasKey) {
return (
<Empty className="flex-1">
<EmptyMedia variant="icon">
<LayersIcon />
</EmptyMedia>
<EmptyTitle>Pixso Move</EmptyTitle>
<EmptyDescription>
Используйте макеты прямо из Pixso при помощи Pixso Move. Добавьте идентификатор в
настройках.
</EmptyDescription>
<EmptyContent>
<Button size="sm" variant="outline" onClick={openSettings}>
Открыть настройки
</Button>
</EmptyContent>
</Empty>
);
}

if (nonce === 0) {
return (
<Empty className="flex-1">
<EmptyTitle>Нет данных</EmptyTitle>
<EmptyDescription>Нажмите «Обновить», чтобы загрузить макеты.</EmptyDescription>
</Empty>
);
}

if (query.isPending) {
return (
<div className="flex flex-1 items-center justify-center">
<Spinner />
</div>
);
}

if (query.isError) {
return (
<Empty className="flex-1">
<EmptyTitle>Не удалось загрузить</EmptyTitle>
<EmptyDescription>{(query.error as Error).message}</EmptyDescription>
</Empty>
);
}

if (query.data.length === 0) {
return (
<Empty className="flex-1">
<EmptyTitle>Пока пусто</EmptyTitle>
<EmptyDescription>Отправьте макет из плагина Pixso.</EmptyDescription>
</Empty>
);
}

return (
<ScrollArea className="min-h-0 flex-1">
<ul className="grid grid-cols-2 gap-2 p-2">
{query.data.map((node) => (
<li key={node.nodeId}>
<button
type="button"
onClick={() => openNode(node.nodeId)}
className={cn(
"group flex w-full flex-col overflow-hidden rounded-lg border border-border text-left transition-colors hover:border-ring",
)}
>
<img
src={previewDataUrl(node.preview)}
alt={node.rootName}
className="aspect-video w-full bg-white object-contain"
/>
<span className="truncate px-2 pt-1.5 text-xs font-medium">{node.rootName}</span>
<span className="px-2 pb-1.5 text-[10px] text-muted-foreground">
{formatAddedAt(node.addedAt)}
</span>
</button>
</li>
))}
</ul>
</ScrollArea>
);
}
104 changes: 104 additions & 0 deletions apps/web/src/ru-fork/pixso-move/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ChevronLeftIcon } from "lucide-react";
import type { ReactNode } from "react";
import ChatMarkdown from "~/components/ChatMarkdown";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { Spinner } from "~/components/ui/spinner";
import { previewDataUrl } from "../api";
import { usePixsoNode, usePixsoProcessing } from "../queries";
import { usePixsoStore } from "../store";
import { CodeCollapsible } from "./CodeCollapsible";
import { ResultBlock } from "./ResultBlock";

/** A node's detail: preview, its raw node JSON (formatted), and the LLM result blocks. */
export function NodeDetail() {
const settings = usePixsoStore((state) => state.settings);
const nodeId = usePixsoStore((state) => state.selectedNodeId);
const back = usePixsoStore((state) => state.backToGallery);
const node = usePixsoNode(settings.serverUrl, settings.designerId, nodeId);
const processing = usePixsoProcessing(settings.serverUrl, settings.designerId, nodeId);

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-2 py-2">
<Button variant="ghost" size="sm" onClick={back}>
<ChevronLeftIcon className="size-4" />
Макеты
</Button>
</div>

<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-3">
{node.isPending ? (
<div className="flex justify-center py-6">
<Spinner />
</div>
) : node.isError ? (
<p className="text-sm text-destructive">{(node.error as Error).message}</p>
) : (
<>
<h3 className="truncate text-sm font-semibold">{node.data.rootName}</h3>
<img
src={previewDataUrl(node.data.preview)}
alt={node.data.rootName}
className="w-full rounded-lg border border-border bg-white object-contain"
/>
<CodeCollapsible
defaultOpen
title={
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
JSON узла
</span>
}
>
<ChatMarkdown
text={`\`\`\`json\n${formatJson(node.data.nodesJson)}\n\`\`\``}
cwd={undefined}
/>
</CodeCollapsible>
</>
)}

<Section title="Результаты обработки">
{processing.isPending ? (
<Spinner />
) : processing.isError ? (
<p className="text-xs text-muted-foreground">Не удалось загрузить результаты.</p>
) : processing.data.length === 0 ? (
<p className="text-xs text-muted-foreground">Пока нет результатов.</p>
) : (
<ul className="space-y-2">
{processing.data.map((result) => (
<li key={result.resultTag}>
<ResultBlock result={result} />
</li>
))}
</ul>
)}
</Section>
</div>
</ScrollArea>
</div>
);
}

// Pretty-print the stored (minified) node JSON so the code block renders multi-line. The
// code renderer only highlights — it doesn't re-indent — so this is what makes it readable.
function formatJson(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}

function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="space-y-1.5">
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{title}
</h4>
{children}
</section>
);
}
Loading
Loading